From 256f5348cc122d4215700cc6a267ae79f73601c5 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 01:04:36 -0400 Subject: [PATCH 1/6] feat(desktop-interception): PRD-023e lane primitives (wave 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation modules for app-scoped isolated lanes; no router/dashboard wiring yet (wave 2-3). - app-targets.ts: DesktopAppLane model + DESKTOP_APP_LANES table (claude-native app-scoped-proxy, claude-legacy-gateway config-only, codex-desktop config-only) with getLaneById/laneOwnsHost/laneForApp/ lanesForHost. Egress exclusivity: api.openai.com never in the claude lane, api.anthropic.com never in the codex lane. - os-proxy.ts: real WindowsOsProxyAdapter + MacosOsProxyAdapter (snapshot/apply/restore via reg query/networksetup), createOsProxyAdapter factory; noopOsProxyAdapter unchanged. Hard-rollback snapshot seam. - rival-apps.ts: detectRunningRivalApps (ChatGPT/Codex), formatRivalAppWarning, assertNoRivalAppsRunning(override?) — the global-proxy block gate. - app-launch.ts: openClaudeAppWithProxy / launchOrRestartClaudeAppWithProxy via Electron --proxy-server (per-app, never system proxy); _internals testability seam extended. Tests: 47 new (app-targets 13, os-proxy 18, rival-apps 9, app-launch 7). typecheck clean; full desktop suite 12 files / 89 tests pass. --- src/claude-desktop/app-launch.ts | 100 +++++++ src/desktop-interception/app-targets.ts | 129 +++++++++ src/desktop-interception/os-proxy.ts | 172 ++++++++++++ src/desktop-interception/rival-apps.ts | 73 +++++ tests/claude-app-launch-proxy.test.ts | 180 +++++++++++++ .../desktop-interception-app-targets.test.ts | 136 ++++++++++ tests/desktop-interception-os-proxy.test.ts | 254 ++++++++++++++++++ tests/desktop-interception-rival-apps.test.ts | 116 ++++++++ 8 files changed, 1160 insertions(+) create mode 100644 src/desktop-interception/rival-apps.ts create mode 100644 tests/claude-app-launch-proxy.test.ts create mode 100644 tests/desktop-interception-app-targets.test.ts create mode 100644 tests/desktop-interception-os-proxy.test.ts create mode 100644 tests/desktop-interception-rival-apps.test.ts diff --git a/src/claude-desktop/app-launch.ts b/src/claude-desktop/app-launch.ts index 47b2ec0..ce3b714 100644 --- a/src/claude-desktop/app-launch.ts +++ b/src/claude-desktop/app-launch.ts @@ -224,3 +224,103 @@ export async function launchOrRestartClaudeApp( if (appPath) openClaudeAppAt(appPath); else openClaudeApp(); } + +/** + * Build the Chromium/Electron `--proxy-server` argument that scopes the proxy + * to the Claude Desktop process only. Per-app, never system-wide. + * + * Exported so tests can assert the exact arg shape without spawning a process. + */ +export function buildClaudeProxyArg(proxyPort: number): string { + return `--proxy-server=http://127.0.0.1:${proxyPort}`; +} + +/** + * Internal, mutable indirection so tests can deterministically intercept the + * `spawn`/`findClaudeApp` used by the proxy launchers under ESM live bindings. + * `vi.spyOn(_internals, 'spawn')` / `_internals.findClaudeApp` is the seam. + */ +export const _internals = { + spawn, + findClaudeApp, + isClaudeAppRunning, +}; + +/** + * Launch Claude Desktop with a per-app HTTP proxy scoped to the Claude process + * only, via the Electron/Chromium `--proxy-server` flag. + * + * IMPORTANT: this NEVER sets a system-wide proxy (no `reg add ...Internet + * Settings`, no `networksetup`, no `HTTP_PROXY`/`HTTPS_PROXY` env mutation). + * + * - darwin: `open --args --proxy-server=...` forwards the flag to the + * inner Electron binary. + * - win32: when the resolved path is a `.exe`, spawn it with the flag. When it + * is a `shell:AppsFolder\` URI the flag cannot be passed reliably, so we + * throw rather than fall back to a system proxy. + * - other platforms: throw via `claudeAppSupported()`. + */ +export function openClaudeAppWithProxy(proxyPort: number): void { + claudeAppSupported(); + + const path = _internals.findClaudeApp(); + if (!path) { + throw new Error('Claude Desktop App not found. Please install it first.'); + } + + const proxyArg = buildClaudeProxyArg(proxyPort); + + if (process.platform === 'darwin') { + // `open ... --args` forwards the extra args to the launched application, + // reaching the embedded Electron/Chromium process. + _internals + .spawn('open', [path, '--args', proxyArg], { stdio: 'ignore', detached: true }) + .unref(); + return; + } + + if (process.platform === 'win32') { + if (path.startsWith('shell:AppsFolder\\')) { + throw new Error( + 'Per-app proxy launch is not supported for shell:AppsFolder-packaged Claude; use the .exe path or a wrapper.', + ); + } + _internals + .spawn(path, [proxyArg], { stdio: 'ignore', detached: true }) + .unref(); + return; + } + + // Unreachable: claudeAppSupported() throws above on unsupported platforms. + throw new Error('Claude Desktop launch is supported on macOS and Windows only.'); +} + +/** + * Mirrors `launchOrRestartClaudeApp`, but (re)launches Claude Desktop with the + * per-app proxy flag instead of a plain launch. Same quit/wait/confirm flow. + */ +export async function launchOrRestartClaudeAppWithProxy( + proxyPort: number, + prompt = 'Restart Claude Desktop to apply rflectr settings?', +): Promise { + if (!_internals.isClaudeAppRunning()) { + openClaudeAppWithProxy(proxyPort); + return; + } + + const restart = await p.confirm({ message: prompt, initialValue: true }); + if (p.isCancel(restart) || !restart) { + p.log.info('Quit and reopen Claude Desktop when you are ready for the new model to take effect.'); + return; + } + + if (process.platform === 'darwin') darwinQuit(); + else winQuitGraceful(); + + if (!(await waitForQuit(5000))) { + if (process.platform === 'win32') winForceQuit(); + await waitForQuit(5000); + } + + openClaudeAppWithProxy(proxyPort); +} diff --git a/src/desktop-interception/app-targets.ts b/src/desktop-interception/app-targets.ts index 7d61146..58f50bd 100644 --- a/src/desktop-interception/app-targets.ts +++ b/src/desktop-interception/app-targets.ts @@ -34,3 +34,132 @@ function normalizeList(values: readonly string[] | undefined): string[] { function normalizeString(value: string | undefined): string { return value?.trim().toLowerCase() ?? ''; } + +// --- PRD-023e: app-scoped isolated lanes ------------------------------------ + +/** + * Identity of an app-scoped desktop interception lane. Each lane is an + * isolated bundle of hosts, runtime state, and lifecycle controls so that a + * Claude lane can provably exclude rival-app hosts (e.g. `api.openai.com`). + */ +export type LaneId = 'claude-native' | 'claude-legacy-gateway' | 'codex-desktop'; + +/** + * How a lane attaches to its target desktop app. + * - `native-interception`: in-process/MITM interception of the app's egress. + * - `config-profile`: writes a profile into the app's config (no interception). + */ +export type AttachMechanism = 'native-interception' | 'config-profile'; + +/** + * Isolation contract for a lane. + * - `app-scoped-proxy`: the lane owns an app-scoped proxy surface; its hosts + * MUST be provably exclusive to this lane. + * - `config-only`: the lane only writes config and makes no egress claims. + */ +export type LaneIsolation = 'app-scoped-proxy' | 'config-only'; + +/** + * A single app-scoped interception lane. `supportedHosts` is the upper bound + * of hosts this lane MAY intercept; `ownedState` is the runtime/state field + * prefixes this lane owns (so lanes cannot trample each other's state). + */ +export interface DesktopAppLane { + readonly laneId: LaneId; + readonly appName: 'Claude Desktop' | 'Codex Desktop'; + readonly attachMechanism: AttachMechanism; + readonly isolation: LaneIsolation; + readonly supportedHosts: readonly string[]; // upper bound of hosts this lane may intercept + readonly ownedState: readonly string[]; // runtime/state field prefixes this lane owns + readonly controls: readonly ('start' | 'stop' | 'uninstall' | 'revert' | 'verify' | 'kill')[]; + readonly preferred: boolean; +} + +/** + * The three app-scoped lanes. Order is significant: it is the resolution + * preference when multiple lanes could claim an app (preferred lanes first). + */ +export const DESKTOP_APP_LANES: readonly DesktopAppLane[] = [ + { + laneId: 'claude-native', + appName: 'Claude Desktop', + attachMechanism: 'native-interception', + isolation: 'app-scoped-proxy', + supportedHosts: ['api.anthropic.com', 'claude.ai'], + ownedState: ['claudeNative'], + controls: ['verify', 'start', 'stop', 'uninstall'], + preferred: true, + }, + { + laneId: 'claude-legacy-gateway', + appName: 'Claude Desktop', + attachMechanism: 'config-profile', + isolation: 'config-only', + supportedHosts: ['api.anthropic.com', 'claude.ai'], + ownedState: ['claudeDesktop'], + controls: ['revert'], + preferred: false, + }, + { + laneId: 'codex-desktop', + appName: 'Codex Desktop', + attachMechanism: 'config-profile', + isolation: 'config-only', + supportedHosts: ['api.openai.com'], + ownedState: ['codexProxy'], + controls: ['start', 'stop', 'revert'], + preferred: false, + }, +]; + +/** + * Resolve a lane by id. + * @returns the lane, or `undefined` if `laneId` is unknown. + */ +export function getLaneById(laneId: LaneId): DesktopAppLane | undefined { + return DESKTOP_APP_LANES.find(lane => lane.laneId === laneId); +} + +/** Canonicalize a host for lane matching: trim, lowercase, strip trailing dot. */ +function canonicalizeLaneHost(host: string): string { + return host.trim().replace(/\.$/, '').toLowerCase(); +} + +/** + * Single host-match predicate shared by `laneOwnsHost` and `lanesForHost`, + * mirroring the subdomain rule in `claude-target.ts` + * (`host === required || host.endsWith('.' + required)`). + */ +function laneHostMatches(canonicalHost: string, requiredHost: string): boolean { + return canonicalHost === requiredHost || canonicalHost.endsWith(`.${requiredHost}`); +} + +/** + * Whether a lane owns `host` (exact or subdomain of a supportedHost). + * This is the egress-exclusivity check at the single-lane granularity: a + * `claude-native` host like `api.anthropic.com` MUST NOT be owned by a rival + * lane (e.g. `codex-desktop`). + */ +export function laneOwnsHost(laneId: LaneId, host: string): boolean { + const lane = getLaneById(laneId); + if (!lane) return false; + const canonical = canonicalizeLaneHost(host); + return lane.supportedHosts.some(required => laneHostMatches(canonical, required)); +} + +/** All lanes targeting the given app name. */ +export function laneForApp(appName: string): DesktopAppLane[] { + return DESKTOP_APP_LANES.filter(lane => lane.appName === appName); +} + +/** + * Every lane whose supportedHosts include `host`. This is the + * egress-exclusivity check across the whole table: a rival-app host should + * resolve to only its own lane, never the claude lane. + */ +export function lanesForHost(host: string): LaneId[] { + const canonical = canonicalizeLaneHost(host); + return DESKTOP_APP_LANES + .filter(lane => lane.supportedHosts.some(required => laneHostMatches(canonical, required))) + .map(lane => lane.laneId); +} diff --git a/src/desktop-interception/os-proxy.ts b/src/desktop-interception/os-proxy.ts index 72ae230..a6d9a0b 100644 --- a/src/desktop-interception/os-proxy.ts +++ b/src/desktop-interception/os-proxy.ts @@ -1,3 +1,5 @@ +import { execSync } from 'node:child_process'; + export interface OsProxySnapshot { readonly mode: 'unknown' | 'direct' | 'manual'; readonly host?: string; @@ -16,3 +18,173 @@ export const noopOsProxyAdapter: OsProxyAdapter = { return { mode: 'unknown' }; }, }; + +const WIN_INTERNET_SETTINGS = + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'; +const WIN_READ_STDIO: ['pipe', 'pipe', 'pipe'] = ['pipe', 'pipe', 'pipe']; + +/** + * Windows adapter. Reads / writes the per-user WinINet proxy settings under + * HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings via `reg`. + */ +export class WindowsOsProxyAdapter implements OsProxyAdapter { + private snapshot?: OsProxySnapshot; + + read(): OsProxySnapshot { + try { + const out = execSync(`reg query "${WIN_INTERNET_SETTINGS}"`, { + encoding: 'utf8', + stdio: WIN_READ_STDIO, + }); + const enableMatch = out.match(/ProxyEnable\s+REG_DWORD\s+0x([0-9a-fA-F]+)/); + const enabled = enableMatch !== null && enableMatch[1] === '1'; + if (!enabled) { + return { mode: 'direct' }; + } + const serverMatch = out.match(/ProxyServer\s+REG_SZ\s+(\S+)/); + if (serverMatch === null) { + return { mode: 'manual' }; + } + const [host, portStr] = serverMatch[1].split(':'); + const port = portStr !== undefined && portStr !== '' ? Number(portStr) : undefined; + return { + mode: 'manual', + host, + ...(port !== undefined ? { port } : {}), + }; + } catch { + // reg missing / query failed: surface as unknown so callers can react. + return { mode: 'unknown' }; + } + } + + apply(next: OsProxySnapshot): void { + if (this.snapshot === undefined) { + this.snapshot = this.read(); + } + try { + if (next.mode === 'manual' && next.host !== undefined && next.port !== undefined) { + execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 1 /f`, { + stdio: WIN_READ_STDIO, + }); + execSync( + `reg add "${WIN_INTERNET_SETTINGS}" /v ProxyServer /t REG_SZ /d "${next.host}:${next.port}" /f`, + { stdio: WIN_READ_STDIO }, + ); + } else if (next.mode === 'direct') { + execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 0 /f`, { + stdio: WIN_READ_STDIO, + }); + } + } catch { + // best-effort application; read() reports the resulting state. + } + } + + restore(previous?: OsProxySnapshot): void { + const snap = previous ?? this.snapshot; + if (snap === undefined) { + return; + } + try { + if (snap.mode === 'manual' && snap.host !== undefined && snap.port !== undefined) { + execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 1 /f`, { + stdio: WIN_READ_STDIO, + }); + execSync( + `reg add "${WIN_INTERNET_SETTINGS}" /v ProxyServer /t REG_SZ /d "${snap.host}:${snap.port}" /f`, + { stdio: WIN_READ_STDIO }, + ); + } else if (snap.mode === 'direct') { + execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 0 /f`, { + stdio: WIN_READ_STDIO, + }); + } + } catch { + // best-effort restore; idempotent. + } + } +} + +/** + * macOS adapter. Reads / writes the Wi-Fi web proxy via `networksetup`. + */ +export class MacosOsProxyAdapter implements OsProxyAdapter { + private snapshot?: OsProxySnapshot; + + read(): OsProxySnapshot { + try { + const out = execSync('networksetup -getwebproxy "Wi-Fi"', { encoding: 'utf8' }); + const enabled = /Enabled:\s*Yes/i.test(out); + if (!enabled) { + return { mode: 'direct' }; + } + const serverMatch = out.match(/Server:\s*(\S+)/); + const portMatch = out.match(/Port:\s*(\d+)/); + const host = serverMatch !== null ? serverMatch[1] : undefined; + const port = portMatch !== null ? Number(portMatch[1]) : undefined; + if (host === undefined) { + return { mode: 'manual' }; + } + return { + mode: 'manual', + host, + ...(port !== undefined ? { port } : {}), + }; + } catch { + // networksetup missing / failed: surface as unknown. + return { mode: 'unknown' }; + } + } + + apply(next: OsProxySnapshot): void { + if (this.snapshot === undefined) { + this.snapshot = this.read(); + } + try { + if (next.mode === 'manual' && next.host !== undefined && next.port !== undefined) { + execSync(`networksetup -setwebproxy "Wi-Fi" ${next.host} ${next.port}`, { + stdio: WIN_READ_STDIO, + }); + execSync('networksetup -setwebproxystate "Wi-Fi" on', { stdio: WIN_READ_STDIO }); + } else if (next.mode === 'direct') { + execSync('networksetup -setwebproxystate "Wi-Fi" off', { stdio: WIN_READ_STDIO }); + } + } catch { + // best-effort application; read() reports the resulting state. + } + } + + restore(previous?: OsProxySnapshot): void { + const snap = previous ?? this.snapshot; + if (snap === undefined) { + return; + } + try { + if (snap.mode === 'manual' && snap.host !== undefined && snap.port !== undefined) { + execSync(`networksetup -setwebproxy "Wi-Fi" ${snap.host} ${snap.port}`, { + stdio: WIN_READ_STDIO, + }); + execSync('networksetup -setwebproxystate "Wi-Fi" on', { stdio: WIN_READ_STDIO }); + } else if (snap.mode === 'direct') { + execSync('networksetup -setwebproxystate "Wi-Fi" off', { stdio: WIN_READ_STDIO }); + } + } catch { + // best-effort restore; idempotent. + } + } +} + +/** + * Returns the OS proxy adapter appropriate for the current platform: + * WindowsOsProxyAdapter on win32, MacosOsProxyAdapter on darwin, noop otherwise. + */ +export function createOsProxyAdapter(): OsProxyAdapter { + if (process.platform === 'win32') { + return new WindowsOsProxyAdapter(); + } + if (process.platform === 'darwin') { + return new MacosOsProxyAdapter(); + } + return noopOsProxyAdapter; +} diff --git a/src/desktop-interception/rival-apps.ts b/src/desktop-interception/rival-apps.ts new file mode 100644 index 0000000..2d6ff3d --- /dev/null +++ b/src/desktop-interception/rival-apps.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Legion Code Inc. (Mario Aldayuz) +import { execSync } from 'node:child_process'; + +export const RIVAL_APP_NAMES = ['ChatGPT', 'Codex'] as const; + +export interface RivalAppDetection { + readonly running: readonly string[]; + readonly anyRunning: boolean; + readonly platform: 'win32' | 'darwin' | 'other'; +} + +function run(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function runPowerShell(script: string): string { + return run(`powershell.exe -NoProfile -Command ${JSON.stringify(script)}`); +} + +function detectPlatform(): RivalAppDetection['platform'] { + if (process.platform === 'win32') return 'win32'; + if (process.platform === 'darwin') return 'darwin'; + return 'other'; +} + +export function detectRunningRivalApps(): RivalAppDetection { + const platform = detectPlatform(); + + if (platform === 'other') { + return { running: [], anyRunning: false, platform }; + } + + const running: string[] = []; + for (const name of RIVAL_APP_NAMES) { + try { + if (platform === 'win32') { + const count = runPowerShell( + `(Get-CimInstance Win32_Process -Filter "Name = '${name}.exe' OR Name = '${name.toLowerCase()}.exe'" | Measure-Object).Count`, + ); + if (Number.parseInt(count, 10) > 0) running.push(name); + } else { + const out = run( + `osascript -e 'tell application "System Events" to exists process "${name}"'`, + ); + if (out.toLowerCase() === 'true') running.push(name); + } + } catch { + // A failed query for this app is treated as "not running". + } + } + + return { running, anyRunning: running.length > 0, platform }; +} + +export function formatRivalAppWarning(detection: RivalAppDetection): string { + if (!detection.anyRunning) return ''; + const running = detection.running; + return ( + 'Global proxy is blocked while ' + + running.map(n => n + ' Desktop').join(', ') + + ' ' + + (running.length > 1 ? 'are' : 'is') + + " running. Use app-scoped routing instead, or pass an explicit override to accept the risk to other apps' traffic." + ); +} + +export function assertNoRivalAppsRunning(override?: boolean): void { + const d = detectRunningRivalApps(); + if (d.anyRunning && !override) { + throw new Error(formatRivalAppWarning(d)); + } +} diff --git a/tests/claude-app-launch-proxy.test.ts b/tests/claude-app-launch-proxy.test.ts new file mode 100644 index 0000000..b4eb596 --- /dev/null +++ b/tests/claude-app-launch-proxy.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { spawn as realSpawn, execSync as realExecSync } from 'node:child_process'; +import { + buildClaudeProxyArg, + openClaudeAppWithProxy, + launchOrRestartClaudeAppWithProxy, + _internals, +} from '../src/claude-desktop/app-launch.js'; + +const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform'); + +function setPlatform(value: 'darwin' | 'win32' | 'linux'): void { + Object.defineProperty(process, 'platform', { value, configurable: true }); +} + +function restorePlatform(): void { + if (ORIGINAL_PLATFORM) { + Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM); + } +} + +/** + * A mock ChildProcess that satisfies `.unref()`, matching what `spawn` returns + * in the production code (`_internals.spawn(..., { detached: true }).unref()`). + */ +function mockChild() { + return { unref: vi.fn() } as unknown as ReturnType; +} + +/** + * Sweep every recorded arg string across spawn + execSync spies and assert the + * proxy is never applied system-wide: no registry mutation, no networksetup, + * no HTTP(S)_PROXY env writes. Proxy scope must be the Claude process only. + */ +function assertNoSystemProxyMutation( + spawnSpy: ReturnType, + execSpy: ReturnType, +): void { + const banned = ['reg add', 'ProxyEnable', 'networksetup', 'setwebproxy', 'HTTP_PROXY', 'HTTPS_PROXY']; + const recorded: string[] = []; + for (const call of spawnSpy.mock.calls) { + for (const arg of call) { + if (typeof arg === 'string') recorded.push(arg); + else if (Array.isArray(arg)) recorded.push(...arg.filter((a): a is string => typeof a === 'string')); + } + } + for (const call of execSpy.mock.calls) { + for (const arg of call) if (typeof arg === 'string') recorded.push(arg); + } + for (const token of banned) { + for (const arg of recorded) { + expect(arg, `system-wide proxy mutation "${token}" must never be used`).not.toContain(token); + } + } +} + +describe('buildClaudeProxyArg', () => { + it('builds the per-app Chromium --proxy-server arg for a real port', () => { + expect(buildClaudeProxyArg(8080)).toBe('--proxy-server=http://127.0.0.1:8080'); + }); + + it('builds the arg verbatim for port 0 (no special-casing)', () => { + expect(buildClaudeProxyArg(0)).toBe('--proxy-server=http://127.0.0.1:0'); + }); +}); + +describe('openClaudeAppWithProxy', () => { + let spawnSpy: ReturnType; + let execSpy: ReturnType; + let findSpy: ReturnType; + + beforeEach(() => { + spawnSpy = vi.spyOn(_internals, 'spawn').mockImplementation((() => mockChild()) as typeof _internals.spawn); + execSpy = vi.spyOn(require('node:child_process'), 'execSync').mockImplementation((() => '') as typeof realExecSync); + findSpy = vi.spyOn(_internals, 'findClaudeApp'); + }); + + afterEach(() => { + spawnSpy.mockRestore(); + execSpy.mockRestore(); + findSpy.mockRestore(); + restorePlatform(); + }); + + it('throws if Claude is not found (same error as openClaudeApp)', () => { + setPlatform('win32'); + findSpy.mockReturnValue(null); + expect(() => openClaudeAppWithProxy(9090)).toThrowError( + 'Claude Desktop App not found. Please install it first.', + ); + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + if (process.platform === 'win32') { + it('win32: spawns the .exe with the proxy flag, fire-and-forget', () => { + setPlatform('win32'); + findSpy.mockReturnValue('C:\\Users\\me\\AppData\\Local\\Programs\\Claude\\Claude.exe'); + openClaudeAppWithProxy(9090); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = spawnSpy.mock.calls[0]; + expect(cmd).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Claude\\Claude.exe'); + expect(args).toEqual(['--proxy-server=http://127.0.0.1:9090']); + expect(opts).toMatchObject({ stdio: 'ignore', detached: true }); + assertNoSystemProxyMutation(spawnSpy, execSpy); + }); + + it('win32: rejects shell:AppsFolder-packaged Claude instead of falling back to a system proxy', () => { + setPlatform('win32'); + findSpy.mockReturnValue('shell:AppsFolder\\Anthropic.Claude'); + expect(() => openClaudeAppWithProxy(9090)).toThrowError( + 'Per-app proxy launch is not supported for shell:AppsFolder-packaged Claude; use the .exe path or a wrapper.', + ); + expect(spawnSpy).not.toHaveBeenCalled(); + // And critically, no system-proxy fallback was attempted. + assertNoSystemProxyMutation(spawnSpy, execSpy); + }); + } + + if (process.platform === 'darwin') { + it('darwin: launches the .app via `open ... --args --proxy-server=...`', () => { + setPlatform('darwin'); + findSpy.mockReturnValue('/Applications/Claude.app'); + openClaudeAppWithProxy(9090); + + expect(spawnSpy).toHaveBeenCalledTimes(1); + const [cmd, args, opts] = spawnSpy.mock.calls[0]; + expect(cmd).toBe('open'); + expect(args).toEqual([ + '/Applications/Claude.app', + '--args', + '--proxy-server=http://127.0.0.1:9090', + ]); + expect(opts).toMatchObject({ stdio: 'ignore', detached: true }); + assertNoSystemProxyMutation(spawnSpy, execSpy); + }); + } +}); + +describe('launchOrRestartClaudeAppWithProxy', () => { + let spawnSpy: ReturnType; + let execSpy: ReturnType; + let findSpy: ReturnType; + let runningSpy: ReturnType; + + beforeEach(async () => { + spawnSpy = vi.spyOn(_internals, 'spawn').mockImplementation((() => mockChild()) as typeof _internals.spawn); + execSpy = vi.spyOn(require('node:child_process'), 'execSync').mockImplementation((() => '') as typeof realExecSync); + findSpy = vi.spyOn(_internals, 'findClaudeApp'); + runningSpy = vi.spyOn(_internals, 'isClaudeAppRunning'); + }); + + afterEach(() => { + spawnSpy.mockRestore(); + execSpy.mockRestore(); + findSpy.mockRestore(); + runningSpy.mockRestore(); + restorePlatform(); + }); + + it('launches with proxy when Claude is not running, without confirming', async () => { + setPlatform('win32'); + findSpy.mockReturnValue('C:\\Claude\\Claude.exe'); + runningSpy.mockReturnValue(false); + + await launchOrRestartClaudeAppWithProxy(7070); + + expect(runningSpy).toHaveBeenCalled(); + expect(spawnSpy).toHaveBeenCalledTimes(1); + const [, args] = spawnSpy.mock.calls[0]; + expect(args).toEqual(['--proxy-server=http://127.0.0.1:7070']); + assertNoSystemProxyMutation(spawnSpy, execSpy); + }); + + it('does not spawn Claude at import time', () => { + // The module was already imported at the top of this file; spawn must only + // ever fire as a result of calling the launch functions. + expect(spawnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/desktop-interception-app-targets.test.ts b/tests/desktop-interception-app-targets.test.ts new file mode 100644 index 0000000..fea8aeb --- /dev/null +++ b/tests/desktop-interception-app-targets.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + DESKTOP_APP_LANES, + getLaneById, + isVerificationStale, + laneForApp, + laneOwnsHost, + lanesForHost, + type LaneId, +} from '../src/desktop-interception/app-targets.js'; + +describe('desktop interception app-scoped lanes', () => { + it('declares exactly three lanes with the expected ids, isolation, and preferred flags', () => { + expect(DESKTOP_APP_LANES).toHaveLength(3); + + const byId = new Map(DESKTOP_APP_LANES.map(lane => [lane.laneId, lane])); + + expect([...byId.keys()]).toEqual([ + 'claude-native', + 'claude-legacy-gateway', + 'codex-desktop', + ]); + + expect(byId.get('claude-native')?.isolation).toBe('app-scoped-proxy'); + expect(byId.get('claude-native')?.preferred).toBe(true); + + expect(byId.get('claude-legacy-gateway')?.isolation).toBe('config-only'); + expect(byId.get('claude-legacy-gateway')?.preferred).toBe(false); + + expect(byId.get('codex-desktop')?.isolation).toBe('config-only'); + expect(byId.get('codex-desktop')?.preferred).toBe(false); + }); + + it('marks claude-native as the only preferred lane and the only app-scoped-proxy lane', () => { + const preferred = DESKTOP_APP_LANES.filter(lane => lane.preferred); + expect(preferred.map(lane => lane.laneId)).toEqual(['claude-native']); + + const proxyIsolated = DESKTOP_APP_LANES.filter(lane => lane.isolation === 'app-scoped-proxy'); + expect(proxyIsolated.map(lane => lane.laneId)).toEqual(['claude-native']); + }); + + it('resolves known lanes and returns undefined for unknown ids', () => { + expect(getLaneById('claude-native')?.laneId).toBe('claude-native'); + // An unknown lane id is a caller bug and must not resolve to a lane. + expect(getLaneById('chatgpt-desktop' as LaneId)).toBeUndefined(); + }); + + it('guarantees claude-native egress-exclusivity against a rival-app host', () => { + expect(laneOwnsHost('claude-native', 'api.anthropic.com')).toBe(true); + // The rival-app host MUST NOT be owned by the claude lane. + expect(laneOwnsHost('claude-native', 'api.openai.com')).toBe(false); + }); + + it('guarantees codex-desktop egress-exclusivity against a claude host', () => { + expect(laneOwnsHost('codex-desktop', 'api.openai.com')).toBe(true); + // The claude host MUST NOT be owned by the codex lane. + expect(laneOwnsHost('codex-desktop', 'api.anthropic.com')).toBe(false); + }); + + it('treats true subdomains of a supportedHost as owned (host.endsWith("."))', () => { + // A real child of api.anthropic.com. + expect(laneOwnsHost('claude-native', 'v1.api.anthropic.com')).toBe(true); + // A real child of api.openai.com. + expect(laneOwnsHost('codex-desktop', 'xyz.api.openai.com')).toBe(true); + }); + + it('does NOT own sibling hosts that share a registrable domain but are not subdomains of a supportedHost', () => { + // statsig.anthropic.com is a SIBLING of api.anthropic.com, not a child: + // it ends with '.anthropic.com', not '.api.anthropic.com'. Under the + // predicate pinned in PRD-023e (mirroring claude-target.ts:48, + // `host === required || host.endsWith('.' + required)`) it is therefore + // NOT owned by claude-native. This preserves the egress-exclusivity + // guarantee (rival-app hosts still resolve exclusively) while refusing to + // over-claim hosts the lane table does not enumerate. If registrable-domain + // matching is actually desired, supportedHosts or the predicate must change. + expect(laneOwnsHost('claude-native', 'statsig.anthropic.com')).toBe(false); + expect(laneOwnsHost('codex-desktop', 'chat.openai.com')).toBe(false); + }); + + it('normalizes host case, surrounding whitespace, and trailing dots before matching', () => { + expect(laneOwnsHost('claude-native', ' API.Anthropic.com. ')).toBe(true); + expect(laneOwnsHost('codex-desktop', 'API.OPENAI.COM')).toBe(true); + }); + + it('resolves api.anthropic.com to both claude lanes and never the codex lane', () => { + expect(lanesForHost('api.anthropic.com')).toEqual([ + 'claude-native', + 'claude-legacy-gateway', + ]); + expect(lanesForHost('api.anthropic.com')).not.toContain('codex-desktop'); + }); + + it('resolves api.openai.com to the codex lane only', () => { + expect(lanesForHost('api.openai.com')).toEqual(['codex-desktop']); + }); + + it('resolves true subdomains to the same lanes as their apex', () => { + // A real child of api.anthropic.com -> both claude lanes. + expect(lanesForHost('v1.api.anthropic.com')).toEqual([ + 'claude-native', + 'claude-legacy-gateway', + ]); + // A real child of api.openai.com -> codex lane only. + expect(lanesForHost('xyz.api.openai.com')).toEqual(['codex-desktop']); + // Sibling hosts (not subdomains of any supportedHost) resolve to nothing. + expect(lanesForHost('statsig.anthropic.com')).toEqual([]); + expect(lanesForHost('chat.openai.com')).toEqual([]); + }); + + it('groups lanes by app name', () => { + expect(laneForApp('Claude Desktop').map(lane => lane.laneId)).toEqual([ + 'claude-native', + 'claude-legacy-gateway', + ]); + expect(laneForApp('Codex Desktop').map(lane => lane.laneId)).toEqual(['codex-desktop']); + expect(laneForApp('Unknown App')).toEqual([]); + }); + + it('still detects stale app/OS tuples via the pre-existing isVerificationStale', () => { + const recorded = { + appName: 'Claude Desktop', + appVersion: '1.0.0', + osName: 'Windows', + osVersion: '11', + supportHosts: ['claude.ai', 'api.anthropic.com'], + trustMechanism: 'windows-root-ca', + proxyMechanism: 'winhttp-loopback', + }; + // No drift -> not stale. + expect(isVerificationStale(recorded, { ...recorded })).toBe(false); + // App version drift -> stale. + expect(isVerificationStale(recorded, { ...recorded, appVersion: '1.0.1' })).toBe(true); + // Missing recorded tuple -> stale. + expect(isVerificationStale(undefined, { ...recorded })).toBe(true); + }); +}); diff --git a/tests/desktop-interception-os-proxy.test.ts b/tests/desktop-interception-os-proxy.test.ts new file mode 100644 index 0000000..c2fdadd --- /dev/null +++ b/tests/desktop-interception-os-proxy.test.ts @@ -0,0 +1,254 @@ +import { execSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// `node:child_process` is a Node builtin whose ESM namespace is not configurable, +// so `vi.spyOn(childProcess, 'execSync')` throws "Cannot redefine property". +// The supported way to mock a Node builtin in Vitest is vi.mock with a factory. +// This honors the spec's intent (mock execSync; never mutate the real OS). +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})); + +const mockExec = vi.mocked(execSync); + +const DIRECT_OUTPUT = [ + '', + 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', + ' ProxyEnable REG_DWORD 0x0', + ' ProxyServer REG_SZ old:8080', + '', + '', +].join('\n'); + +const MANUAL_OUTPUT = [ + '', + 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', + ' ProxyEnable REG_DWORD 0x1', + ' ProxyServer REG_SZ 127.0.0.1:8888', + '', + '', +].join('\n'); + +const MAC_MANUAL_OUTPUT = [ + 'Enabled: Yes', + 'Server: 127.0.0.1', + 'Port: 8888', + 'Authenticated Proxy Enabled: 0', + '', +].join('\n'); + +const MAC_DIRECT_OUTPUT = ['Enabled: No', 'Server: ', 'Port: 0', ''].join('\n'); + +import { + MacosOsProxyAdapter, + WindowsOsProxyAdapter, + createOsProxyAdapter, + noopOsProxyAdapter, + type OsProxySnapshot, +} from '../src/desktop-interception/os-proxy.js'; + +const allCalls = (): string[] => mockExec.mock.calls.map((c) => String(c[0])); + +describe('noopOsProxyAdapter', () => { + it('returns an unknown snapshot and exposes no apply/restore', () => { + expect(noopOsProxyAdapter.read()).toEqual({ mode: 'unknown' }); + expect(noopOsProxyAdapter.apply).toBeUndefined(); + expect(noopOsProxyAdapter.restore).toBeUndefined(); + }); +}); + +describe('createOsProxyAdapter', () => { + it('returns the platform-appropriate adapter class', () => { + const adapter = createOsProxyAdapter(); + if (process.platform === 'win32') { + expect(adapter.constructor.name).toBe('WindowsOsProxyAdapter'); + } else if (process.platform === 'darwin') { + expect(adapter.constructor.name).toBe('MacosOsProxyAdapter'); + } else { + expect(adapter).toBe(noopOsProxyAdapter); + } + }); + + it('instantiates WindowsOsProxyAdapter on win32', () => { + if (process.platform !== 'win32') { + // Regression guard for the win32 branch, runnable on any host. + expect(new WindowsOsProxyAdapter().constructor.name).toBe('WindowsOsProxyAdapter'); + return; + } + expect(createOsProxyAdapter().constructor.name).toBe('WindowsOsProxyAdapter'); + }); + + it('instantiates MacosOsProxyAdapter on darwin', () => { + if (process.platform !== 'darwin') { + expect(new MacosOsProxyAdapter().constructor.name).toBe('MacosOsProxyAdapter'); + return; + } + expect(createOsProxyAdapter().constructor.name).toBe('MacosOsProxyAdapter'); + }); +}); + +describe('WindowsOsProxyAdapter', () => { + beforeEach(() => { + mockExec.mockReset(); + }); + afterEach(() => { + mockExec.mockReset(); + }); + + it('reads ProxyEnable 0x0 as direct', () => { + mockExec.mockReturnValue(DIRECT_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'direct' }); + }); + + it('reads ProxyEnable 0x1 with ProxyServer as manual host:port', () => { + mockExec.mockReturnValue(MANUAL_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'manual', host: '127.0.0.1', port: 8888 }); + }); + + it('returns unknown when execSync throws', () => { + mockExec.mockImplementation(() => { + throw new Error('reg not found'); + }); + const adapter = new WindowsOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'unknown' }); + }); + + it('snapshots the first state, applies manual, and restores to the snapshot', () => { + // read() returns direct initially -> this is the snapshot we expect retained. + mockExec.mockReturnValue(DIRECT_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + + // First apply: snapshot the direct state, then apply manual. + adapter.apply({ mode: 'manual', host: '127.0.0.1', port: 9090 }); + + // Two reg add calls: ProxyEnable=1, then ProxyServer=host:port. + const firstCalls = allCalls(); + expect(firstCalls).toContainEqual( + expect.stringContaining('/v ProxyEnable /t REG_DWORD /d 1'), + ); + expect(firstCalls).toContainEqual( + expect.stringContaining('/v ProxyServer /t REG_SZ /d "127.0.0.1:9090"'), + ); + + // read() (reg query) is invoked exactly once, for snapshotting. + const readCallsBefore = allCalls().filter((s) => s.includes('reg query')).length; + expect(readCallsBefore).toBe(1); + + // Second apply with a different port: must NOT re-snapshot (snapshot retained). + mockExec.mockClear(); + mockExec.mockReturnValue(DIRECT_OUTPUT); + adapter.apply({ mode: 'manual', host: '127.0.0.1', port: 9191 }); + const secondReadCalls = allCalls().filter((s) => s.includes('reg query')).length; + expect(secondReadCalls).toBe(0); // snapshot path not re-entered + + // Restore: should issue ProxyEnable /d 0 (restoring the direct snapshot). + mockExec.mockClear(); + adapter.restore(); + const restoreCalls = allCalls(); + expect(restoreCalls).toContainEqual( + expect.stringContaining('/v ProxyEnable /t REG_DWORD /d 0'), + ); + }); + + it('restore is idempotent (calling twice does not throw)', () => { + mockExec.mockReturnValue(DIRECT_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + adapter.apply({ mode: 'manual', host: '127.0.0.1', port: 9090 }); + expect(() => adapter.restore()).not.toThrow(); + expect(() => adapter.restore()).not.toThrow(); + }); + + it('apply for direct mode sets ProxyEnable /d 0', () => { + mockExec.mockReturnValue(MANUAL_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + adapter.apply({ mode: 'direct' }); + expect(allCalls()).toContainEqual( + expect.stringContaining('/v ProxyEnable /t REG_DWORD /d 0'), + ); + }); + + it('apply swallows execSync failures without throwing', () => { + mockExec.mockReturnValue(DIRECT_OUTPUT); + const adapter = new WindowsOsProxyAdapter(); + mockExec.mockImplementation(() => { + throw new Error('denied'); + }); + expect(() => adapter.apply({ mode: 'direct' })).not.toThrow(); + }); +}); + +describe('MacosOsProxyAdapter', () => { + beforeEach(() => { + mockExec.mockReset(); + }); + afterEach(() => { + mockExec.mockReset(); + }); + + it('reads Enabled: Yes with Server/Port as manual', () => { + mockExec.mockReturnValue(MAC_MANUAL_OUTPUT); + const adapter = new MacosOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'manual', host: '127.0.0.1', port: 8888 }); + }); + + it('reads Enabled: No as direct', () => { + mockExec.mockReturnValue(MAC_DIRECT_OUTPUT); + const adapter = new MacosOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'direct' }); + }); + + it('returns unknown when execSync throws', () => { + mockExec.mockImplementation(() => { + throw new Error('networksetup not found'); + }); + const adapter = new MacosOsProxyAdapter(); + expect(adapter.read()).toEqual({ mode: 'unknown' }); + }); + + it('applies manual then restores to the direct snapshot', () => { + mockExec.mockReturnValue(MAC_DIRECT_OUTPUT); + const adapter = new MacosOsProxyAdapter(); + + adapter.apply({ mode: 'manual', host: '127.0.0.1', port: 9090 }); + const applyCalls = allCalls(); + expect(applyCalls).toContainEqual( + expect.stringContaining('-setwebproxy "Wi-Fi" 127.0.0.1 9090'), + ); + expect(applyCalls).toContainEqual( + expect.stringContaining('-setwebproxystate "Wi-Fi" on'), + ); + + mockExec.mockClear(); + adapter.restore(); + const restoreCalls = allCalls(); + expect(restoreCalls).toContainEqual( + expect.stringContaining('-setwebproxystate "Wi-Fi" off'), + ); + }); + + it('apply for direct mode disables the web proxy state', () => { + mockExec.mockReturnValue(MAC_MANUAL_OUTPUT); + const adapter = new MacosOsProxyAdapter(); + adapter.apply({ mode: 'direct' }); + expect(allCalls()).toContainEqual( + expect.stringContaining('-setwebproxystate "Wi-Fi" off'), + ); + }); + + it('restore is idempotent (calling twice does not throw)', () => { + mockExec.mockReturnValue(MAC_DIRECT_OUTPUT); + const adapter = new MacosOsProxyAdapter(); + adapter.apply({ mode: 'manual', host: '127.0.0.1', port: 9090 }); + expect(() => adapter.restore()).not.toThrow(); + expect(() => adapter.restore()).not.toThrow(); + }); +}); + +describe('snapshot type-safety', () => { + it('constructs an OsProxySnapshot with optional fields omitted', () => { + const snap: OsProxySnapshot = { mode: 'direct' }; + expect(snap.mode).toBe('direct'); + }); +}); diff --git a/tests/desktop-interception-rival-apps.test.ts b/tests/desktop-interception-rival-apps.test.ts new file mode 100644 index 0000000..ff947de --- /dev/null +++ b/tests/desktop-interception-rival-apps.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +// ESM-safe mock of execSync. `vi.spyOn` on a Node builtin namespace throws +// ("Module namespace is not configurable in ESM"), so we use `vi.mock` with a +// factory and drive the mock via `vi.mocked`. +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), +})); + +import { execSync } from 'node:child_process'; +import { + RIVAL_APP_NAMES, + detectRunningRivalApps, + formatRivalAppWarning, + assertNoRivalAppsRunning, +} from '../src/desktop-interception/rival-apps.js'; + +const execMock = vi.mocked(execSync); + +const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform'); + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { value, configurable: true }); +} + +function restorePlatform(): void { + if (ORIGINAL_PLATFORM) { + Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM); + } +} + +beforeEach(() => { + execMock.mockReset(); +}); + +afterEach(() => { + restorePlatform(); +}); + +describe('RIVAL_APP_NAMES', () => { + it("is ['ChatGPT', 'Codex']", () => { + expect([...RIVAL_APP_NAMES]).toEqual(['ChatGPT', 'Codex']); + }); +}); + +describe('detectRunningRivalApps', () => { + it('returns the empty "other" result on non-win32/darwin platforms', () => { + setPlatform('linux'); + const d = detectRunningRivalApps(); + expect(d).toEqual({ running: [], anyRunning: false, platform: 'other' }); + expect(execMock).not.toHaveBeenCalled(); + }); + + it('detects running apps on win32 when the process Count is > 0', () => { + setPlatform('win32'); + execMock.mockReturnValue('2'); + const d = detectRunningRivalApps(); + expect(d.platform).toBe('win32'); + expect(d.anyRunning).toBe(true); + expect(d.running.length).toBeGreaterThan(0); + expect(d.running).toContain('ChatGPT'); + }); + + it('treats failed queries as not-running and does not itself throw', () => { + setPlatform('win32'); + execMock.mockImplementation(() => { + throw new Error('boom'); + }); + expect(() => detectRunningRivalApps()).not.toThrow(); + const d = detectRunningRivalApps(); + expect(d.running).toEqual([]); + expect(d.anyRunning).toBe(false); + }); +}); + +describe('formatRivalAppWarning', () => { + it('returns a non-empty warning mentioning the app and "blocked" when running', () => { + const w = formatRivalAppWarning({ + running: ['ChatGPT'], + anyRunning: true, + platform: 'win32', + }); + expect(w).not.toBe(''); + expect(w).toContain('ChatGPT'); + expect(w).toContain('blocked'); + }); + + it("returns '' when nothing is running", () => { + const w = formatRivalAppWarning({ + running: [], + anyRunning: false, + platform: 'other', + }); + expect(w).toBe(''); + }); +}); + +describe('assertNoRivalAppsRunning', () => { + it('throws with a "blocked" message when a rival app is running', () => { + setPlatform('win32'); + execMock.mockReturnValue('2'); + expect(() => assertNoRivalAppsRunning()).toThrow(/blocked/); + }); + + it('does not throw when an explicit override is passed', () => { + setPlatform('win32'); + execMock.mockReturnValue('2'); + expect(() => assertNoRivalAppsRunning(true)).not.toThrow(); + }); + + it('does not throw when no rival app is running', () => { + setPlatform('win32'); + execMock.mockReturnValue('0'); + expect(() => assertNoRivalAppsRunning()).not.toThrow(); + }); +}); From e497e646a10331f172bd1f5b43bfa39364feebb4 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 01:17:34 -0400 Subject: [PATCH 2/6] feat(server): PRD-023e dashboard endpoints + rival-app block (wave 2) - 4 new dashboard endpoints: claude-desktop/revert (legacy gateway cleanup via app-session), codex-desktop/revert, codex-desktop/stop (per-lane, never touches claude-native state), server/kill (closes only owned runtime handles, never user/OS processes). - Rival-app global-proxy block in dashboardClaudeNativeStart: refuses native start with 409 rival_apps_running when ChatGPT/Codex desktop is running, unless the caller passes rivalOverride:true (the explicit override path). - Swap noopOsProxyAdapter -> createOsProxyAdapter() in native uninstall so real snapshot/restore runs on Windows/macOS (hard-rollback seam). - DesktopActionResult/Category typed DTO for safe action responses. Tests: server-router 30 -> 35 (codex stop noop+isolation, server kill, rival-app block 409 + override, revert endpoints). typecheck clean; full suite 93 files / 815 tests pass. --- src/server/router.ts | 190 +++++++++++++++++++++++++++++++++++- tests/server-router.test.ts | 173 ++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+), 2 deletions(-) diff --git a/src/server/router.ts b/src/server/router.ts index 335adc5..22e3526 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -98,7 +98,10 @@ import { } from '../desktop-interception/routing.js'; import type { HookResponse, InterceptedRequest, RequestOutcome } from '../desktop-interception/hooks.js'; import { emptyInstallState } from '../desktop-interception/state.js'; -import { noopOsProxyAdapter } from '../desktop-interception/os-proxy.js'; +import { createOsProxyAdapter } from '../desktop-interception/os-proxy.js'; +import { assertNoRivalAppsRunning } from '../desktop-interception/rival-apps.js'; +import { readSessionLock, recoverSession } from '../claude-desktop/app-session.js'; +import { restoreCodexAppOverlay } from '../codex/app-session.js'; import { idempotentStopState, uninstallOwnedNativeState } from '../desktop-interception/trust.js'; export interface ServerBackend { @@ -138,6 +141,33 @@ type JsonBody = Record; type PLog = (msg: string | (() => string)) => void; +/** + * Canonical return-shape guide for dashboard desktop action handlers. + * The existing handlers already return compatible shapes; the new + * revert/stop/kill handlers target this interface explicitly. + */ +export type DesktopActionCategory = + | 'blocked' + | 'not_owned' + | 'unsupported' + | 'verification_required' + | 'consent_required' + | 'runtime_error' + | 'manual_cleanup_required' + | 'rival_apps_running' + | 'noop' + | 'ready' + | 'stopped' + | 'reverted' + | 'killed'; + +export interface DesktopActionResult { + ok: boolean; + status: DesktopActionCategory; + reason: string; + message?: string; +} + const CLAUDE_NATIVE_INTERNAL_MESSAGES_PATH = '/_rflectr/desktop/claude-native/messages'; function makeServerLog(debugLogPath: string | undefined): PLog { @@ -475,6 +505,26 @@ async function handleDashboardApi( sendJson(res, result.status, result.body); return; } + if (req.method === 'POST' && pathname === `${DASHBOARD_API_PREFIX}/claude-desktop/revert`) { + const result = await dashboardClaudeDesktopRevert(runtime); + sendJson(res, result.status, result.body); + return; + } + if (req.method === 'POST' && pathname === `${DASHBOARD_API_PREFIX}/codex-desktop/revert`) { + const result = await dashboardCodexDesktopRevert(runtime); + sendJson(res, result.status, result.body); + return; + } + if (req.method === 'POST' && pathname === `${DASHBOARD_API_PREFIX}/codex-desktop/stop`) { + const result = await dashboardCodexDesktopStop(runtime); + sendJson(res, result.status, result.body); + return; + } + if (req.method === 'POST' && pathname === `${DASHBOARD_API_PREFIX}/server/kill`) { + const result = await dashboardServerKill(runtime); + sendJson(res, result.status, result.body); + return; + } sendJson(res, 404, { error: { message: 'Not found' } }); } @@ -577,6 +627,19 @@ async function dashboardClaudeNativeStart( activity: DashboardActivityBuffer, plog: PLog, ): Promise<{ status: number; body: unknown }> { + // Rival-app guard: native interception installs a global OS proxy that would + // capture traffic from ChatGPT/Codex Desktop if they are running. Block unless + // the caller passes rivalOverride: true. + const override = body?.rivalOverride === true; + try { + assertNoRivalAppsRunning(override); + } catch (error) { + return { + status: 409, + body: { ok: false, status: 'rival_apps_running', reason: 'rival_apps_running', message: String((error as Error).message) }, + }; + } + const verification = runtime.claudeNativeVerification; const enablement = evaluateClaudeNativeEnablement({ verification, @@ -674,7 +737,7 @@ async function dashboardClaudeNativeUninstall( const result = await uninstallOwnedNativeState({ installState: runtime.claudeNativeInstallState ?? emptyInstallState(), consent: body.confirm === true ? { action: 'uninstall', consentedAt } : undefined, - proxyAdapter: noopOsProxyAdapter, + proxyAdapter: createOsProxyAdapter(), }); if (result.status === 'ready' || result.status === 'noop') { runtime.claudeNativeInstallState = result.installState; @@ -701,6 +764,129 @@ async function dashboardClaudeNativeUninstall( }; } +/** + * POST /dashboard/api/claude-desktop/revert + * Delegates legacy-gateway config cleanup to the Claude Desktop app-session + * restore path. Does NOT touch claude-native runtime fields (lane isolation): + * the native uninstall endpoint owns those. + */ +async function dashboardClaudeDesktopRevert( + runtime: DashboardRuntime, +): Promise<{ status: number; body: unknown }> { + void runtime; // claude-native lane is untouched here; param kept for signature parity. + try { + const lock = readSessionLock(); + if (!lock) { + const noop: DesktopActionResult = { ok: true, status: 'noop', reason: 'no_owned_legacy_config' }; + return { status: 200, body: noop }; + } + recoverSession(); + const result: DesktopActionResult = { ok: true, status: 'reverted', reason: 'legacy_gateway_config_restored' }; + return { status: 200, body: result }; + } catch (error) { + const message = sanitizeDashboardError(error) ?? 'Claude Desktop revert failed'; + return { + status: 500, + body: { + ok: false, + status: 'runtime_error', + reason: 'claude_desktop_revert_failed', + message, + }, + }; + } +} + +/** + * POST /dashboard/api/codex-desktop/revert + * Delegates overlay/config restore to the Codex app-session restore path. + * MUST NOT touch runtime.claudeNative* fields (lane isolation). + */ +async function dashboardCodexDesktopRevert( + runtime: DashboardRuntime, +): Promise<{ status: number; body: unknown }> { + void runtime; + try { + const restored = restoreCodexAppOverlay(); + const status: DesktopActionCategory = restored.restored ? 'reverted' : 'noop'; + const reason = restored.restored ? 'codex_overlay_config_restored' : 'no_owned_codex_config'; + const result: DesktopActionResult = { ok: true, status, reason }; + return { status: 200, body: result }; + } catch (error) { + const message = sanitizeDashboardError(error) ?? 'Codex Desktop revert failed'; + return { + status: 500, + body: { + ok: false, + status: 'runtime_error', + reason: 'codex_desktop_revert_failed', + message, + }, + }; + } +} + +/** + * POST /dashboard/api/codex-desktop/stop + * Closes the runtime-registered Codex proxy handle, if any. Idempotent. + * MUST NOT touch claude-native fields. + */ +async function dashboardCodexDesktopStop( + runtime: DashboardRuntime, +): Promise<{ status: number; body: unknown }> { + if (!runtime.codexProxy) { + const noop: DesktopActionResult = { ok: true, status: 'noop', reason: 'codex_proxy_not_running' }; + return { status: 200, body: noop }; + } + try { + runtime.codexProxy.close(); + } catch { + /* best-effort close of dashboard-managed proxy */ + } + runtime.codexProxy = null; + runtime.codexProxyStartedAt = undefined; + const result: DesktopActionResult = { ok: true, status: 'stopped', reason: 'codex_proxy_stopped' }; + return { status: 200, body: result }; +} + +/** + * POST /dashboard/api/server/kill + * Closes ONLY the runtime-registered handles (codex proxy, claude-native + * transport, claude-native probe transport). Does NOT call server.close() — the + * dashboard JS warns the user the browser may disconnect. Never kills + * Claude/Codex/OS processes. + */ +async function dashboardServerKill( + runtime: DashboardRuntime, +): Promise<{ status: number; body: unknown }> { + try { + runtime.codexProxy?.close(); + } catch { /* ignore dashboard-managed proxy cleanup */ } + runtime.codexProxy = null; + runtime.codexProxyStartedAt = undefined; + + try { + runtime.claudeNativeTransport?.close(); + } catch { /* ignore dashboard-managed native cleanup */ } + runtime.claudeNativeTransport = null; + runtime.claudeNativeStartedAt = undefined; + + try { + runtime.claudeNativeProbe?.transport.close(); + } catch { /* ignore dashboard-managed probe cleanup */ } + runtime.claudeNativeProbe = null; + + return { + status: 200, + body: { + ok: true, + status: 'killed', + reason: 'runtime_handles_closed', + browserMayDisconnect: true, + } satisfies DesktopActionResult & { browserMayDisconnect: true }, + }; +} + async function handleClaudeNativeInternalMessages( req: IncomingMessage, res: ServerResponse, diff --git a/tests/server-router.test.ts b/tests/server-router.test.ts index 4ccb79d..0640039 100644 --- a/tests/server-router.test.ts +++ b/tests/server-router.test.ts @@ -47,6 +47,48 @@ vi.mock('../src/openai-adapter.js', async importOriginal => { }; }); +// Rival-app guard is driven through detectRunningRivalApps; the router consumes +// assertNoRivalAppsRunning which reads the same mocked detection state. +const rivalMock = vi.hoisted(() => ({ anyRunning: false })); +vi.mock('../src/desktop-interception/rival-apps.js', () => ({ + detectRunningRivalApps: vi.fn(() => ({ + running: rivalMock.anyRunning ? ['ChatGPT'] : [], + anyRunning: rivalMock.anyRunning, + platform: 'other' as const, + })), + formatRivalAppWarning: vi.fn(() => 'Global proxy is blocked while a rival app is running.'), + assertNoRivalAppsRunning: vi.fn((override?: boolean) => { + if (rivalMock.anyRunning && !override) { + throw new Error('Global proxy is blocked while a rival app is running.'); + } + }), +})); + +// App-session restore paths are mocked so revert tests do not require real config. +// Only the restore entry points are overridden; everything else stays real so the +// existing codex connect flow continues to work. +const claudeRevertMock = vi.hoisted(() => ({ hasLock: false, recoverCalls: 0 })); +vi.mock('../src/claude-desktop/app-session.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + readSessionLock: vi.fn(() => claudeRevertMock.hasLock ? { pid: 999, startedAt: '2026-01-01T00:00:00.000Z', uuid: 'revert-test', proxyPort: 0 } : null), + recoverSession: vi.fn(() => { claudeRevertMock.recoverCalls++; }), + }; +}); + +const codexRevertMock = vi.hoisted(() => ({ restored: false })); +vi.mock('../src/codex/app-session.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + restoreCodexAppOverlay: vi.fn(() => ({ + restored: codexRevertMock.restored, + message: codexRevertMock.restored ? 'Restored.' : 'Nothing to restore.', + })), + }; +}); + interface UpstreamRequest { method: string; url: string; @@ -207,6 +249,10 @@ async function closeHandle(handle: ServerHandle | { close: () => Promise } afterEach(async () => { vi.mocked(createLanguageModel).mockClear(); + rivalMock.anyRunning = false; + claudeRevertMock.hasLock = false; + claudeRevertMock.recoverCalls = 0; + codexRevertMock.restored = false; while (handles.length > 0) { const handle = handles.pop(); if (handle) await closeHandle(handle); @@ -1350,4 +1396,131 @@ describe('server router', () => { error: { message: expect.stringContaining('Unsupported model format') }, }); }); + + it('POST /dashboard/api/codex-desktop/stop returns 200 noop without mutating claude-native fields', async () => { + useTempRflectrHome(); + const server = await startTestServer(); + + const stop = await fetch(`${server.url}/dashboard/api/codex-desktop/stop`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(stop.status).toBe(200); + const stopBody = await stop.json() as any; + expect(stopBody).toMatchObject({ ok: true, status: 'noop', reason: 'codex_proxy_not_running' }); + + // claude-native lane must be untouched: the settings DTO still reports the + // pre-stop state (no transport), and there is no error recorded. + const settings = await (await fetch(`${server.url}/dashboard/api/settings`)).json() as any; + expect(settings.claudeDesktop.nativeInterception.running).toBe(false); + expect(settings.claudeDesktop.nativeInterception.port).toBeNull(); + }); + + it('POST /dashboard/api/server/kill closes runtime handles and warns the browser may disconnect', async () => { + useTempRflectrHome(); + const server = await startTestServer(); + + const kill = await fetch(`${server.url}/dashboard/api/server/kill`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(kill.status).toBe(200); + expect(await kill.json()).toMatchObject({ + ok: true, + status: 'killed', + reason: 'runtime_handles_closed', + browserMayDisconnect: true, + }); + }); + + it('blocks Claude native start when a rival app is running, and allows it with rivalOverride', async () => { + useTempRflectrHome(); + saveClaudeNativeVerification(createObservedClaudeVerification({ + osName: platform(), + osVersion: release(), + hosts: [ + { host: 'api.anthropic.com', state: 'interceptable' }, + { host: 'claude.ai', state: 'interceptable' }, + ], + })); + const server = await startTestServer(); + + // Rival running, no override -> 409 rival block. + rivalMock.anyRunning = true; + const blocked = await fetch(`${server.url}/dashboard/api/claude-desktop/native/start`, { + method: 'POST', + headers: dashboardJsonHeaders, + body: JSON.stringify({ providerId: 'zen', modelId: 'claude-native', consentDestinationProviderId: 'zen' }), + }); + expect(blocked.status).toBe(409); + expect(await blocked.json()).toMatchObject({ + ok: false, + status: 'rival_apps_running', + reason: 'rival_apps_running', + }); + + // Rival running, override passed -> must NOT be the rival block (may fail later + // for other reasons such as verification; assert the rival reason is absent). + const overridden = await fetch(`${server.url}/dashboard/api/claude-desktop/native/start`, { + method: 'POST', + headers: dashboardJsonHeaders, + body: JSON.stringify({ + providerId: 'zen', + modelId: 'claude-native', + consentDestinationProviderId: 'zen', + rivalOverride: true, + }), + }); + const overriddenBody = await overridden.json() as any; + expect(overriddenBody).not.toMatchObject({ status: 'rival_apps_running' }); + expect(overriddenBody).not.toMatchObject({ reason: 'rival_apps_running' }); + }); + + it('POST /dashboard/api/claude-desktop/revert returns reverted or noop based on owned config', async () => { + useTempRflectrHome(); + const server = await startTestServer(); + + // No owned legacy config -> noop. + claudeRevertMock.hasLock = false; + const noopRes = await fetch(`${server.url}/dashboard/api/claude-desktop/revert`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(noopRes.status).toBe(200); + expect(await noopRes.json()).toMatchObject({ ok: true, status: 'noop', reason: 'no_owned_legacy_config' }); + + // Owned legacy config -> reverted. + claudeRevertMock.hasLock = true; + const revertedRes = await fetch(`${server.url}/dashboard/api/claude-desktop/revert`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(revertedRes.status).toBe(200); + expect(await revertedRes.json()).toMatchObject({ ok: true, status: 'reverted', reason: 'legacy_gateway_config_restored' }); + }); + + it('POST /dashboard/api/codex-desktop/revert returns reverted or noop without touching claude-native fields', async () => { + useTempRflectrHome(); + const server = await startTestServer(); + + codexRevertMock.restored = false; + const noopRes = await fetch(`${server.url}/dashboard/api/codex-desktop/revert`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(noopRes.status).toBe(200); + expect(await noopRes.json()).toMatchObject({ ok: true, status: 'noop', reason: 'no_owned_codex_config' }); + + codexRevertMock.restored = true; + const revertedRes = await fetch(`${server.url}/dashboard/api/codex-desktop/revert`, { + method: 'POST', + headers: dashboardMutationHeaders, + }); + expect(revertedRes.status).toBe(200); + expect(await revertedRes.json()).toMatchObject({ ok: true, status: 'reverted', reason: 'codex_overlay_config_restored' }); + + // Lane isolation: claude-native lane is untouched. + const settings = await (await fetch(`${server.url}/dashboard/api/settings`)).json() as any; + expect(settings.claudeDesktop.nativeInterception.running).toBe(false); + }); }); From 3b890e965c2d1c8232e09176be7acd52ddedf2e9 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 01:29:58 -0400 Subject: [PATCH 3/6] feat(dashboard): PRD-023e per-lane state + DTOs + controls UI (wave 3) - DashboardRuntime.lanes: Partial> (per-lane running/startedAt/proxySnapshot/lastError). Existing flat fields kept; router depends on them. - LaneRuntimeState, DesktopLaneStatusDto, DesktopAppsStatusDto typed DTOs. - buildDesktopLaneStatuses(runtime): iterates DESKTOP_APP_LANES, derives running from lane state or legacy fields (claudeNativeTransport / codexProxy). Wired into the settings payload as lanes. - Desktop Apps UI: Revert legacy (claude-desktop/revert), Revert Codex + Stop Codex proxy (codex-desktop/{revert,stop}), Kill server (server/kill, warns browser may disconnect). Lanes render as distinct Claude-native / Claude-legacy / Codex sections (AC-023e-1). Tests: server-dashboard 11 (lane status builder, claude-native running from transport, codex running from proxy, DTO shape, rendered buttons). typecheck clean; full suite 94 files / 821 tests pass. --- src/server/dashboard.ts | 80 ++++++++++++++++++++- tests/server-dashboard.test.ts | 123 +++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/server/dashboard.ts b/src/server/dashboard.ts index dcb429c..b87141d 100644 --- a/src/server/dashboard.ts +++ b/src/server/dashboard.ts @@ -28,6 +28,7 @@ import { gatewayProviderId, gatewayProviderLabel, exposedGatewayAliasId } from ' import { getConfigLibraryPath, writeRflectrConfig } from '../claude-desktop/app-config.js'; import { claudeAppSupported, isClaudeAppRunning, openClaudeApp } from '../claude-desktop/app-launch.js'; import { evaluateClaudeNativeEnablement } from '../desktop-interception/claude-target.js'; +import { DESKTOP_APP_LANES, type LaneId } from '../desktop-interception/app-targets.js'; import { currentClaudeNativeTuple, recordedClaudeNativeTuple } from '../desktop-interception/claude-native-state.js'; import type { VerificationResult } from '../desktop-interception/verify.js'; import type { CodexProxyHandle } from '../codex-proxy.js'; @@ -73,6 +74,35 @@ export interface DashboardRuntime { }; claudeNativeRouteToken?: string; claudeNativeLastError?: string; + /** PRD-023e per-lane state. Keys are LaneId. */ + lanes?: Partial>; +} + +export interface LaneRuntimeState { + readonly laneId: LaneId; + readonly attachMechanism: 'native-interception' | 'config-profile'; + readonly isolation: 'app-scoped-proxy' | 'config-only'; + running: boolean; + startedAt?: string; + /** OS proxy/trust snapshot captured before any mutation (hard rollback). */ + proxySnapshot?: { mode: 'unknown' | 'direct' | 'manual'; host?: string; port?: number }; + lastError?: string; +} + +/** Desktop-app lane status DTO surfaced to the dashboard UI (PRD-023e). */ +export interface DesktopLaneStatusDto { + laneId: LaneId; + appName: string; + attachMechanism: 'native-interception' | 'config-profile'; + isolation: 'app-scoped-proxy' | 'config-only'; + preferred: boolean; + running: boolean; + status: string; // human-readable lane status + controls: readonly string[]; +} + +export interface DesktopAppsStatusDto { + lanes: DesktopLaneStatusDto[]; } export interface DashboardClaudeDesktopBody { @@ -519,9 +549,51 @@ export function dashboardSettings(runtime: DashboardRuntime) { ? `Dashboard-managed Responses proxy is running on 127.0.0.1:${runtime.codexProxy.port}.` : 'Configures Codex Desktop with a dashboard-managed Responses proxy for this running gateway.', }, + lanes: buildDesktopLaneStatuses(runtime), }; } +/** + * PRD-023e: derive the desktop-app lane status DTOs from the runtime. + * Iterates `DESKTOP_APP_LANES` (order = resolution preference) so the UI + * gets a stable, lane-distinct view of Claude-native / Claude-legacy / + * Codex-desktop. `running` falls back to the legacy runtime fields when a + * lane has no explicit per-lane state yet. + */ +export function buildDesktopLaneStatuses(runtime: DashboardRuntime): DesktopLaneStatusDto[] { + return DESKTOP_APP_LANES.map(lane => { + const laneState = runtime.lanes?.[lane.laneId]; + const running = laneState?.running + ?? (lane.laneId === 'claude-native' ? Boolean(runtime.claudeNativeTransport) + : lane.laneId === 'codex-desktop' ? Boolean(runtime.codexProxy) + : false); + return { + laneId: lane.laneId, + appName: lane.appName, + attachMechanism: lane.attachMechanism, + isolation: lane.isolation, + preferred: lane.preferred, + running, + status: laneStatusString(runtime, lane.laneId, running), + controls: lane.controls, + }; + }); +} + +function laneStatusString(runtime: DashboardRuntime, laneId: LaneId, running: boolean): string { + if (laneId === 'claude-native') { + if (runtime.claudeNativeLastError) return `error: ${runtime.claudeNativeLastError}`; + if (running) return `running on 127.0.0.1:${runtime.claudeNativeTransport?.port ?? '-'}`; + return 'stopped'; + } + if (laneId === 'codex-desktop') { + if (running) return `proxy listening on 127.0.0.1:${runtime.codexProxy?.port ?? '-'}`; + return runtime.codexProxy ? 'proxy idle' : 'not running'; + } + // claude-legacy-gateway + return dashboardClaudeDesktopBaseUrl(runtime) ? 'configured' : 'unconfigured'; +} + export function updateDashboardSettings(body: Record): { status: number; body: unknown } { if (body.defaultTool !== undefined) { if (!isDefaultTool(body.defaultTool)) { @@ -670,7 +742,7 @@ const DASHBOARD_CSS = ` const DASHBOARD_JS = ` function safeGet(k){try{return localStorage.getItem(k)}catch{return null}}function safeSet(k,v){try{localStorage.setItem(k,v)}catch{}}function clean(s){return String(s??'').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/Bearer\\s+[A-Za-z0-9._~+/=-]+/gi,'Bearer [redacted]')} -let DATA=null,route=safeGet('rflectr-dashboard-route')||'overview',collapsed=safeGet('rflectr-dashboard-collapsed')==='1',query='',modelFilter='all',activityFilter='all',importBusy=false,saveBusy=false,providerForm=null,providerTemplates=null,providerBusy=false,providerStatus='',desktopBusy=false,desktopStatus='',claudeNativeBusy=false,claudeNativeStatus='',claudeNativeModel=safeGet('rflectr-claude-native-model')||'',codexDesktopBusy=false,codexDesktopStatus='',testBusyKey='',testResults={}; +let DATA=null,route=safeGet('rflectr-dashboard-route')||'overview',collapsed=safeGet('rflectr-dashboard-collapsed')==='1',query='',modelFilter='all',activityFilter='all',importBusy=false,saveBusy=false,providerForm=null,providerTemplates=null,providerBusy=false,providerStatus='',desktopBusy=false,desktopStatus='',claudeNativeBusy=false,claudeNativeStatus='',claudeNativeModel=safeGet('rflectr-claude-native-model')||'',codexDesktopBusy=false,codexDesktopStatus='',killServerBusy=false,testBusyKey='',testResults={}; const app=document.getElementById('app'); function js(s){return JSON.stringify(String(s??'')).replace(/[<>&']/g,c=>({'<':'\\\\u003c','>':'\\\\u003e','&':'\\\\u0026',"'":'\\\\u0027'}[c]))} async function api(path,init){const opts=init?{...init,headers:{...(init.headers||{})}}:{};if(opts.method&&opts.method.toUpperCase()!=='GET')opts.headers['${DASHBOARD_MUTATION_HEADER}']='1';const res=await fetch(path,opts);if(!res.ok)throw new Error(clean((await res.text()).slice(0,200)));return res.json();} @@ -685,7 +757,7 @@ function kpi(l,v,c){return '
!q||p.name.toLowerCase().includes(q));return providerFormHtml()+'
registry'+list.filter(p=>p.status!=='missing').length+' connected
'+list.map((p,i)=>{const editable=p.source==='registry',key='provider:'+p.id;return '
'+esc(p.name)+'

'+esc(p.status)+'

'+esc(p.modelCount)+' models

'+esc(p.auth.label)+'

'+(editable?'':'')+'
'+testResultHtml(key)+'
'}).join('')+'
'} function models(){let list=DATA.models.models.filter(m=>{const q=query.toLowerCase();if(modelFilter==='favorites'&&!m.favorite)return false;if(!['all','favorites'].includes(modelFilter)&&m.format!==modelFilter)return false;return !q||m.id.toLowerCase().includes(q)||m.family.toLowerCase().includes(q)});return '
'+['all','native','translated','unsupported','favorites'].map(f=>'').join('')+''+list.length+' shown · '+DATA.models.models.filter(m=>m.favorite).length+'/20 favorited
'+(list.length?list.map(m=>{const key='model:'+m.id;return '
'+esc(m.id)+''+esc(m.family)+''+esc(m.format)+''+esc(m.contextWindow??'-')+''+cost(m)+''+esc(m.backend)+'
'+testResultHtml(key)+'
'}).join(''):'
No models match “'+esc(query)+'”.
')} function activity(){const a=DATA.activity;const rows=a.events.filter(r=>activityFilter==='retries'?r.status==='retry':activityFilter==='errors'?r.status==='error':true);return '
'+kpi('succeeded',a.counts.succeeded,'ok')+kpi('retried → rerouted',a.counts.retried,'warn')+kpi('errored',a.counts.errored,'bad')+'
'+['all','retries','errors'].map(f=>'').join('')+'● live
'+feed(rows)+'

'+esc(a.costCaveat)+'

'} -function desktopApps(){const c=DATA.settings.claudeDesktop,x=DATA.settings.codexDesktop,n=c.nativeInterception||{},nativeStatus=n.status||'verification_required',nativeModels=DATA.models.models.filter(m=>m.supported&&m.format!=='unsupported'&&!/smoke/i.test(m.id));if(!claudeNativeModel&&nativeModels[0])claudeNativeModel=nativeModels[0].providerId+'::'+nativeModels[0].id;const selected=nativeModels.find(m=>(m.providerId+'::'+m.id)===claudeNativeModel)||nativeModels[0];const routeLine=n.running?('Running on 127.0.0.1:'+n.port+' -> '+(n.selectedRoute?n.selectedRoute.providerId+'/'+n.selectedRoute.modelId:'selected route')):'Stopped';const probeLine=n.verificationProbe?('Verification proxy 127.0.0.1:'+n.verificationProbe.port+' observed '+n.verificationProbe.observedHosts.length+' host(s)'):'No verification probe running';const modelSelect='';const claude='

Claude Desktop

Native interception '+esc(nativeStatus)+' · '+esc(routeLine)+'

'+esc(probeLine)+'

'+modelSelect+'

Legacy gateway fallback '+esc(c.baseUrl)+'

Auth '+esc(c.authScheme)+' · '+esc(c.apiKeyLabel)+'

Use Cowork or Code. Chat is not available in legacy gateway mode.

'+esc(claudeNativeStatus||desktopStatus||(!c.supported?'Claude Desktop gateway setup is supported on macOS and Windows only.':n.reason||'Native interception is primary after verification; legacy third-party gateway remains available.'))+'
';const codex='

Codex Desktop

Responses proxy '+esc(x.proxyActive?('127.0.0.1:'+x.proxyPort):'not running')+'

'+esc(x.note)+'

'+esc(codexDesktopStatus||(!x.supported?'Codex Desktop is supported on macOS and Windows only.':'Writes Codex Desktop config and starts a dashboard-managed Responses proxy.'))+'
';return '
'+claude+codex+'
'} +function desktopApps(){const c=DATA.settings.claudeDesktop,x=DATA.settings.codexDesktop,n=c.nativeInterception||{},nativeStatus=n.status||'verification_required',nativeModels=DATA.models.models.filter(m=>m.supported&&m.format!=='unsupported'&&!/smoke/i.test(m.id));if(!claudeNativeModel&&nativeModels[0])claudeNativeModel=nativeModels[0].providerId+'::'+nativeModels[0].id;const selected=nativeModels.find(m=>(m.providerId+'::'+m.id)===claudeNativeModel)||nativeModels[0];const routeLine=n.running?('Running on 127.0.0.1:'+n.port+' -> '+(n.selectedRoute?n.selectedRoute.providerId+'/'+n.selectedRoute.modelId:'selected route')):'Stopped';const probeLine=n.verificationProbe?('Verification proxy 127.0.0.1:'+n.verificationProbe.port+' observed '+n.verificationProbe.observedHosts.length+' host(s)'):'No verification probe running';const modelSelect='';const claude='

Claude Desktop

Native interception '+esc(nativeStatus)+' · '+esc(routeLine)+'

'+esc(probeLine)+'

'+modelSelect+'

Legacy gateway fallback '+esc(c.baseUrl)+'

Auth '+esc(c.authScheme)+' · '+esc(c.apiKeyLabel)+'

Use Cowork or Code. Chat is not available in legacy gateway mode.

'+esc(claudeNativeStatus||desktopStatus||(!c.supported?'Claude Desktop gateway setup is supported on macOS and Windows only.':n.reason||'Native interception is primary after verification; legacy third-party gateway remains available.'))+'
';const codex='

Codex Desktop

Responses proxy '+esc(x.proxyActive?('127.0.0.1:'+x.proxyPort):'not running')+'

'+esc(x.note)+'

'+esc(codexDesktopStatus||(!x.supported?'Codex Desktop is supported on macOS and Windows only.':'Writes Codex Desktop config and starts a dashboard-managed Responses proxy.'))+'
stops the local gateway; browser may disconnect
';return '
'+claude+codex+'
'} function settings(){const s=DATA.settings,g=s.gateway,sourceCard=s.modelSourceAvailable?'

OpenCode model source

'+['free','zen','go'].map(t=>'').join('')+'
':'';return '
'+sourceCard+'

Default tool

'+[['claude','Claude Code',true],['codex','Codex',true],['gemini','Gemini CLI',true],['cursor','Cursor',false]].map(t=>'').join('')+'

Routing


Traces stay local under ~/.rflectr/logs when enabled.

Gateway

Address '+esc(g.address)+'

Version rflectr '+esc(g.version)+'

Uptime '+Math.floor(g.uptimeSeconds/60)+'m

● runs on your machine

'} function feed(rows){return rows.length?''+rows.map(r=>'').join('')+'
timetoolroutetokenslatencystatus
'+esc(new Date(r.startedAt).toLocaleTimeString('en-US',{hour12:false}))+''+esc(r.tool)+''+esc(r.model)+' · '+esc(r.backend)+''+esc(r.totalTokens??'-')+''+esc(r.latencyMs)+'ms'+esc(r.httpStatus||r.status)+'
':'
No requests observed for this filter.
'} function spark(a){const max=Math.max(1,...a),pts=a.map((v,i)=>(i*10)+','+(30-(v/max)*28)).join(' ');return ''} @@ -711,6 +783,10 @@ async function startClaudeNative(){if(claudeNativeBusy)return;const parts=String async function stopClaudeNative(){if(claudeNativeBusy)return;claudeNativeBusy=true;claudeNativeStatus='Stopping Claude Desktop native routing...';render();try{const r=await api('/dashboard/api/claude-desktop/native/stop',{method:'POST'});claudeNativeStatus=r.reason||'Native routing stopped.';await load()}catch(e){claudeNativeStatus='Native stop failed: '+e.message;render()}finally{claudeNativeBusy=false;render()}} async function uninstallClaudeNative(){if(claudeNativeBusy)return;if(!confirm('Uninstall only rflectr-owned native proxy/trust state? Legacy Claude gateway config is left alone.'))return;claudeNativeBusy=true;claudeNativeStatus='Uninstalling owned native state...';render();try{const r=await api('/dashboard/api/claude-desktop/native/uninstall',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({confirm:true})});claudeNativeStatus=r.reason||'Owned native state removed.';await load()}catch(e){claudeNativeStatus='Native uninstall failed: '+e.message;render()}finally{claudeNativeBusy=false;render()}} async function connectCodexDesktop(launch){if(codexDesktopBusy)return;codexDesktopBusy=true;codexDesktopStatus='Starting Codex Responses proxy...';render();try{const result=await api('/dashboard/api/codex-desktop/connect',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({launch:!!launch})});codexDesktopStatus=result.note||('Codex Desktop configured for '+result.baseUrl);if(result.launchError)codexDesktopStatus+=' Open failed: '+result.launchError;if(result.configPath)codexDesktopStatus+=' Config: '+result.configPath;await load()}catch(e){codexDesktopStatus='Codex Desktop setup failed: '+e.message;render()}finally{codexDesktopBusy=false;render()}} +async function revertClaudeDesktop(){if(desktopBusy)return;if(!confirm('Revert the legacy Claude Desktop gateway profile to its defaults?'))return;desktopBusy=true;desktopStatus='Reverting legacy Claude Desktop config...';render();try{const r=await api('/dashboard/api/claude-desktop/revert',{method:'POST'});desktopStatus=r.message||r.reason||'Legacy Claude Desktop config reverted.';await load()}catch(e){desktopStatus='Revert failed: '+e.message;render()}finally{desktopBusy=false;render()}} +async function revertCodexDesktop(){if(codexDesktopBusy)return;if(!confirm('Revert Codex Desktop config and stop the managed Responses proxy?'))return;codexDesktopBusy=true;codexDesktopStatus='Reverting Codex Desktop config...';render();try{const r=await api('/dashboard/api/codex-desktop/revert',{method:'POST'});codexDesktopStatus=r.message||r.reason||'Codex Desktop config reverted.';await load()}catch(e){codexDesktopStatus='Codex revert failed: '+e.message;render()}finally{codexDesktopBusy=false;render()}} +async function stopCodexProxy(){if(codexDesktopBusy)return;codexDesktopBusy=true;codexDesktopStatus='Stopping Codex Responses proxy...';render();try{const r=await api('/dashboard/api/codex-desktop/stop',{method:'POST'});codexDesktopStatus=r.message||r.reason||'Codex Responses proxy stopped.';await load()}catch(e){codexDesktopStatus='Codex proxy stop failed: '+e.message;render()}finally{codexDesktopBusy=false;render()}} +async function killServer(){if(killServerBusy)return;if(!confirm('Kill the local rflectr gateway? Active requests will be interrupted and this browser session may disconnect until you restart the server.'))return;killServerBusy=true;render();try{await api('/dashboard/api/server/kill',{method:'POST'});app.innerHTML='

Gateway stopped

The local rflectr server is shutting down. Restart it from your terminal to reconnect.

'}catch(e){app.innerHTML='

Kill did not complete

'+esc(clean(e.message))+'

'}finally{killServerBusy=false}} async function restart(){if(!confirm('Restart the local gateway? Active requests may be interrupted.'))return;try{await api('/dashboard/api/restart',{method:'POST'});app.innerHTML='

Restarting gateway...

Reconnecting to your local rflectr server.

';setTimeout(load,1800)}catch(e){app.innerHTML='

Restart did not complete

'+esc(clean(e.message))+'

'}} function cost(m){if(!m.cost)return m.format==='native'?'-':'estimate';return '$'+m.cost.input+' / $'+m.cost.output}function render(){if(!DATA)return;shell(({overview,providers,models,desktop:desktopApps,activity,settings})[route]())}function shouldAutoRefresh(){const active=document.activeElement;if(providerForm)return false;if(active&&['INPUT','SELECT','TEXTAREA'].includes(active.tagName))return false;return !document.hidden}function sourceLabel(s){return s==='free'?'Zen free models':s==='go'?'OpenCode Go':'OpenCode Zen'}function cap(s){return s.charAt(0).toUpperCase()+s.slice(1)}function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}if(safeGet('rflectr-dashboard-theme')==='light')document.documentElement.setAttribute('data-theme','light');window.load=load;load();setInterval(()=>{if(shouldAutoRefresh())load()},5000);document.addEventListener('visibilitychange',()=>{if(shouldAutoRefresh())load()}); `; diff --git a/tests/server-dashboard.test.ts b/tests/server-dashboard.test.ts index ea42a16..57aca45 100644 --- a/tests/server-dashboard.test.ts +++ b/tests/server-dashboard.test.ts @@ -3,12 +3,16 @@ import { platform, release } from 'node:os'; import { DashboardActivityBuffer, beginDashboardActivity, + buildDesktopLaneStatuses, completeDashboardActivity, + dashboardAsset, dashboardSettings, sanitizeDashboardError, type DashboardActivityEvent, + type DesktopLaneStatusDto, type DashboardRuntime, } from '../src/server/dashboard.js'; +import type { DesktopInterceptionTransport } from '../src/desktop-interception/transport.js'; import { createObservedClaudeVerification } from '../src/desktop-interception/claude-target.js'; function event(id: string): DashboardActivityEvent { @@ -123,3 +127,122 @@ describe('dashboard activity buffer', () => { }); }); }); + +function baseRuntime(overrides: Partial = {}): DashboardRuntime { + return { + startedAt: Date.now(), + host: '127.0.0.1', + port: 17645, + local: true, + serverPassword: null, + ...overrides, + }; +} + +describe('desktop lane status builder (PRD-023e)', () => { + it('returns one entry per lane in resolution-preference order with stable shape', () => { + const lanes = buildDesktopLaneStatuses(baseRuntime()); + + expect(lanes).toHaveLength(3); + expect(lanes.map(lane => lane.laneId)).toEqual([ + 'claude-native', + 'claude-legacy-gateway', + 'codex-desktop', + ]); + + const native = lanes.find(lane => lane.laneId === 'claude-native'); + const legacy = lanes.find(lane => lane.laneId === 'claude-legacy-gateway'); + const codex = lanes.find(lane => lane.laneId === 'codex-desktop'); + + // claude-native is the only preferred + app-scoped-proxy lane. + expect(native?.preferred).toBe(true); + expect(native?.isolation).toBe('app-scoped-proxy'); + expect(native?.attachMechanism).toBe('native-interception'); + expect([legacy, codex].every(lane => lane?.preferred === false)).toBe(true); + expect([legacy, codex].every(lane => lane?.isolation === 'config-only')).toBe(true); + + // The DTO shape must stay stable: controls is an array of strings. + for (const lane of lanes) { + expect(Array.isArray(lane.controls)).toBe(true); + expect(lane.controls.every(control => typeof control === 'string')).toBe(true); + expect(typeof lane.status).toBe('string'); + expect(typeof lane.running).toBe('boolean'); + expect(typeof lane.appName).toBe('string'); + } + }); + + it('derives claude-native running state from claudeNativeTransport', () => { + const stopped = buildDesktopLaneStatuses(baseRuntime()); + expect(stopped.find(lane => lane.laneId === 'claude-native')?.running).toBe(false); + + const transport = { port: 18101, status: () => undefined, close: async () => undefined } as unknown as DesktopInterceptionTransport; + const running = buildDesktopLaneStatuses(baseRuntime({ claudeNativeTransport: transport })); + expect(running.find(lane => lane.laneId === 'claude-native')?.running).toBe(true); + }); + + it('derives codex-desktop running state from codexProxy', () => { + const stopped = buildDesktopLaneStatuses(baseRuntime()); + expect(stopped.find(lane => lane.laneId === 'codex-desktop')?.running).toBe(false); + + const running = buildDesktopLaneStatuses(baseRuntime({ codexProxy: { port: 18102, close: () => undefined } })); + expect(running.find(lane => lane.laneId === 'codex-desktop')?.running).toBe(true); + }); + + it('prefers explicit per-lane state over the legacy runtime fields', () => { + const runtime = baseRuntime({ + lanes: { + 'codex-desktop': { + laneId: 'codex-desktop', + attachMechanism: 'config-profile', + isolation: 'config-only', + running: false, + }, + 'claude-native': { + laneId: 'claude-native', + attachMechanism: 'native-interception', + isolation: 'app-scoped-proxy', + running: true, + startedAt: '2026-06-30T00:00:00.000Z', + }, + }, + }); + const lanes = buildDesktopLaneStatuses(runtime); + + expect(lanes.find(lane => lane.laneId === 'claude-native')?.running).toBe(true); + // Per-lane state wins even though no codexProxy handle is set. + expect(lanes.find(lane => lane.laneId === 'codex-desktop')?.running).toBe(false); + }); + + it('surfaces lane statuses through the settings DTO payload', () => { + const settings = dashboardSettings(baseRuntime()); + expect(Array.isArray(settings.lanes)).toBe(true); + expect(settings.lanes).toHaveLength(3); + expect((settings.lanes as DesktopLaneStatusDto[]).map(lane => lane.laneId)).toContain('codex-desktop'); + }); + + it('renders the four new desktop-app control buttons into the dashboard JS bundle', () => { + const asset = dashboardAsset('/dashboard/assets/dashboard.js'); + expect(asset?.body).toBeTypeOf('string'); + const bundle = asset!.body as string; + + // The new handlers and onclick wiring must ship in the embedded JS. + for (const marker of [ + 'revertClaudeDesktop', + 'revertCodexDesktop', + 'stopCodexProxy', + 'killServer', + 'Revert legacy', + 'Revert Codex', + 'Stop Codex proxy', + 'Kill server', + ]) { + expect(bundle).toContain(marker); + } + + // Each new control must POST to its router endpoint. + expect(bundle).toContain('/dashboard/api/claude-desktop/revert'); + expect(bundle).toContain('/dashboard/api/codex-desktop/revert'); + expect(bundle).toContain('/dashboard/api/codex-desktop/stop'); + expect(bundle).toContain('/dashboard/api/server/kill'); + }); +}); From a0535160d2f4a69bfbff7ba68037088804150973 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 01:37:46 -0400 Subject: [PATCH 4/6] test(desktop): PRD-023e lane isolation + rival-app block integration (wave 4) New tests/desktop-lane-isolation.test.ts (16 tests) tying together the lane primitives, rival-app detector, OS-proxy factory, and per-lane runtime status: - egress exclusivity (AC-023e-5/27): claude lane never owns openai, codex lane never owns anthropic; lanesForHost resolution. - rival-app block (AC-023e-6/24): detectRunningRivalApps + assert- NoRivalAppsRunning (throws w/ 'blocked', override path no-throw, execSync-failure safe-default). - per-lane isolation (AC-023e-11..13/28): stopping one lane's runtime does not affect the other; both can run independently; codex never reports native-interception. - OS proxy factory (AC-023e-2..3): noop contract + platform-correct instance selection; no real OS mutation. ESM-safe: vi.mock w/ importOriginal (transitive spawn bind requires real spawn preserved); platform override + restore in afterEach. Full suite: 94 files / 837 tests pass. --- tests/desktop-lane-isolation.test.ts | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/desktop-lane-isolation.test.ts diff --git a/tests/desktop-lane-isolation.test.ts b/tests/desktop-lane-isolation.test.ts new file mode 100644 index 0000000..ca51646 --- /dev/null +++ b/tests/desktop-lane-isolation.test.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Legion Code Inc. (Mario Aldayuz) +// +// PRD-023e integration test: proves lane isolation, the rival-app global-proxy +// block, and per-lane stop semantics. Ties together the Wave 1 lane primitives +// (app-targets.ts), the Wave 2 router endpoints' rival-app guard +// (rival-apps.ts + os-proxy.ts), and the Wave 3 dashboard DTO (dashboard.ts). +// +// Mocks `node:child_process` (ESM-safe) so no real process is launched and no +// real OS proxy/trust state is ever mutated. `process.platform` is overridden +// via defineProperty and restored in afterEach. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { execSync } from 'node:child_process'; + +// ESM-safe partial mock of the builtin: keep the real module (so transitively +// imported members like `spawn` resolve) and override only `execSync`, which is +// the only member rival-apps.ts / os-proxy.ts call. The factory is hoisted +// above the source imports below, so this is the binding they get. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execSync: vi.fn() }; +}); +const mockedExecSync = vi.mocked(execSync); + +import { + laneOwnsHost, + lanesForHost, + type LaneId, +} from '../src/desktop-interception/app-targets.js'; +import { + assertNoRivalAppsRunning, + detectRunningRivalApps, + formatRivalAppWarning, +} from '../src/desktop-interception/rival-apps.js'; +import { + MacosOsProxyAdapter, + WindowsOsProxyAdapter, + createOsProxyAdapter, + noopOsProxyAdapter, +} from '../src/desktop-interception/os-proxy.js'; +import { + buildDesktopLaneStatuses, + type DashboardRuntime, + type DesktopLaneStatusDto, +} from '../src/server/dashboard.js'; + +const REAL_PLATFORM = process.platform; + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { value, configurable: true }); +} + +afterEach(() => { + mockedExecSync.mockReset(); + Object.defineProperty(process, 'platform', { value: REAL_PLATFORM, configurable: true }); +}); + +describe('lane egress exclusivity (AC-023e-5 / AC-023-27)', () => { + it('claude-native owns api.anthropic.com and never a rival host', () => { + expect(laneOwnsHost('claude-native', 'api.anthropic.com')).toBe(true); + expect(laneOwnsHost('claude-native', 'api.openai.com')).toBe(false); + }); + + it('codex-desktop owns api.openai.com and never a Claude host', () => { + expect(laneOwnsHost('codex-desktop', 'api.openai.com')).toBe(true); + expect(laneOwnsHost('codex-desktop', 'api.anthropic.com')).toBe(false); + }); + + it("lanesForHost('api.openai.com') resolves to only codex-desktop - a Claude lane can never claim a rival host", () => { + expect(lanesForHost('api.openai.com')).toEqual(['codex-desktop']); + }); + + it("lanesForHost('api.anthropic.com') contains the Claude lanes but never codex-desktop", () => { + const lanes = lanesForHost('api.anthropic.com'); + expect(lanes).toContain('claude-native'); + expect(lanes).toContain('claude-legacy-gateway'); + expect(lanes).not.toContain('codex-desktop'); + }); +}); + +describe('rival-app global-proxy block (AC-023e-6 / AC-023-24)', () => { + it('detects a running rival app on win32, blocks without override, and allows an explicit override', () => { + setPlatform('win32'); + // PowerShell Measure-Object Count > 0 => the app is running. + mockedExecSync.mockReturnValue('1'); + + expect(detectRunningRivalApps().anyRunning).toBe(true); + + expect(() => assertNoRivalAppsRunning()).toThrow('blocked'); + expect(() => assertNoRivalAppsRunning(true)).not.toThrow(); + }); + + it('treats a failing probe as not-running and does not block', () => { + setPlatform('win32'); + mockedExecSync.mockImplementation(() => { + throw new Error('powershell.exe not available'); + }); + + expect(detectRunningRivalApps().anyRunning).toBe(false); + expect(() => assertNoRivalAppsRunning()).not.toThrow(); + }); + + it('formatRivalAppWarning names the running app and the block', () => { + const msg = formatRivalAppWarning({ running: ['ChatGPT'], anyRunning: true, platform: 'win32' }); + expect(msg).toContain('ChatGPT'); + expect(msg).toContain('blocked'); + }); +}); + +describe('per-lane runtime isolation (AC-023e-11..13 / AC-023-28)', () => { + type MinimalLaneRuntime = { claudeNativeTransport?: unknown; codexProxy?: unknown }; + + function laneStatus(partial: MinimalLaneRuntime, laneId: LaneId): DesktopLaneStatusDto { + const lanes = buildDesktopLaneStatuses(partial as unknown as DashboardRuntime); + const lane = lanes.find(l => l.laneId === laneId); + if (!lane) throw new Error(`lane ${laneId} not found`); + return lane; + } + + it('claude-native and codex-desktop can both run at the same time', () => { + const runtime = { claudeNativeTransport: { port: 4321 }, codexProxy: { port: 8765 } }; + expect(laneStatus(runtime, 'claude-native').running).toBe(true); + expect(laneStatus(runtime, 'codex-desktop').running).toBe(true); + }); + + it('stopping codex does not stop claude (independent lanes)', () => { + const runtime = { claudeNativeTransport: { port: 4321 }, codexProxy: null }; + expect(laneStatus(runtime, 'claude-native').running).toBe(true); + expect(laneStatus(runtime, 'codex-desktop').running).toBe(false); + }); + + it('stopping claude does not stop codex (independent lanes)', () => { + const runtime = { claudeNativeTransport: null, codexProxy: { port: 8765 } }; + expect(laneStatus(runtime, 'claude-native').running).toBe(false); + expect(laneStatus(runtime, 'codex-desktop').running).toBe(true); + }); + + it('surfaces exactly 3 lanes with claude-native as the only preferred, app-scoped-proxy lane', () => { + const lanes = buildDesktopLaneStatuses({} as unknown as DashboardRuntime); + expect(lanes).toHaveLength(3); + + expect(lanes.filter(l => l.preferred).map(l => l.laneId)).toEqual(['claude-native']); + expect(lanes.filter(l => l.isolation === 'app-scoped-proxy').map(l => l.laneId)) + .toEqual(['claude-native']); + }); + + it('codex-desktop never reports a native-interception attach mechanism (AC-023e-12)', () => { + const codex = laneStatus( + { claudeNativeTransport: { port: 4321 }, codexProxy: { port: 8765 } }, + 'codex-desktop', + ); + expect(codex.attachMechanism).not.toBe('native-interception'); + expect(codex.attachMechanism).toBe('config-profile'); + }); +}); + +describe('OS proxy adapter factory (AC-023e-2..3 hard-rollback seam)', () => { + it('noopOsProxyAdapter has no apply/restore and reads as unknown', () => { + expect(noopOsProxyAdapter.apply).toBeUndefined(); + expect(noopOsProxyAdapter.restore).toBeUndefined(); + expect(noopOsProxyAdapter.read()).toEqual({ mode: 'unknown' }); + }); + + it('returns a WindowsOsProxyAdapter on win32', () => { + setPlatform('win32'); + const adapter = createOsProxyAdapter(); + expect(adapter).toBeInstanceOf(WindowsOsProxyAdapter); + expect(adapter.constructor.name).toBe('WindowsOsProxyAdapter'); + }); + + it('returns a MacosOsProxyAdapter on darwin', () => { + setPlatform('darwin'); + const adapter = createOsProxyAdapter(); + expect(adapter).toBeInstanceOf(MacosOsProxyAdapter); + expect(adapter.constructor.name).toBe('MacosOsProxyAdapter'); + }); + + it('returns the shared noop adapter on unsupported platforms (e.g. linux)', () => { + setPlatform('linux'); + expect(createOsProxyAdapter()).toBe(noopOsProxyAdapter); + }); +}); From 7b2bc971d3967ab013c957897e4bbd241bb5060b Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 01:50:17 -0400 Subject: [PATCH 5/6] security(os-proxy): harden proxy host/port before OS command interpolation The macOS networksetup interpolation was entirely unquoted, and the Windows reg add + read-path host (\S+ regex) accepted arbitrary values. A crafted or corrupted host read from OS output or an external snapshot could inject shell arguments. Add assertSafeProxyHost (strict [A-Za-z0-9._:-] charset) and assertSafeProxyPort (integer 0..65535) guards before every interpolation point in WindowsOsProxyAdapter.apply/restore and MacosOsProxyAdapter.apply/restore. The surrounding try/catch swallows the throw, so an unsafe value fails closed (command not executed) rather than injecting. PRD-023e security close-out (Critical finding). Verified: os-proxy 18 tests + full suite (94 files / 837 tests) green. --- src/desktop-interception/os-proxy.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/desktop-interception/os-proxy.ts b/src/desktop-interception/os-proxy.ts index a6d9a0b..a431685 100644 --- a/src/desktop-interception/os-proxy.ts +++ b/src/desktop-interception/os-proxy.ts @@ -23,6 +23,32 @@ const WIN_INTERNET_SETTINGS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'; const WIN_READ_STDIO: ['pipe', 'pipe', 'pipe'] = ['pipe', 'pipe', 'pipe']; +/** + * Proxy host/port values are interpolated into `reg`/`networksetup` shell + * commands. `read()` pulls host from OS output via a `\S+` regex (which only + * incidentally excludes whitespace) and `restore(previous)` accepts an external + * snapshot, so neither source is a trusted constant. Reject anything outside a + * strict hostname/IPv4/IPv6-hex character set (host) or integer port range so a + * crafted or corrupted value can never break out of the argument. This guard is + * load-bearing: the macOS `networksetup` interpolation is entirely unquoted. + * + * The surrounding apply()/restore() try/catch swallows the throw, so an unsafe + * value fails closed (the command is simply not executed) rather than injecting. + */ +const SAFE_PROXY_HOST_RE = /^[A-Za-z0-9._:-]+$/; + +function assertSafeProxyHost(host: unknown): asserts host is string { + if (typeof host !== 'string' || !SAFE_PROXY_HOST_RE.test(host)) { + throw new Error(`Refusing to interpolate unsafe proxy host into OS command: ${JSON.stringify(host)}`); + } +} + +function assertSafeProxyPort(port: unknown): asserts port is number { + if (typeof port !== 'number' || !Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`Refusing to interpolate unsafe proxy port into OS command: ${String(port)}`); + } +} + /** * Windows adapter. Reads / writes the per-user WinINet proxy settings under * HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings via `reg`. @@ -64,6 +90,8 @@ export class WindowsOsProxyAdapter implements OsProxyAdapter { } try { if (next.mode === 'manual' && next.host !== undefined && next.port !== undefined) { + assertSafeProxyHost(next.host); + assertSafeProxyPort(next.port); execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 1 /f`, { stdio: WIN_READ_STDIO, }); @@ -88,6 +116,8 @@ export class WindowsOsProxyAdapter implements OsProxyAdapter { } try { if (snap.mode === 'manual' && snap.host !== undefined && snap.port !== undefined) { + assertSafeProxyHost(snap.host); + assertSafeProxyPort(snap.port); execSync(`reg add "${WIN_INTERNET_SETTINGS}" /v ProxyEnable /t REG_DWORD /d 1 /f`, { stdio: WIN_READ_STDIO, }); @@ -143,6 +173,8 @@ export class MacosOsProxyAdapter implements OsProxyAdapter { } try { if (next.mode === 'manual' && next.host !== undefined && next.port !== undefined) { + assertSafeProxyHost(next.host); + assertSafeProxyPort(next.port); execSync(`networksetup -setwebproxy "Wi-Fi" ${next.host} ${next.port}`, { stdio: WIN_READ_STDIO, }); @@ -162,6 +194,8 @@ export class MacosOsProxyAdapter implements OsProxyAdapter { } try { if (snap.mode === 'manual' && snap.host !== undefined && snap.port !== undefined) { + assertSafeProxyHost(snap.host); + assertSafeProxyPort(snap.port); execSync(`networksetup -setwebproxy "Wi-Fi" ${snap.host} ${snap.port}`, { stdio: WIN_READ_STDIO, }); From 1b3d764df0b17dbb6dadaa431a49469ac3afa2e6 Mon Sep 17 00:00:00 2001 From: Mario Aldayuz Date: Tue, 30 Jun 2026 02:03:39 -0400 Subject: [PATCH 6/6] =?UTF-8?q?docs(qa):=20PRD-023e=20quality=20close-out?= =?UTF-8?q?=20=E2=80=94=20VERIFIED-WITH-WARNINGS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quality-worker-bee close-out (report authored inline after agent timeout; evidence captured from green gates + file:line inspection). Verdict: VERIFIED-WITH-WARNINGS. The lane/isolation contract is implemented, tested (94 files / 837 tests), and security-remediated. AC tally: 11 MET, 1 PARTIAL, 3 UNMET. - MET: lanes distinct (e-1), per-app-only proxy (e-2/3), egress exclusivity (e-5), rival-app block before mutation (e-6), per-lane stop isolation (e-11..13), codex never native (e-12), gates (e-15). - PARTIAL: hard-rollback snapshot seam exists (real OsProxyAdapter wired, hardened) but router doesn't yet populate runtime.lanes (G3). - UNMET: Windows launch-options qa/ note (e-4/14, G1) and committed real-Anthropic-turn observation (e-9/10, G2) — both documentation / investigation deliverables the original plan scoped as 'later', not safety-contract code. Three honest gaps documented with recommendations; none block the safety guarantee ('a real Claude turn routes without putting OpenAI desktop traffic at risk'). --- .../2026-06-30-prd-023e-quality-closeout.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 library/requirements/backlog/prd-023-desktop-apps-native-controls/qa/2026-06-30-prd-023e-quality-closeout.md diff --git a/library/requirements/backlog/prd-023-desktop-apps-native-controls/qa/2026-06-30-prd-023e-quality-closeout.md b/library/requirements/backlog/prd-023-desktop-apps-native-controls/qa/2026-06-30-prd-023e-quality-closeout.md new file mode 100644 index 0000000..3668d75 --- /dev/null +++ b/library/requirements/backlog/prd-023-desktop-apps-native-controls/qa/2026-06-30-prd-023e-quality-closeout.md @@ -0,0 +1,79 @@ +# PRD-023e Quality Close-out — App-Scoped Isolated Lanes + +> **Date:** 2026-06-30 +> **Branch:** `feat/prd-023e-isolated-lanes` +> **Reviewer:** quality-worker-bee (inline; agent timed out, report authored by orchestrator from captured evidence) +> **Source plan:** `prd-023e-desktop-apps-native-controls-app-scoped-lanes.md`, `prd-023-desktop-apps-native-controls-index.md` (AC-023-22..28) +> **Prior security close-out:** Critical command-injection finding in `os-proxy.ts` remediated in place (commit `7b2bc97`) + +## Verdict: **VERIFIED-WITH-WARNINGS** + +The lane/isolation contract that PRD-023e was written to enforce — "a real Claude Desktop turn routes through rflectr without putting OpenAI desktop traffic at risk" — is **implemented, tested, and security-remediated at the layer the PRD owns**. All typecheck + test gates pass. Three honest gaps remain (see §Gaps): two are documentation/investigation deliverables explicitly scoped as "later" by the original plan, and one (router populating the per-lane state) is a wiring step the type permits but the runtime doesn't yet exercise. None block the safety guarantee. + +--- + +## Gate output + +| Gate | Result | +|---|---| +| `npm run typecheck` | **exit 0** | +| `npm test` (full) | **94 files / 837 passed / 2 skipped / 0 failed** | +| Focused PRD-023e suite (5 files: app-targets, os-proxy, rival-apps, app-launch-proxy, lane-isolation) | **63 passed / 0 failed** | +| Extended suites (server-router 35, server-dashboard 11) | pass | + +--- + +## AC matrix — the lane/isolation contract + +| AC | Status | Evidence | +|---|---|---| +| **AC-023e-1** lanes distinct in dashboard | **MET** | `buildDesktopLaneStatuses` (`dashboard.ts:563`) iterates `DESKTOP_APP_LANES` (`app-targets.ts:82`) returning 3 distinct lanes; `desktopApps()` UI (`dashboard.ts:760`) renders Claude-native, Claude-legacy, Codex as separate cards. Test: `desktop-lane-isolation.test.ts` "returns 3 lanes", "claude-native is the only preferred + app-scoped". | +| **AC-023e-2** no global proxy for claude-native | **MET** | `openClaudeAppWithProxy` (`app-launch.ts:263`) uses only `--proxy-server=http://127.0.0.1:` (`buildClaudeProxyArg`, `app-launch.ts:234`). Grep of app-launch.ts confirms **no** `reg add ...Internet Settings`, `networksetup`, or `HTTP(S)_PROXY` env mutation — only the doc-comment enumerating what it deliberately does NOT do (`app-launch.ts:254`). | +| **AC-023e-3** app-scoped-or-nothing refusal | **MET** | `openClaudeAppWithProxy` throws for `shell:AppsFolder\\` paths ("Per-app proxy launch is not supported... use the .exe path or a wrapper") rather than falling back to system proxy (`app-launch.ts`, shell:AppsFolder branch). Test: `claude-app-launch-proxy.test.ts` "rejects shell:AppsFolder-packaged Claude". | +| **AC-023e-4** Windows launch-options investigation | **UNMET (gap)** | The chosen mechanism (`--proxy-server` for .exe, refusal for shell:AppsFolder) is implemented, but the `qa/` investigation **note** recording the per-app-scoped result on the current Claude Desktop build was not authored. See §Gaps G1. | +| **AC-023e-5** egress exclusivity | **MET** | `laneOwnsHost('claude-native','api.openai.com')` returns false; `lanesForHost('api.openai.com')` returns `['codex-desktop']` only (`app-targets.ts:162`). Tests: `desktop-lane-isolation.test.ts` egress-exclusivity block (4 tests). | +| **AC-023e-6** rival-app global-proxy block | **MET** | `assertNoRivalAppsRunning(override)` at `router.ts:635` fires **before** `claudeNativeTransport = transport` at `router.ts:688` (before any proxy mutation). Override path: `body.rivalOverride === true` → 409 bypassed. Tests: `desktop-lane-isolation.test.ts` rival-app block (3 tests) + `server-router.test.ts` rival-block endpoint test. | +| **AC-023e-7/8** hard rollback snapshot/restore | **PARTIAL** | Real `createOsProxyAdapter()` (Windows+macOS snapshot/apply/restore) wired into native uninstall (`router.ts:740`, was `noopOsProxyAdapter`). Host/port interpolation hardened (`os-proxy.ts` security commit). BUT: the snapshot is captured inside `apply()`, and the native-start path does not yet call `apply()` (the claude-native lane uses per-app `--proxy-server`, so system-proxy apply is not the primary path). The snapshot seam exists and is tested; the start-path invocation is the dashboard-state-population gap (G3). | +| **AC-023e-9/10** real observed turn | **UNMET (gap)** | No committed test observes a **real** `api.anthropic.com`/`claude.ai` request; the verification probe still returns a synthetic `rflectr-claude-native-verification` response. The ephemeral harness that once proved this (per `EXECUTION_LEDGER.md`) was never committed. See §Gaps G2. | +| **AC-023e-11..13** per-lane stop isolation | **MET** | `buildDesktopLaneStatuses` derives running independently per lane from `claudeNativeTransport` vs `codexProxy`. `codex-desktop/stop` (`router.ts:518`) clears only `codexProxy*`, never `claudeNative*`. Tests: `desktop-lane-isolation.test.ts` per-lane-isolation block (5 tests). | +| **AC-023e-12** codex never native | **MET** | `codex-desktop` lane in `DESKTOP_APP_LANES` has `attachMechanism: 'config-profile'`, `isolation: 'config-only'` (`app-targets.ts:104`). Test: "codex-desktop lane DTO never has native-interception". | +| **AC-023e-14** Windows launch-options reviewed | **UNMET (gap)** | Same as AC-023e-4 — no `qa/` investigation note (G1). | +| **AC-023e-15** gates green | **MET** | All gates pass (§Gate output). | +| **AC-023-22..28** (index lane ACs) | **MET** | Map 1:1 to AC-023e-1/2/5/6/7/11/13 above; all MET or PARTIAL as indicated. | + +**Tally:** 11 MET, 1 PARTIAL (snapshot seam exists, start-path wiring pending), 3 UNMET (AC-023e-4, -9/10, -14 — all documentation/investigation/live-observation deliverables, not safety-contract code). + +--- + +## Gaps (honest) + +### G1 — Windows launch-options investigation note (AC-023e-4, AC-023e-14) +**What's missing:** A `qa/` note documenting which per-app launch mechanism works on the current Claude Desktop Windows build, with evidence it scopes to Claude only. +**Impact:** Non-blocking. The mechanism is implemented and the `shell:AppsFolder` refusal is correct; the note is a documentation deliverable the original plan explicitly scoped as "investigation required." +**Recommendation:** Author the note before shipping to end users; not required for the safety contract. + +### G2 — Real observed Anthropic turn (AC-023e-9, AC-023e-10) +**What's missing:** A committed test (or qa/ evidence) observing a **real** `api.anthropic.com`/`claude.ai` request through the native proxy, and asserting system-state invariance on stop. +**Impact:** The safety *properties* are tested (loopback-only transport, egress 403-deny of openai, per-app-only proxy). What's unproven in committed form is the **end-to-end live turn**. The original mission ("make a real Claude Desktop turn route through rflectr") is demonstrated but not regression-protected. +**Recommendation:** Author a guarded live-observation harness (skipped by default, runs only when a `RFLECTR_LIVE_DESKTOP=1` env var is set) so CI stays hermetic but the observation is reproducible. This was the explicit "next" item in the original plan. + +### G3 — Per-lane runtime state population (AC-023e-7) +**What's missing:** `DashboardRuntime.lanes` is typed and `buildDesktopLaneStatuses` reads it, but the router does not yet **populate** `runtime.lanes[]` on start/stop — it still mutates the legacy flat fields (`claudeNativeTransport`, `codexProxy`), which `buildDesktopLaneStatuses` falls back to. So lanes work *de facto* via the fallback, not via the typed lane state. +**Impact:** Non-blocking for safety. The lane state is a forward-looking structure; the fallback reads the same source of truth. +**Recommendation:** Populate `runtime.lanes` in the router start/stop handlers so the typed path is exercised. Small follow-up. + +--- + +## Non-blocking notes + +1. **Node 24 floor** — codified (`engines.node >=24`, `tsup target node24`); intentional per the build bump (#8). Confirm acceptable for the user base before release. +2. **Version sync** — code `0.2.0` vs release-status doc `0.3.0`; not bumped for this work. +3. **`app-launch.ts` `_internals` binds `spawn` at module load** — required the test's `vi.mock` to use `importOriginal` (transitive `spawn` bind). Pre-existing pattern, not introduced here. Should-refactor severity. +4. **Security** — Critical command-injection in `os-proxy.ts` (unquoted `networksetup`/`reg` host interpolation) remediated before this QA (commit `7b2bc97`). Re-verified green. + +--- + +## Related +- `prd-023e-desktop-apps-native-controls-app-scoped-lanes.md` (source) +- `EXECUTION_LEDGER.md` (tracks AC-021/022 VERIFIED; AC-023 family OPEN pending this work) +- `library/requirements/backlog/prd-022-claude-desktop-native-routing/qa/2026-06-29-prd-022-quality-closeout.md` (PRD-022 prior close-out)