diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 887493f1935..03e1dbd11e0 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -1,6 +1,6 @@ --- -title: ChatGPT Desktop app-server shim (experimental) -description: An opt-in macOS experiment that rewrites plain-quota gate fields on the bundled app-server stdout pipe. +title: ChatGPT Desktop integrations (experimental) +description: Opt-in macOS app-server shim and local-CA TLS intercept experiments for plain-quota send gates. --- This experiment is **macOS only and off by default**. It filters the bundled ChatGPT @@ -91,7 +91,7 @@ folders and trusted sticky folders retain their normal permissions behavior. These are ownership, POSIX-permission, and signature checks; native ACL and volume ownership-policy behavior has not been verified. -This integration installs no certificate, network listener, PAC, or background +The app-server shim alone installs no certificate, network listener, PAC, or background watcher. It does not log the app's messages or environment. Status reports whether the running ChatGPT bundle process carries the expected launcher override. @@ -129,3 +129,150 @@ This standalone shim does not rewrite conversation metadata or route model calls Other app gates or upstream refusals can still prevent sending. Evidence reported on an exhausted Plus account also used an intercept, so it does not establish that this shim alone resolves every desktop send lock. + +## Local-CA TLS intercept (experimental candidate) + +The separate `chatgptDesktop.unblockSend` experiment terminates TLS for the +`chatgpt.com` apex host on loopback, relays the account's cookies and credentials, +and rewrites known quota send gates in conversation metadata and usage responses. +It is **off by default**, macOS only, and independent of `appServerShim`: + +```json +{ + "chatgptDesktop": { + "unblockSend": true, + "port": 10300 + } +} +``` + +`port` is optional; its default is the running proxy's public port plus 200 +(`10100` → `10300`). A derived port outside the TCP range requires an explicit +free port. Client-role processes do not start the intercept. A bind or certificate +failure warns without stopping the proxy's other services. + +Start OpenCodex with this config, then run `ocx chatgpt status`. The listener +creates or reuses the local authority shared with the Claude intercept. **You +must trust this CA yourself** in the macOS login keychain before launching the +intercepted app. Status prints the exact command; with the default config path: + +```bash +security add-trusted-cert -r trustRoot -p ssl -k "$HOME/Library/Keychains/login.keychain-db" "$HOME/.opencodex/claude-intercept/ca.pem" +ocx chatgpt launch +ocx chatgpt status +``` + +Use the certificate path reported by status if your OpenCodex home differs. The +CLI prints the trust command and never runs it. Trusting a local CA changes the +login keychain's TLS trust: anyone controlling its private key can issue trusted +certificates. The listener sees the decrypted account traffic, including cookies, +authorization headers and message content that it relays. Protect the config +directory and CA key. The relay does not log request bodies or credentials. +`restore` removes launch overrides; it does **not** remove CA trust or delete the +shared authority. Remove trust manually through Keychain Access when you no +longer need it, accounting for other integrations using the same authority. + +Launch restarts ChatGPT with +`--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`. Explicit system HTTP/SOCKS +proxies get an apex-host bypass while other hosts retain the proxy without a synthetic +direct fallback; TUN/direct networking needs only the resolver rule. An existing system +PAC cannot be combined with that bypass, so it may prevent the intercept from +seeing traffic. With `pacFallback` (below) the app is launched through a generated +PAC instead of the resolver rule. + +If both flags are true, `ocx chatgpt launch` applies the existing app-server shim +and the intercept together. The shim alone still works without a running proxy. +The intercept requires a listener authenticated by the published local CA and the `chatgpt.com` hostname, +then confirmed by its service identity. Missing or invalid CA files leave the app untouched; these +checks do not install keychain trust. When OpenCodex +stops, an app still carrying the resolver rule cannot reach `chatgpt.com`; run +`ocx chatgpt restore` to relaunch with native networking, or use PAC fallback. + +### PAC fallback: preserve your network policy when OpenCodex stops + +PAC fallback launches the app with a generated PAC script instead of the resolver +rule, so the app falls back on its own. The script is passed inline (a `data:` URL) +because the app ignores a `file://` PAC and an `http://` one would need OpenCodex +running to be fetched: + +```json +{ "chatgptDesktop": { "unblockSend": true, "pacFallback": true } } +``` + +`pacFallback` only takes effect together with `unblockSend`. Config diagnostics +warn when PAC fallback is enabled on its own; the saved flags are left unchanged. +OpenCodex normally also listens on the listener port plus one (`10301` by default), +or port `65534` when the listener uses `65535`. This CONNECT entry splices onto the +TLS listener. OpenCodex rewrites `chatgpt-unblock.pac` in its home +directory at every start, before the `chatgpt-unblock.ready` marker. Before restarting an +app, the launcher prepares the PAC argument; if the file cannot be read and encoded, +is empty, or is not a regular file, it leaves the running app untouched. The PAC sends +`chatgpt.com` to that entry first, and every other host the way the system routes it: + +| Setup | Other hosts, and `chatgpt.com` while OpenCodex is stopped | +|---|---| +| No proxy, or VPN in TUN mode | Direct. | +| VPN in system-proxy mode | The configured system proxy chain, without adding direct fallback. | +| PAC file | The system PAC, embedded in the generated file. | + +When OpenCodex stops, the app uses the captured route without a restart; the send +unblock pauses until OpenCodex is back. If every configured proxy is unavailable, +requests fail instead of silently connecting directly. Embedded system PACs retain +Chromium’s non-mandatory PAC behavior: invalid results or execution failures can +still fall back directly. Do not use that mode when a fail-closed system-PAC policy is required. The route is captured when +OpenCodex starts: after changing the VPN mode, restart OpenCodex and run +`ocx chatgpt launch`. If a system PAC is set but cannot be read at that moment, or +is too large to pass to the app (the PAC travels inside one launch argument, +limited to 512 KiB once encoded), the optional intercept does not start. An unreadable +system proxy configuration or automatic proxy discovery also prevents PAC-mode startup; fix it and restart OpenCodex. After turning `pacFallback` on +or off, restart OpenCodex, run `ocx chatgpt launch`, and run +`ocx chatgpt install-watcher` again if you use the watcher. `restore` undoes a PAC +launch the same way as a resolver-rule one. Resolver-rule launches likewise reject unreadable +system proxy settings before quitting the app and never add a direct fallback to an explicit +proxy. `restore` remains available even when proxy settings cannot be read. + +### Intercept rewrite boundary + +Only `/backend-api/conversation/init`, `/backend-api/conversation` and +`/backend-api/f/conversation` (including child paths), plus the exact +`/backend-api/wham/usage` and `/backend-api/wham/usage/stream` paths are rewritten. +Conversation metadata loses known quota `send` / `tpp_send` blocks and exhausted +send progress entries. Unknown and subscription/policy/workspace reasons remain; +status reports preserved reasons. Usage rewriting reuses the shim's gate helpers, +keeping workspace, credit and spend-control gates and usage display intact. Other +HTTP responses pass through; WebSocket upgrades, voice and dictation relay +without rewriting through direct, HTTP CONNECT or shared SOCKS5 transport. + +### Optional intercept launch watcher + +```bash +ocx chatgpt install-watcher --yes +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +Installation without `--yes` asks in an interactive terminal. The launchd agent +watches the app's Electron `SingletonLock` and the `chatgpt-unblock.ready` marker. In watch +mode, it restarts an app launched without intercept switches only while the listener answers as +OpenCodex and the app is no more than five minutes old; a missing or unparseable process age +counts as fresh, and explicit `ocx chatgpt launch` is not age-limited. This can interrupt +startup work; it does nothing while the listener is unavailable. It manages +**intercept launches only**; use explicit launch for the app-server shim. A loaded +watcher must be uninstalled before `restore`, so it cannot put the switches back. +The watcher records the absolute bundle path discovered at installation. Before each restart, +it rechecks the bundle identifier, ownership, parent permissions and OpenAI signature, then quits +and opens that same path. If the bundle or OpenCodex runtime moved, reinstall the watcher; a failed +check leaves the running app untouched. +In PAC-fallback mode the watcher also verifies a complete CONNECT and CA-authenticated identity +exchange through the entry, and launches with the PAC switch instead of the resolver rule. A foreign +listener or an entry that only accepts TCP is not considered ready. + +### Evidence and decision limits + +[#6196](https://github.com/lidge-jun/opencodex/issues/6196) reported zero established +listener connections over about 20 hours on current Desktop builds: the bundled +app-server performs the gate reads and may bypass Chromium's resolver rule. +The later exhausted-Plus-account report used the shim, intercept and restart +together and does not isolate the intercept's effectiveness. This candidate +conflicts with the maintainer's provider-aware admission design, which rejects +local CA installation and quota-data rewriting; maintainers may close it. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..39692b4488a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -73,6 +73,23 @@ "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", "cli-combo-partial-update.test.ts": "cli", + "desktop-unblock-ws-frame.test.ts": "clients", + "desktop-unblock-watcher-install.test.ts": "clients", + "desktop-unblock-lifecycle.test.ts": "clients", + "desktop-unblock-readiness-auth.test.ts": "clients", + "desktop-unblock-bundle-preflight.test.ts": "clients", + "desktop-unblock-ws-relay.test.ts": "clients", + "desktop-unblock-ws-upstream.test.ts": "clients", + "desktop-unblock-runtime.test.ts": "clients", + "desktop-unblock-pac.test.ts": "clients", + "desktop-unblock-entry-proxy.test.ts": "clients", + "desktop-unblock-pac-runtime.test.ts": "clients", + "desktop-unblock-listener.test.ts": "clients", + "desktop-unblock-launch-script.test.ts": "clients", + "desktop-unblock-config-boundary.test.ts": "clients", + "desktop-unblock-ca-trust.test.ts": "clients", + "desktop-rewrite.test.ts": "clients", + "socks5-handshake.test.ts": "lib", "owner-registry-acl.test.ts": "config", "minimax-combo-image-input.test.ts": "providers", "web-search-forced-declaration.test.ts": "web-search", diff --git a/skills/ocx/references/01_surface_lifecycle.md b/skills/ocx/references/01_surface_lifecycle.md index e8aa0b21a9d..e64e3ca74e5 100644 --- a/skills/ocx/references/01_surface_lifecycle.md +++ b/skills/ocx/references/01_surface_lifecycle.md @@ -12,7 +12,7 @@ Declared capabilities: 12. ### `ocx chatgpt` -Experimental ChatGPT app-server shim: launch, restore or status (macOS only). +Experimental ChatGPT shim/intercept: launch, restore, status and watcher management (macOS only). State-changing: yes. @@ -20,7 +20,7 @@ Drives no management route. JSON mode: `none`. -- Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher. +- Default off; launch requires chatgptDesktop.appServerShim or unblockSend. Intercept needs the running proxy and manual CA trust. Watcher manages intercept launches only; restore removes the shim launcher. ### `ocx status` diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index 4864a7bece3..7cf13393fb6 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -1,7 +1,7 @@ /** Plain-quota gate rewriting; usage display and non-quota restrictions are preserved. */ const PLAIN_QUOTA_REACHED_TYPE = "rate_limit_reached"; -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/chatgpt/desktop-unblock/bundle-preflight.ts b/src/chatgpt/desktop-unblock/bundle-preflight.ts new file mode 100644 index 00000000000..cade45bb31d --- /dev/null +++ b/src/chatgpt/desktop-unblock/bundle-preflight.ts @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import { isAbsolute, join, normalize } from "node:path"; +import { untrustedChatgptBundleReason } from "../app-server-shim/bundle-trust"; + +export interface WatcherBundlePreflightDeps { + platform: NodeJS.Platform; + trustReason(path: string): string | null; + bundleIdentifier(path: string): string | null; +} + +const defaults: WatcherBundlePreflightDeps = { + platform: process.platform, + trustReason: untrustedChatgptBundleReason, + bundleIdentifier(path) { + const result = spawnSync("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleIdentifier", join(path, "Contents", "Info.plist")], { + encoding: "utf8", timeout: 5000, + }); + return result.status === 0 ? result.stdout.trim() : null; + }, +}; + +/** Recheck the recorded bundle on each watcher wake, before either quit or open. */ +export function chatgptWatcherBundleProblem(path: string, deps: WatcherBundlePreflightDeps = defaults): string | null { + if (deps.platform !== "darwin") return "the ChatGPT launch watcher is only supported on macOS"; + if (!isAbsolute(path) || normalize(path) !== path) return "the ChatGPT bundle path must be absolute and normalized"; + const reason = deps.trustReason(path); + if (reason) return reason; + if (deps.bundleIdentifier(path) !== "com.openai.codex") return "the bundle identifier is not com.openai.codex"; + return null; +} + +export function runChatgptWatcherBundlePreflight(path: string): number { + const problem = chatgptWatcherBundleProblem(path); + if (!problem) return 0; + console.error(`Refusing the ChatGPT watcher action: ${problem}.`); + return 1; +} diff --git a/src/chatgpt/desktop-unblock/ca-trust.ts b/src/chatgpt/desktop-unblock/ca-trust.ts new file mode 100644 index 00000000000..a85d5187d0f --- /dev/null +++ b/src/chatgpt/desktop-unblock/ca-trust.ts @@ -0,0 +1,51 @@ +import { X509Certificate } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultSecurityRunner, loginKeychainPath, type SecurityRunner } from "../../claude/intercept/picker-trust"; + +/** + * Whether macOS trusts the intercept CA the ChatGPT listener's leaf is issued from. + * + * Without that trust every request the app sends to chatgpt.com fails certificate + * verification, which the app does not report: account, usage and settings pages just stay + * empty. `ocx chatgpt status` surfaces this state so the cause is visible. Trust is matched by + * the CA's SHA-1 fingerprint in the user's exported trust settings, so a different or + * regenerated certificate with the same name never counts. + */ + +export type ChatgptCaTrust = "trusted" | "untrusted" | "missing" | "unknown" | "unsupported"; + +export function certificateSha1(pem: string): string { + return new X509Certificate(pem).fingerprint.replace(/:/g, "").toUpperCase(); +} + +export async function inspectChatgptCaTrust( + caPath: string, + run: SecurityRunner = defaultSecurityRunner, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "darwin") return "unsupported"; + if (!existsSync(caPath)) return "missing"; + let dir: string | undefined; + try { + const sha1 = certificateSha1(readFileSync(caPath, "utf8")); + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-trust-")); + const file = join(dir, "trust-settings.plist"); + const exported = await run(["trust-settings-export", file]); + if (exported.code !== 0) { + // A user domain with no trust settings at all cannot be exported; that is plain "untrusted". + return /no trust settings/i.test(`${exported.stdout}${exported.stderr}`) ? "untrusted" : "unknown"; + } + return readFileSync(file, "utf8").includes(`${sha1}`) ? "trusted" : "untrusted"; + } catch { // no-excuse-ok: catch -- unreadable certificate or trust settings are no evidence of trust. + return "unknown"; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + +/** The command that restores trust; it prompts for the login password, so only the user runs it. */ +export function chatgptCaTrustCommand(caPath: string): string { + return `security add-trusted-cert -r trustRoot -p ssl -k "${loginKeychainPath()}" "${caPath}"`; +} diff --git a/src/chatgpt/desktop-unblock/entry-proxy.ts b/src/chatgpt/desktop-unblock/entry-proxy.ts new file mode 100644 index 00000000000..3b938a0b3f8 --- /dev/null +++ b/src/chatgpt/desktop-unblock/entry-proxy.ts @@ -0,0 +1,194 @@ +import { connect as bunConnect, listen as bunListen, type Socket, type TCPSocketListener } from "bun"; + +/** + * Loopback CONNECT entry for the ChatGPT desktop send-unblock PAC fallback. + * + * The PAC points chatgpt.com at this plaintext listener. It speaks only enough HTTP proxy to + * accept `CONNECT chatgpt.com:443`, answer 200, and splice the raw bytes both ways onto the TLS + * origin listener (which terminates the intercept). Anything else is refused: this is not a + * general forward proxy, and the app only ever asks for the intercepted host here. + * + * When opencodex stops, this listener dies with the process; Chromium sees the refused CONNECT + * and falls through the PAC chain on its own, with no app restart. + */ + +const MAX_HEAD_BYTES = 8 * 1024; +const HEAD_TIMEOUT_SECONDS = 10; + +/** + * One direction of the splice. Bun sockets are unbuffered: `write()` takes what fits and + * returns the count, so the remainder waits here and the producing socket stays paused until + * the target drains. One dropped byte would corrupt the TLS stream the tunnel carries. + */ +interface Pipe { + queue: Buffer[]; + /** The producing side closed; end the target once the queue is flushed. */ + ended: boolean; +} + +interface EntryState { + head: Buffer; + /** Set once the CONNECT head parsed; the app socket is paused until the upstream attaches. */ + connecting: boolean; + upstream: Socket | null; + /** app -> origin */ + toUpstream: Pipe; + /** origin -> app */ + toClient: Pipe; +} + +export interface EntryProxyHandle { + port: number; + stop(): Promise; +} + +export interface StartEntryProxyOptions { + /** Loopback port of the TLS origin listener the tunnels splice onto. */ + originPort: number; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; + /** Test seam: seconds a client has to finish its CONNECT head. */ + headTimeoutSeconds?: number; +} + +/** Write what `target` accepts now and queue the rest, pausing `source` until `target` drains. */ +function send(target: Socket, pipe: Pipe, bytes: Uint8Array, source: Socket): void { + if (pipe.queue.length === 0) { + const wrote = target.write(bytes); + // -1: the target is closed and its close handler tears the pair down. + if (wrote < 0 || wrote === bytes.byteLength) return; + bytes = bytes.subarray(wrote); + } + pipe.queue.push(Buffer.from(bytes)); + source.pause(); +} + +/** From the target's `drain`: flush the queue, then resume the source or finish the half. */ +function flush(target: Socket, pipe: Pipe, source: Socket | null): void { + while (pipe.queue.length > 0) { + const next = pipe.queue[0]!; + const wrote = target.write(next); + if (wrote < 0) { + pipe.queue.length = 0; + return; + } + if (wrote < next.length) { + pipe.queue[0] = next.subarray(wrote); + return; + } + pipe.queue.shift(); + } + if (pipe.ended) target.end(); + else source?.resume(); +} + +/** The producer of `pipe` closed: end the target now, or once the queued bytes are written. */ +function finish(target: Socket, pipe: Pipe): void { + pipe.ended = true; + if (pipe.queue.length === 0) target.end(); +} + +function refuse(socket: Socket, line: string): void { + if (socket.data.upstream) return; + socket.write(`HTTP/1.1 ${line}\r\nConnection: close\r\n\r\n`); + socket.end(); +} + +function handleData(socket: Socket, chunk: Uint8Array, originPort: number): void { + const state = socket.data; + if (state.upstream) { + send(state.upstream, state.toUpstream, chunk, socket); + return; + } + if (state.connecting) { + // Read before the pause took effect; it goes out right after the 200. + state.toUpstream.queue.push(Buffer.from(chunk)); + return; + } + state.head = state.head.length === 0 ? Buffer.from(chunk) : Buffer.concat([state.head, Buffer.from(chunk)]); + const end = state.head.indexOf("\r\n\r\n"); + if (end === -1) { + if (state.head.length > MAX_HEAD_BYTES) refuse(socket, "431 Request Header Fields Too Large"); + return; + } + const head = state.head.subarray(0, end).toString("latin1"); + const leftover = state.head.subarray(end + 4); + if (!/^CONNECT\s+chatgpt\.com:443\s+HTTP\/1\.[01]\r?$/i.test(head.split("\r\n")[0] ?? "")) { + refuse(socket, "403 Forbidden"); + return; + } + // Answer exactly one CONNECT: later bytes are tunnel payload, never another head. + state.connecting = true; + socket.pause(); + if (leftover.length > 0) state.toUpstream.queue.push(Buffer.from(leftover)); + bunConnect({ + hostname: "127.0.0.1", + port: originPort, + socket: { + data(upstream, upChunk) { send(socket, state.toClient, upChunk, upstream); }, + drain(upstream) { flush(upstream, state.toUpstream, socket); }, + close() { finish(socket, state.toClient); }, + error() { /* close follows */ }, + }, + }).then(upstream => { + state.upstream = upstream; + state.connecting = false; + // The head deadline guards a silent client; a live tunnel may idle for as long as it likes. + socket.timeout(0); + send(socket, state.toClient, Buffer.from("HTTP/1.1 200 Connection established\r\n\r\n"), upstream); + flush(upstream, state.toUpstream, socket); + }).catch(() => { + state.connecting = false; + refuse(socket, "502 Bad Gateway"); + }); +} + +export function startChatgptUnblockEntryProxy(options: StartEntryProxyOptions): Promise { + return new Promise((resolve, reject) => { + let server: TCPSocketListener; + try { + server = bunListen({ + hostname: "127.0.0.1", + port: options.port ?? 0, + socket: { + open(socket) { + socket.data = { + head: Buffer.alloc(0), + connecting: false, + upstream: null, + toUpstream: { queue: [], ended: false }, + toClient: { queue: [], ended: false }, + }; + socket.timeout(options.headTimeoutSeconds ?? HEAD_TIMEOUT_SECONDS); + }, + data(socket, chunk) { handleData(socket, chunk, options.originPort); }, + // Bun only fires the deadline when a handler exists, and never closes on its own. + timeout(socket) { + if (!socket.data.upstream) socket.end(); + }, + drain(socket) { + if (socket.data.upstream) flush(socket, socket.data.toClient, socket.data.upstream); + }, + close(socket) { + const state = socket.data; + if (state.upstream) finish(state.upstream, state.toUpstream); + else state.toUpstream.ended = true; + }, + error() { /* the close handler tears the pair down */ }, + }, + }); + } catch (error) { + reject(error); + return; + } + // Bun binds synchronously: `listen()` either returned a serving listener or threw. + resolve({ + port: server.port, + stop: () => + new Promise(done => { + server.stop(true); + done(); + }), + }); + }); +} diff --git a/src/chatgpt/desktop-unblock/launch-watcher.ts b/src/chatgpt/desktop-unblock/launch-watcher.ts new file mode 100644 index 00000000000..8cc833f8e5a --- /dev/null +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -0,0 +1,763 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; +import { connect as connectSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getConfigDir } from "../../config/paths"; +import { claudeInterceptCaCertPath } from "../../claude/intercept/local-ca"; +import { CHATGPT_INTERCEPT_HOST, CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_SERVICE_ID } from "./listener"; +import type { PreservedSendBlock } from "./rewrite"; +import { CHATGPT_UNBLOCK_PAC_ARG_PREFIX, chatgptUnblockPacArg, chatgptUnblockPacPath, chatgptUnblockReadyPath, chatgptUnblockResolverArg } from "./runtime"; +import { chatgptShimLauncherPath } from "../app-server-shim/launcher"; +import { selfLaunchArgv } from "../../lib/self-launch-argv"; +import { chatgptWatcherBundleProblem } from "./bundle-preflight"; + +/** + * Launch integration for the ChatGPT desktop send-unblock intercept. + * + * The launch switches only apply when the app is launched with them, so a normal + * Dock/Spotlight start reaches the real chatgpt.com and the composer locks again. This module + * installs a launchd agent that watches the app's Electron `SingletonLock` -- written on every + * launch -- and, exactly once per launch, restarts the app with the switches if it was + * started without them. It also watches a readiness marker opencodex writes once its listener is + * up, so an app that started before opencodex (both open at login) is corrected as soon as the + * listener answers. There is no resident polling process: launchd wakes the script on either + * event and the script exits after one check. In watch mode it only restarts an app that started + * within the last five minutes, so it never quits an app the user has been working in. + * + * The watcher only acts when the opencodex intercept listener answers its identity path, so + * with the feature off -- or another process holding the port -- the app is left native. + * + * The watcher, `ocx chatgpt launch` and `ocx chatgpt restore` run the same script, so the launch + * arguments are built in exactly one place. The mode decides the switch set: + * + * resolver-rule mode (default): + * - always `--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`; + * - with an HTTP(S)/SOCKS system proxy (a VPN in system-proxy mode), also + * `--proxy-server=` and `--proxy-bypass-list=chatgpt.com`. Chromium hands + * proxied hosts to the proxy unresolved, which would skip the resolver rule, so the apex host + * must bypass the proxy. The bypass list only takes effect beside an explicit proxy server, + * and a bare hostname there matches that host exactly (subdomains stay on the proxy). A + * failed configured proxy does not silently fall back to a direct connection; + * - with no proxy, or TUN mode (loopback never enters the tunnel), the resolver rule alone; + * - with a PAC file, the resolver rule alone: PAC cannot be combined with a bypass, so + * chatgpt.com may stay on the proxy and the composer may lock, but nothing else breaks. + * + * PAC-fallback mode (`chatgptDesktop.pacFallback`): only `--proxy-pac-url=data:...` carrying the + * PAC inline (a `file://` PAC is ignored by the app). The + * PAC (regenerated at every opencodex start) sends chatgpt.com to the CONNECT entry listener, + * which splices onto the TLS origin listener; when opencodex is down the refused CONNECT makes + * Chromium fall through to the captured system policy (static proxy chains add no DIRECT) -- no resolver rule + * may be present, or it would blackhole that fallback to the dead origin port. No app restart + * is needed to recover. + */ + +export const CHATGPT_APP_PATH = "/Applications/ChatGPT.app"; +/** The desktop app is `openai-codex-electron` internally: its Electron userData dir is `Codex`. */ +export const CHATGPT_SINGLETON_LOCK_PATH = "Library/Application Support/Codex/SingletonLock"; + +/** How recently the app must have started for watch mode to restart it. */ +export const CHATGPT_WATCHER_FRESH_APP_SECONDS = 300; +export const CHATGPT_UNBLOCK_WATCHER_LABEL = "com.opencodex.chatgpt-unblock-watcher"; + +function expandHome(path: string): string { + return path.startsWith("~") ? join(homedir(), path.slice(1)) : path; +} + +/** A value for a single-quoted bash word: `'` becomes `'\''`. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** A value for a plist `` element. */ +function xmlEscape(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +export interface ChatgptUnblockWatcherPaths { + scriptPath: string; + plistPath: string; + errPath: string; + lockPath: string; + /** Written by opencodex once its listener is up; the agent wakes on it too. */ + readyPath: string; +} + +export function chatgptUnblockWatcherPaths(configDir?: string): ChatgptUnblockWatcherPaths { + const dir = configDir ?? getConfigDir(); + return { + scriptPath: join(dir, "chatgpt-unblock-watcher.sh"), + plistPath: expandHome(`~/Library/LaunchAgents/${CHATGPT_UNBLOCK_WATCHER_LABEL}.plist`), + errPath: join(dir, "chatgpt-unblock-watcher.err"), + lockPath: expandHome(`~/${CHATGPT_SINGLETON_LOCK_PATH}`), + readyPath: chatgptUnblockReadyPath(dir), + }; +} + +/** Where the launch script records what it did; kept beside the rest of the opencodex state. */ +function chatgptUnblockWatcherLogPath(configDir?: string): string { + return join(configDir ?? getConfigDir(), "chatgpt-unblock-watcher.log"); +} + +/** + * The launch script. + * watch (launchd, on every app launch) only corrects a running app that lacks the switches; + * launch (`ocx chatgpt launch`) also starts the app when it is not running; + * native (`ocx chatgpt restore`) restarts a switched app WITHOUT them, for when the + * listener is gone or the feature is being turned off. + * + * `pacMode` switches the argument set (see the module doc): the PAC switch alone when on, the + * resolver rule [+ proxy/bypass] otherwise. The app is "flagged" by whichever switch the mode + * uses, so a mode change makes the watcher correct an app launched under the other mode. + */ +export interface ChatgptWatcherBundle { + appPath: string; + /** Test seam: the shared trust-check command, without its bundle-path argument. */ + preflightArgv?: readonly string[]; +} + +export function buildChatgptUnblockWatcherScript( + port: number, + configDir?: string, + shimMode = false, + pac?: { entryPort: number }, + bundle: ChatgptWatcherBundle = { appPath: CHATGPT_APP_PATH }, +): string { + const preflight = bundle.preflightArgv ?? [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-bundle-preflight"], { sourceEntrypoint: fileURLToPath(new URL("../../cli/index.ts", import.meta.url)) })]; + const pacFile = chatgptUnblockPacPath(configDir ?? getConfigDir()); + return `#!/bin/bash +# opencodex ChatGPT send-unblock launcher. +# watch (launchd, fired by the app's Electron SingletonLock on every launch and by the +# readiness marker opencodex writes once its listener is up): if the app is running +# WITHOUT the launch switches (a normal Dock/Spotlight launch) and started within the +# last FRESH_APP_SECONDS, restart it once with them. A correctly launched app, an app +# the user has been working in, or an absent intercept, is left alone. +# launch (ocx chatgpt launch): same, and start the app if it is not running. +# native (ocx chatgpt restore): restart an app that carries the switches without them. + +PORT=${port} +MODE="\${1:-watch}" +unset CODEX_CLI_PATH +RESOLVER_ARG=${shellQuote(chatgptUnblockResolverArg(port))} +PAC_FILE=${shellQuote(pacFile)} +PAC_PREFIX=${shellQuote(CHATGPT_UNBLOCK_PAC_ARG_PREFIX)} +PAC_MODE=${pac ? "1" : "0"} +ENTRY_URL=${shellQuote(`http://127.0.0.1:${pac?.entryPort ?? 0}/`)} +SHIM_MODE=${shimMode ? "1" : "0"} +SHIM_SCRIPT=${shellQuote(chatgptShimLauncherPath(configDir ?? getConfigDir()))} +BYPASS_HOST=${shellQuote(CHATGPT_INTERCEPT_HOST)} +APP_BUNDLE=${shellQuote(bundle.appPath)} +APP_BINARY=${shellQuote(join(bundle.appPath, "Contents", "MacOS", "ChatGPT"))} +BUNDLE_PREFLIGHT=(${preflight.map(shellQuote).join(" ")}) +CA_CERT=${shellQuote(claudeInterceptCaCertPath(configDir ?? getConfigDir()))} +IDENTITY_URL=${shellQuote(`https://${CHATGPT_INTERCEPT_HOST}:${port}${CHATGPT_UNBLOCK_IDENTITY_PATH}`)} +ENTRY_IDENTITY_URL=${shellQuote(`https://${CHATGPT_INTERCEPT_HOST}${CHATGPT_UNBLOCK_IDENTITY_PATH}`)} +SERVICE_ID=${shellQuote(`"service":"${CHATGPT_UNBLOCK_SERVICE_ID}"`)} +LOG=${shellQuote(chatgptUnblockWatcherLogPath(configDir))} +LOCK_DIR="\${TMPDIR:-/tmp}/opencodex-chatgpt-launch.lock" +FRESH_APP_SECONDS=${CHATGPT_WATCHER_FRESH_APP_SECONDS} + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } +say() { [ "$MODE" != watch ] && echo "$*"; log "$*"; } +# The app-server shim is chosen through the app's CODEX_CLI_PATH environment variable, which 'ps eww' +# shows for the app's own process as soon as it exists (a child process would only appear later). +app_has_shim() { + ps eww -o command= -p "$1" 2>/dev/null | grep -qF "CODEX_CLI_PATH=$SHIM_SCRIPT" +} +# The app's main process: found by exact process name, then confirmed by path. Matching the +# whole command line instead would also match any shell whose command mentions the rule. +# -a: pgrep skips its own ancestors by default, and a command run from a terminal inside the +# desktop app has the app as an ancestor. +app_pid() { + local pid + for pid in $(pgrep -a -x ChatGPT 2>/dev/null); do + case "$(ps -o command= -p "$pid" 2>/dev/null)" in + "$APP_BINARY"|"$APP_BINARY "*) echo "$pid"; return 0 ;; + esac + done + return 1 +} +app_running() { app_pid >/dev/null; } +# Seconds a process has been running, from ps's [[dd-]hh:]mm:ss elapsed time. Fails when it +# cannot be read, which the caller treats as a fresh launch. +app_age() { + local etime days=0 h=0 m s a b c + etime=$(ps -o etime= -p "$1" 2>/dev/null | tr -d ' ') + [ -n "$etime" ] || return 1 + case "$etime" in *-*) days=\${etime%%-*}; etime=\${etime#*-} ;; esac + IFS=: read -r a b c <<< "$etime" + if [ -n "$c" ]; then h=$a; m=$b; s=$c; else m=$a; s=$b; fi + case "$days:$h:$m:$s" in *::*|:*|*:|*[!0-9:]*) return 1 ;; esac + echo $(( 10#$days * 86400 + 10#$h * 3600 + 10#$m * 60 + 10#$s )) +} +# The PAC travels inline as a data: URL (a file:// PAC is ignored by the app), so the switch is +# rebuilt from the file opencodex regenerated at its last start. Reject unavailable/empty files +# and propagate read/encoding failures instead of returning a prefix with an incomplete payload. +pac_arg() { + [ -f "$PAC_FILE" ] && [ -r "$PAC_FILE" ] || return 1 + local encoded + encoded=$(set -o pipefail; base64 < "$PAC_FILE" | tr -d '\n') || return 1 + [ -n "$encoded" ] || return 1 + printf '%s%s' "$PAC_PREFIX" "$encoded" +} +# Flagged by whichever switch the configured mode uses, so a mode change corrects an app launched +# under the other mode. +app_flagged() { + local pid cmdline pac + pid=$(app_pid) || return 1 + cmdline=$(ps -o command= -p "$pid" 2>/dev/null) + if [ "$PAC_MODE" = 1 ]; then + pac=$(pac_arg) || return 1 + case "$cmdline" in *" $pac"*) return 0 ;; esac + return 1 + fi + case "$cmdline" in *" $RESOLVER_ARG"*) return 0 ;; esac + return 1 +} + +# The shim is wanted only while its launcher exists: opencodex writes it after the bundle passes +# the OpenAI signature check. Pointing the app at a missing launcher would leave it without an +# app-server. +shim_wanted() { + [ "$SHIM_MODE" = 1 ] && [ -x "$SHIM_SCRIPT" ] +} + +# Either launch switch, whatever the configured mode: restore must also undo the switch an app was +# launched with, including any PAC (an older script, or the file:// form from before the inline +# one), and an app started through the shim counts too, so restore can hand it back native. +app_switched() { + local pid + pid=$(app_pid) || return 1 + local cmdline + cmdline=$(ps -o command= -p "$pid" 2>/dev/null) + case "$cmdline" in *" $PAC_PREFIX"*|*" --proxy-pac-url=file://$PAC_FILE"*|*" $RESOLVER_ARG"*) return 0 ;; esac + app_has_shim "$pid" && return 0 + return 1 +} +# Authenticate both the local authority and chatgpt.com before accepting the service identity. +# -q disables curlrc overrides; the explicit loopback mapping and proxy policy prevent a probe +# from leaving this machine. No user keychain trust is required or changed. +listener_ours() { + local identity + identity=$(curl -qfsS --noproxy '*' --max-time 3 --cacert "$CA_CERT" \\ + --resolve "$BYPASS_HOST:$PORT:127.0.0.1" "$IDENTITY_URL" 2>/dev/null) || return 1 + printf '%s' "$identity" | grep -qF "$SERVICE_ID" +} +# Prove that the PAC entry actually CONNECTs to the authenticated intercept, not just that +# something accepts TCP. Empty/failed responses (including curl exit 56) are never readiness. +entry_ours() { + [ "$ENTRY_URL" != ${shellQuote("http://127.0.0.1:0/")} ] || return 1 + local identity + identity=$(curl -qfsS --noproxy '' --max-time 3 --cacert "$CA_CERT" \\ + --proxy "$ENTRY_URL" "$ENTRY_IDENTITY_URL" 2>/dev/null) || return 1 + printf '%s' "$identity" | grep -qF "$SERVICE_ID" +} +# \`open\` on a running app only activates it and drops the arguments, so the old instance must +# be fully gone first. Quit is re-sent because the app can ignore it while starting up. +quit_app() { + for attempt in 1 2 3; do + osascript -e 'on run argv' -e 'tell application (POSIX file (item 1 of argv) as text) to quit' -e 'end run' "$APP_BUNDLE" >/dev/null 2>&1 + for _ in $(seq 1 20); do + app_running || return 0 + sleep 0.25 + done + done + ! app_running +} + +# Extra switches for the current system proxy, one per line (none without a proxy). Only in +# resolver-rule mode: the PAC carries the system chain itself. +proxy_args() { + [ "$PAC_MODE" = 0 ] || return 0 + local out + out=$(scutil --proxy 2>/dev/null) || return 1 + printf '%s\\n' "$out" | grep -q '^[[:space:]]*[[:space:]]*[{]' || return 1 + printf '%s\\n' "$out" | tail -n 1 | grep -q '[}][[:space:]]*$' || return 1 + val() { printf '%s\\n' "$out" | awk -v k="$1" '$1 == k { sub(/^[^:=]*[:=][[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); print; exit }'; } + local key + for key in HTTPSEnable HTTPEnable SOCKSEnable ProxyAutoConfigEnable ProxyAutoDiscoveryEnable; do + case "$(val "$key")" in ''|0|1) ;; *) return 1 ;; esac + done + if [ "$(val ProxyAutoConfigEnable)" = 1 ] || [ "$(val ProxyAutoDiscoveryEnable)" = 1 ]; then return 0; fi + local scheme host port + if [ "$(val HTTPSEnable)" = 1 ]; then scheme=http; host=$(val HTTPSProxy); port=$(val HTTPSPort) + elif [ "$(val HTTPEnable)" = 1 ]; then scheme=http; host=$(val HTTPProxy); port=$(val HTTPPort) + elif [ "$(val SOCKSEnable)" = 1 ]; then scheme=socks5; host=$(val SOCKSProxy); port=$(val SOCKSPort) + else return 0 + fi + [ -n "$host" ] && [ -n "$port" ] || return 1 + # Reject proxy-list delimiters and malformed addresses instead of letting Chromium discard + # the configured proxy. IPv6 literals are validated, then bracketed in the switch. + case "$host" in + *:*) + case "$host" in + '['*']') host="\${host#[}"; host="\${host%]}" ;; + *'['*|*']'*) return 1 ;; + esac + printf '%s\\n' "$host" | awk -F: ' + /[^0-9a-fA-F:]/ || /:::/ || /^:[^:]/ || /[^:]:$/ { exit 1 } + { n = split($0, parts, "::"); if (n > 2) exit 1; + groups = 0; for (i = 1; i <= NF; i++) { if (length($i) > 4) exit 1; if (length($i)) groups++; } + if (n == 2) { if (groups >= 8) exit 1; } + else if (groups != 8) exit 1; + }' || return 1 + host="[$host]" ;; + *) + printf '%s\\n' "$host" | awk -F. ' + length($0) > 253 { exit 1 } + { numeric = ($0 ~ /^[0-9.]+$/); if ($NF == "") NF--; + if (numeric && NF != 4) exit 1; + for (i = 1; i <= NF; i++) { + if (length($i) < 1 || length($i) > 63 || $i !~ /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/) exit 1; + if (numeric && ($i > 255 || (length($i) > 1 && substr($i, 1, 1) == "0"))) exit 1; + } + }' || return 1 ;; + esac + case "$port" in *[!0-9]*|'') return 1 ;; esac + [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1 + printf '%s\\n' "--proxy-server=$scheme://$host:$port" "--proxy-bypass-list=$BYPASS_HOST" +} + +if [ "$MODE" != native ] && ! listener_ours; then + [ "$MODE" = launch ] && { echo "opencodex's ChatGPT listener is not answering on port $PORT; start opencodex first" >&2; exit 1; } + exit 0 +fi +if [ "$MODE" != native ] && [ "$PAC_MODE" = 1 ] && ! entry_ours; then + [ "$MODE" = launch ] && { echo "the ChatGPT PAC entry listener is not answering; start opencodex first" >&2; exit 1; } + exit 0 +fi +# One run at a time: quitting the app deletes the SingletonLock, which fires launchd again. +# A lock left by a killed run expires after two minutes. +find "$LOCK_DIR" -maxdepth 0 -mmin +2 -exec rmdir {} \\; 2>/dev/null +mkdir "$LOCK_DIR" 2>/dev/null || { + [ "$MODE" = launch ] && { echo "another ChatGPT launch is in progress; retry shortly" >&2; exit 1; } + exit 0 +} +trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT + +if [ "$MODE" = native ]; then + if ! app_running; then say "ChatGPT is not running"; exit 0; fi + if ! app_switched; then say "ChatGPT is already running without the launch switches"; exit 0; fi + "\${BUNDLE_PREFLIGHT[@]}" "$APP_BUNDLE" || { say "ChatGPT bundle verification failed; leaving the app untouched"; exit 1; } + say "ChatGPT carries the launch switches; restarting it without" + quit_app || { say "ChatGPT did not quit; quit it manually and reopen it"; exit 1; } + open -a "$APP_BUNDLE" || { say "could not relaunch ChatGPT with native networking"; exit 1; } + say "relaunched ChatGPT with native networking" + exit 0 +fi + +RESTART_APP=0 +if app_running; then + if app_flagged; then + if shim_wanted && ! app_has_shim "$(app_pid)"; then + : # started without the shim; fall through and correct it below + else + say "ChatGPT is already running with the launch switches" + exit 0 + fi + fi + # The readiness marker fires this while the app may have been open for hours (opencodex + # restarted under it). Only an app that just started is restarted; the explicit launch command + # always acts. + if [ "$MODE" = watch ] && age=$(app_age "$(app_pid)") && [ "$age" -gt "$FRESH_APP_SECONDS" ]; then + log "ChatGPT has been running for \${age}s without the launch switches; leaving it (run 'ocx chatgpt launch' to restart it with them)" + exit 0 + fi + RESTART_APP=1 +elif [ "$MODE" != launch ]; then + exit 0 +fi + +"\${BUNDLE_PREFLIGHT[@]}" "$APP_BUNDLE" || { say "ChatGPT bundle verification failed; leaving the app untouched"; exit 1; } +# Prepare every launch argument before quitting; open uses this same PAC snapshot even if the +# file changes while the old app exits. Failed preflight leaves a running app untouched. +ARGS=() +if [ "$PAC_MODE" = 1 ]; then + PAC_SWITCH=$(pac_arg) || { say "cannot prepare PAC file at $PAC_FILE; start opencodex with pacFallback first"; exit 1; } + ARGS+=("$PAC_SWITCH") +else + ARGS+=("$RESOLVER_ARG") +fi +PROXY_ARGS=$(proxy_args) || { say "cannot read the system proxy policy; leaving the app untouched"; exit 1; } +while IFS= read -r arg; do + [ -n "$arg" ] && ARGS+=("$arg") +done <<< "$PROXY_ARGS" +OPEN_ENV=() +if [ "$SHIM_MODE" = 1 ]; then OPEN_ENV=(--env "CODEX_CLI_PATH=$SHIM_SCRIPT"); fi +# Bundle signature verification can take time. Recheck readiness at the action boundary +# so a listener that stopped during preflight cannot make us quit a healthy native app. +if ! listener_ours || { [ "$PAC_MODE" = 1 ] && ! entry_ours; }; then + say "ChatGPT listener readiness changed during preflight; leaving the app untouched" + exit 1 +fi +if [ "$RESTART_APP" = 1 ]; then + say "ChatGPT is running without the launch switches; restarting it" + quit_app || { say "ChatGPT did not quit; leaving it running without the switches"; exit 1; } +fi +open -a "$APP_BUNDLE" \${OPEN_ENV[@]+"\${OPEN_ENV[@]}"} --args "\${ARGS[@]}" || { say "could not launch ChatGPT"; exit 1; } +say "launched ChatGPT with: \${ARGS[*]}" +`; +} + +/** `bash -n` on a script file: parses without running it. */ +export function checkChatgptWatcherScriptSyntax(scriptPath: string): CommandResult { + return sh("/bin/bash", ["-n", scriptPath]); +} + +/** One-shot launchd agent: wake on the app's SingletonLock or the readiness marker, run the script, exit. */ +export function buildChatgptUnblockWatcherPlist(scriptPath: string, watchPaths: string | readonly string[], errPath: string): string { + const watched = (typeof watchPaths === "string" ? [watchPaths] : watchPaths).map(path => ` ${xmlEscape(path)}`).join("\n"); + return ` + + + + Label + ${CHATGPT_UNBLOCK_WATCHER_LABEL} + ProgramArguments + + /bin/bash + ${xmlEscape(scriptPath)} + watch + + WatchPaths + +${watched} + + StandardErrorPath + ${xmlEscape(errPath)} + + +`; +} + +interface CommandResult { + ok: boolean; + status: number | null; + output: string; +} + +function sh(command: string, args: string[]): CommandResult { + try { + const output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { ok: true, status: 0, output }; + } catch (error) { + const err = error as { status?: number | null; stdout?: string; stderr?: string }; + return { ok: false, status: err.status ?? null, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() }; + } +} + +/** Runs `launchctl `; injectable so install/uninstall can be tested without launchd. */ +export type LaunchctlRunner = (args: string[]) => CommandResult; +const defaultLaunchctl: LaunchctlRunner = args => sh("launchctl", args); + +/** + * Whether a `bootout` exit status means "nothing was loaded there" rather than a failure: 3 is + * "No such process", 113/112 answer for the service and the domain. Same statuses as + * `launchctlBootoutBenign` in src/service/launchd.ts, restated so the server-side lifecycle that + * imports this module does not pull in the service manager. + */ +function launchctlBootoutBenign(status: number | null): boolean { + return status === 0 || status === 3 || status === 112 || status === 113; +} + +function watcherDomain(): string { + return `gui/${process.getuid?.() ?? 0}`; +} + +export interface InstallChatgptUnblockWatcherOptions { + port: number; + /** The absolute discovered bundle path, retained and revalidated on every wake. */ + appPath?: string; + /** PAC mode: the CONNECT entry port the generated script checks and the app is pointed at. */ + entryPort?: number; + /** Start the app through the app-server shim (`CODEX_CLI_PATH`). */ + shimMode?: boolean; + configDir?: string; + /** Test seam: skip the macOS / app-presence guards. */ + assumeSupported?: boolean; + /** Test seam: where the agent plist is written instead of ~/Library/LaunchAgents. */ + plistPath?: string; + /** Test seam: the script body written instead of the generated one. */ + scriptText?: string; + launchctl?: LaunchctlRunner; +} + +/** + * Install the launch watcher: write script + agent plist and load it with launchd. Throws with + * launchctl's diagnostic when the agent cannot be loaded, and removes the files it wrote so a + * failed install leaves nothing half-installed behind. + */ +export function installChatgptUnblockWatcher(options: InstallChatgptUnblockWatcherOptions): void { + if (process.platform !== "darwin" && !options.assumeSupported) { + throw new Error("the ChatGPT launch watcher is only supported on macOS"); + } + const appPath = options.appPath ?? CHATGPT_APP_PATH; + if (!options.assumeSupported) { + const problem = chatgptWatcherBundleProblem(appPath); + if (problem) throw new Error(`Refusing to install the ChatGPT watcher: ${problem}`); + } + const launchctl = options.launchctl ?? defaultLaunchctl; + const paths = { ...chatgptUnblockWatcherPaths(options.configDir), ...(options.plistPath ? { plistPath: options.plistPath } : {}) }; + // Unload any previous generation first; "not loaded" is the expected answer on a fresh install. + const previous = launchctl(["bootout", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + if (!launchctlBootoutBenign(previous.status)) { + throw new Error(`could not unload the previous watcher (launchctl bootout exited ${previous.status}): ${previous.output}`); + } + if (!options.plistPath) mkdirSync(expandHome("~/Library/LaunchAgents"), { recursive: true }); + writeFileSync( + paths.scriptPath, + options.scriptText ?? buildChatgptUnblockWatcherScript( + options.port, + options.configDir, + options.shimMode === true, + options.entryPort !== undefined ? { entryPort: options.entryPort } : undefined, + { appPath }, + ), + { mode: 0o700 }, + ); + // The script is generated from a template literal and never parsed as bash before launchd runs + // it, so a quoting slip would install a watcher that dies on every app launch. Refuse it here. + const syntax = checkChatgptWatcherScriptSyntax(paths.scriptPath); + if (!syntax.ok) { + rmSync(paths.scriptPath, { force: true }); + throw new Error(`the generated watcher script is not valid bash: ${syntax.output || `bash -n exited ${syntax.status}`}`); + } + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, [paths.lockPath, paths.readyPath], paths.errPath)); + const loaded = launchctl(["bootstrap", watcherDomain(), paths.plistPath]); + if (!loaded.ok) { + rmSync(paths.plistPath, { force: true }); + rmSync(paths.scriptPath, { force: true }); + throw new Error(`launchctl bootstrap exited ${loaded.status}: ${loaded.output || "no diagnostic"}`); + } +} + +export interface UninstallChatgptUnblockWatcherOptions { + configDir?: string; + plistPath?: string; + launchctl?: LaunchctlRunner; +} + +/** + * Remove the launch watcher: unload the agent, then delete its files. An agent that was not + * loaded is fine; any other unload failure throws and keeps the files, so the installed state + * and what is on disk never disagree. + */ +export function uninstallChatgptUnblockWatcher(options: UninstallChatgptUnblockWatcherOptions = {}): void { + const launchctl = options.launchctl ?? defaultLaunchctl; + const paths = { ...chatgptUnblockWatcherPaths(options.configDir), ...(options.plistPath ? { plistPath: options.plistPath } : {}) }; + const unloaded = launchctl(["bootout", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + if (!launchctlBootoutBenign(unloaded.status)) { + throw new Error(`launchctl bootout exited ${unloaded.status}: ${unloaded.output || "no diagnostic"}; watcher files kept`); + } + rmSync(paths.plistPath, { force: true }); + rmSync(paths.scriptPath, { force: true }); +} + +export interface ChatgptUnblockWatcherStatus { + scriptInstalled: boolean; + plistInstalled: boolean; + agentLoaded: boolean; + scriptUpToDate: boolean; + plistUpToDate: boolean; +} + +/** Check launchd state alone, without reading installed watcher files or building a script. */ +export function chatgptUnblockWatcherLoaded(launchctl: LaunchctlRunner = defaultLaunchctl): boolean { + return launchctl(["print", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; +} + +export function chatgptUnblockWatcherStatus( + port: number, + configDir?: string, + shimMode = false, + pac?: { entryPort: number }, + appPath = CHATGPT_APP_PATH, +): ChatgptUnblockWatcherStatus { + const paths = chatgptUnblockWatcherPaths(configDir); + const scriptInstalled = existsSync(paths.scriptPath); + const plistInstalled = existsSync(paths.plistPath); + const agentLoaded = chatgptUnblockWatcherLoaded(); + const scriptUpToDate = scriptInstalled + && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port, configDir, shimMode, pac, { appPath }); + const plistUpToDate = plistInstalled + && readFileSync(paths.plistPath, "utf8") === buildChatgptUnblockWatcherPlist(paths.scriptPath, [paths.lockPath, paths.readyPath], paths.errPath); + return { scriptInstalled, plistInstalled, agentLoaded, scriptUpToDate, plistUpToDate }; +} + +/** + * The running app's command line, or null. Mirrors the script's `app_pid`: exact process name + * (ancestors included, since `ocx` may run in a terminal inside the app), then the bundle path, + * never a match against every command line. + */ +export function chatgptAppCommandLine(): string | null { + const pids = sh("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return null; + for (const pid of pids.output.split(/\s+/).filter(Boolean)) { + const command = sh("ps", ["-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) return command.output.trim(); + } + return null; +} + +/** + * Whether the running app was started through the app-server shim. The choice travels in the + * app's CODEX_CLI_PATH environment variable, which "ps eww" shows for the app's own process. + */ +export function chatgptAppHasShim(configDir: string): boolean { + const pids = sh("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return false; + const marker = `CODEX_CLI_PATH=${chatgptShimLauncherPath(configDir)}`; + for (const pid of pids.output.split(/\s+/).filter(Boolean)) { + const command = sh("ps", ["eww", "-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) return command.output.includes(marker); + } + return false; +} + +/** Whether a command line carries the resolver switch for `port`. */ +/** Whether a command line carries the PAC switch of the given config dir. */ +export function chatgptCommandLineHasPac(commandLine: string, configDir: string): boolean { + // The exact current script, or a file:// switch from before the inline form (still "ours"). + return commandLine.includes(` ${chatgptUnblockPacArg(configDir)}`) + || commandLine.includes(` --proxy-pac-url=file://${chatgptUnblockPacPath(configDir)}`); +} + +export function chatgptCommandLineHasRule(commandLine: string, port: number): boolean { + return commandLine.includes(` ${chatgptUnblockResolverArg(port)}`); +} + + +export type ChatgptListenerProbe = + | { state: "ours"; preservedSendBlocks: (PreservedSendBlock & { lastSeen: string })[] } + /** Something answers on the port, but not opencodex's listener. */ + | { state: "foreign" } + | { state: "down" }; + +/** Whether anything accepts a TCP connection on the loopback port. */ +function loopbackPortOpen(port: number): Promise { + return new Promise(resolve => { + const socket = connectSocket({ host: "127.0.0.1", port }); + const done = (open: boolean) => { socket.destroy(); resolve(open); }; + socket.setTimeout(3000, () => done(false)); + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + }); +} + +/** + * GET the listener's identity path over a raw TLS socket and return the response body, or null. + * Not fetch: Bun's fetch sends even loopback requests through HTTP(S)_PROXY from the user's + * shell and ignores `proxy: false` (Bun 1.4), and a proxy cannot reach this machine's loopback. + */ +function requestIdentity(port: number, configDir = getConfigDir()): Promise { + let ca: string; + try { + // Trust exactly the locally published authority, never the OS/default root store. A + // missing, invalid or non-CA certificate cannot authorize a listener on this port. + const certificate = new X509Certificate(readFileSync(claudeInterceptCaCertPath(configDir))); + if (!certificate.ca) return Promise.resolve(null); + ca = certificate.toString(); + } catch { + return Promise.resolve(null); + } + return new Promise(resolve => { + let raw = ""; + let settled = false; + let socket: ReturnType | undefined; + const finish = (body: string | null) => { + if (settled) return; + settled = true; + socket?.destroy(); + resolve(body); + }; + try { + socket = connectTls({ host: "127.0.0.1", port, servername: CHATGPT_INTERCEPT_HOST, ca, rejectUnauthorized: true }, () => { + socket!.write(`GET ${CHATGPT_UNBLOCK_IDENTITY_PATH} HTTP/1.1\r\nHost: ${CHATGPT_INTERCEPT_HOST}\r\nConnection: close\r\n\r\n`); + }); + } catch { + finish(null); + return; + } + socket.setTimeout(3000, () => finish(null)); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { raw += chunk; }); + socket.on("error", () => finish(null)); + socket.on("end", () => { + const split = raw.indexOf("\r\n\r\n"); + finish(split !== -1 && /^HTTP\/1\.[01] 200\b/.test(raw) ? raw.slice(split + 4) : null); + }); + }); +} + +/** + * Ask the port who holds it. A plain TCP connect decides "down" -- error codes for a refused + * connection vary by runtime and network setup -- and only an open port is asked for its + * identity through the listener's local identity path, authenticated by the published local CA. + */ +export async function probeChatgptUnblockListener( + port: number, + request: (port: number, configDir?: string) => Promise = requestIdentity, + portOpen: (port: number) => Promise = loopbackPortOpen, + configDir = getConfigDir(), +): Promise { + if (!(await portOpen(port))) return { state: "down" }; + const text = await request(port, configDir); + if (text === null) return { state: "foreign" }; + try { + const body = JSON.parse(text) as { service?: unknown; preservedSendBlocks?: unknown }; + if (body.service !== CHATGPT_UNBLOCK_SERVICE_ID) return { state: "foreign" }; + const blocks = Array.isArray(body.preservedSendBlocks) ? body.preservedSendBlocks : []; + return { state: "ours", preservedSendBlocks: blocks as (PreservedSendBlock & { lastSeen: string })[] }; + } catch { + return { state: "foreign" }; + } +} + +function runLaunchScript( + mode: "launch" | "native", + port: number, + configDir?: string, + shimMode = false, + pac?: { entryPort: number }, + appPath = CHATGPT_APP_PATH, +): { ok: boolean; output: string } { + if (process.platform !== "darwin") { + throw new Error("launching the ChatGPT desktop app is only supported on macOS"); + } + // The script goes in on stdin, so no file is needed and the script's own command line never + // looks like the app's. + // An inherited CODEX_CLI_PATH (ocx run inside the app) must not leak into the relaunch. + const env = { ...process.env }; + delete env.CODEX_CLI_PATH; + const result = spawnSync("/bin/bash", ["-s", mode], { + env, + input: buildChatgptUnblockWatcherScript(port, configDir, shimMode, pac, { appPath }), + encoding: "utf8", + }); + return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; +} + +/** Start the app with the launch arguments, restarting it if it runs without them (macOS). */ +export function launchChatgptWithRule( + port: number, + configDir?: string, + shimMode = false, + pac?: { entryPort: number }, + appPath = CHATGPT_APP_PATH, +): { ok: boolean; output: string } { + return runLaunchScript("launch", port, configDir, shimMode, pac, appPath); +} + +/** Restart an app that carries the launch switches without them, returning it to native networking. */ +export function restoreChatgptNative( + port: number, + configDir?: string, + shimMode = false, + pac?: { entryPort: number }, + appPath = CHATGPT_APP_PATH, +): { ok: boolean; output: string } { + return runLaunchScript("native", port, configDir, shimMode, pac, appPath); +} diff --git a/src/chatgpt/desktop-unblock/listener.ts b/src/chatgpt/desktop-unblock/listener.ts new file mode 100644 index 00000000000..cc8ac522001 --- /dev/null +++ b/src/chatgpt/desktop-unblock/listener.ts @@ -0,0 +1,271 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { rewriteSurfaceFor, stripSendBlocksFromJson, stripSendBlocksFromSseLine } from "./rewrite"; +import type { PreservedSendBlock, RewriteSurface } from "./rewrite"; +import { handleWebSocketUpgrade, isRelayableUpgrade } from "./ws-relay"; +import type { WsRelaySocketData } from "./ws-relay"; +import type { DialUpstreamOptions } from "./ws-upstream"; + +/** + * TLS listener for the ChatGPT desktop send-unblock intercept. + * + * Launched with `--host-resolver-rules="MAP chatgpt.com 127.0.0.1:"`, the desktop app + * dialls this listener believing it reached chatgpt.com. Requests are relayed verbatim to the + * real upstream with the caller's own auth headers; responses pass through untouched except + * that conversation payloads lose their client-side send-lock entries. Nothing is logged and + * no credential is persisted -- the listener is a pipe, not a store. + * + * Only the exact host `chatgpt.com` is ever presented here. Subdomains (`ab.chatgpt.com`, + * `codex-cloud-backend.chatgpt.com`) and `auth.openai.com` are not mapped by the launcher, so + * login, telemetry and cloud sessions stay native. + */ + +export const CHATGPT_UNBLOCK_UPSTREAM = "https://chatgpt.com"; +export const CHATGPT_INTERCEPT_HOST = "chatgpt.com"; + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. `alt-svc` is dropped so the app never tries HTTP/3: QUIC is UDP, which +// the TCP listener cannot answer, and a stray attempt only costs the app a fallback delay. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "alt-svc", +]); + +/** Statuses that carry no body; constructing a Response with one throws. */ +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +/** + * Local-only path the listener answers itself, never relayed. The launch watcher and + * `ocx chatgpt status` use it to tell this listener apart from any other process that happens + * to hold the port, and it reports the send blocks the rewrite deliberately preserved. + */ +export const CHATGPT_UNBLOCK_IDENTITY_PATH = "/__opencodex/chatgpt-unblock"; +export const CHATGPT_UNBLOCK_SERVICE_ID = "opencodex-chatgpt-unblock"; + +/** How many distinct preserved send blocks the listener remembers for status output. */ +const PRESERVED_BLOCKS_KEPT = 8; + +/** + * In-memory record of send blocks the rewrite left in place: feature name and reason only, no + * payload, account or request data. Nothing is written to disk. + */ +export class ChatgptUnblockDiagnostics { + private readonly preserved = new Map(); + + record(blocks: readonly PreservedSendBlock[]): void { + for (const block of blocks) { + const key = `${block.name}\u0000${block.reason}`; + this.preserved.delete(key); + this.preserved.set(key, { ...block, lastSeen: new Date().toISOString() }); + if (this.preserved.size > PRESERVED_BLOCKS_KEPT) this.preserved.delete(this.preserved.keys().next().value!); + } + } + + snapshot(): { service: string; preservedSendBlocks: (PreservedSendBlock & { lastSeen: string })[] } { + return { service: CHATGPT_UNBLOCK_SERVICE_ID, preservedSendBlocks: [...this.preserved.values()] }; + } +} + +export interface ChatgptUnblockListenerOptions { + leaf: PemKeyPair; + upstreamBase?: string; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; + /** Test seam: where WebSocket upgrades dial instead of chatgpt.com through the configured proxy. */ + wsUpstream?: DialUpstreamOptions; + diagnostics?: ChatgptUnblockDiagnostics; +} + +function responseHeaders(source: Response): Headers { + const headers = new Headers(); + source.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +/** + * Line-oriented SSE rewriter. Complete lines are checked one at a time so an untouched stream + * keeps its exact chunking and line endings; only `data:` lines whose JSON loses an entry are + * re-serialized. + */ +export function sseRewriteStream(options: { + surface?: RewriteSurface; + diagnostics?: ChatgptUnblockDiagnostics; +} = {}): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + const rewriteLine = (line: string): string | null => { + const preserved: PreservedSendBlock[] = []; + const rewritten = stripSendBlocksFromSseLine(line, options.surface, preserved); + options.diagnostics?.record(preserved); + return rewritten; + }; + return new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, index); + pending = pending.slice(index + 1); + const rewritten = rewriteLine(line); + controller.enqueue(encoder.encode(`${rewritten ?? line}\n`)); + } + }, + flush(controller) { + if (pending.length === 0) return; + const rewritten = rewriteLine(pending); + controller.enqueue(encoder.encode(rewritten ?? pending)); + pending = ""; + }, + }); +} + +/** Largest JSON body the relay buffers to rewrite; anything bigger passes through unchanged. */ +export const MAX_REWRITE_BODY_BYTES = 8 * 1024 * 1024; + +type BoundedText = { text: string } | { overflow: ReadableStream }; + +/** + * Read a body as text up to `cap` bytes. Past the cap the bytes already read are replayed in + * front of the rest of the stream, so the caller can pass the whole body on unchanged. + */ +async function readBoundedText(body: ReadableStream | null, cap: number): Promise { + if (!body) return { text: "" }; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.byteLength; + if (size > cap) { + return { + overflow: new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + }, + async pull(controller) { + const next = await reader.read(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }), + }; + } + } + const whole = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + whole.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(whole) }; +} + +function isJsonContentType(contentType: string): boolean { + return contentType.includes("application/json") || contentType.endsWith("+json"); +} + +function isEventStreamContentType(contentType: string): boolean { + return contentType.includes("text/event-stream"); +} + +export async function relayWithSendUnblock( + req: Request, + upstreamBase: string, + fetchImpl: typeof fetch = fetch, + diagnostics?: ChatgptUnblockDiagnostics, +): Promise { + const url = new URL(req.url); + if (url.pathname === CHATGPT_UNBLOCK_IDENTITY_PATH) { + return Response.json((diagnostics ?? new ChatgptUnblockDiagnostics()).snapshot(), { headers: { "cache-control": "no-store" } }); + } + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { error: { message: `chatgpt unblock relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = responseHeaders(upstream); + const init = { status: upstream.status, statusText: upstream.statusText, headers }; + if (NULL_BODY_STATUSES.has(upstream.status) || req.method === "HEAD") return new Response(null, init); + // Everything but the composer's conversation and usage endpoints passes through untouched. + const surface = rewriteSurfaceFor(url.pathname); + if (surface === null) return new Response(upstream.body, init); + const contentType = upstream.headers.get("content-type") ?? ""; + if (isJsonContentType(contentType)) { + // The gate payloads are small; a body past the cap is a transcript or listing that carries + // nothing to rewrite, so it streams through untouched instead of being buffered and copied. + const declared = Number(upstream.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_REWRITE_BODY_BYTES) return new Response(upstream.body, init); + let read: BoundedText; + try { + read = await readBoundedText(upstream.body, MAX_REWRITE_BODY_BYTES); + } catch { + return new Response(JSON.stringify({ error: { message: "chatgpt unblock upstream read failed" } }), { status: 502, headers }); + } + if ("overflow" in read) return new Response(read.overflow, init); + const text = read.text; + const preserved: PreservedSendBlock[] = []; + const rewritten = stripSendBlocksFromJson(text, surface, preserved); + diagnostics?.record(preserved); + return new Response(rewritten ?? text, init); + } + if (isEventStreamContentType(contentType) && upstream.body) { + return new Response(upstream.body.pipeThrough(sseRewriteStream({ surface, diagnostics })), init); + } + return new Response(upstream.body, init); +} + +/** + * Bind the intercept TLS listener on an ephemeral loopback port. WebSocket upgrades are + * relayed by `handleWebSocketUpgrade` (voice/dictation and every other WS endpoint on the + * intercepted host); everything else keeps going through the fetch-based relay untouched. + */ +export function startChatgptUnblockListener(options: ChatgptUnblockListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CHATGPT_UNBLOCK_UPSTREAM; + const diagnostics = options.diagnostics ?? new ChatgptUnblockDiagnostics(); + return Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + websocket: { + // Frames both directions once Bun finished the client-side upgrade; see WsRelay. + open(ws) { + ws.data.relay.attach(ws); + }, + message(ws, message) { + ws.data.relay.clientMessage(message); + }, + close(ws, code, reason) { + ws.data.relay.clientClose(code, reason); + }, + }, + async fetch(req, server) { + if (isRelayableUpgrade(req)) return handleWebSocketUpgrade(req, server, options.wsUpstream); + return relayWithSendUnblock(req, upstreamBase, options.fetchImpl, diagnostics); + }, + }); +} diff --git a/src/chatgpt/desktop-unblock/pac.ts b/src/chatgpt/desktop-unblock/pac.ts new file mode 100644 index 00000000000..c70f6bc65b0 --- /dev/null +++ b/src/chatgpt/desktop-unblock/pac.ts @@ -0,0 +1,193 @@ +/** + * PAC-file support for the ChatGPT desktop send-unblock intercept. + * + * With `chatgptDesktop.pacFallback` on, the app is launched with + * an inline `--proxy-pac-url=data:` switch (the generated script, base64) instead of a resolver rule. The PAC + * sends chatgpt.com to the entry CONNECT listener (which splices onto the TLS origin listener), + * and every other host the way the system routes it. When opencodex stops, the refused CONNECT + * makes Chromium fall through to the system route on its own -- the app keeps working, no + * restart -- which is the whole point of the fallback. + * + * The system route is captured at generation time, not hard-coded, because it depends on the + * VPN's mode at the time: + * - system proxy mode: only the HTTPS / HTTP / SOCKS proxies from `scutil --proxy`; + * - PAC mode: the system PAC script itself, embedded so its per-host rules keep applying; + * - TUN mode / no VPN: no system proxy, so DIRECT and Chromium resolves and dials on its own. + */ + +import { execFileSync } from "node:child_process"; +import { readFile, stat } from "node:fs/promises"; +import { get as httpGet } from "node:http"; +import { get as httpsGet } from "node:https"; +import { isIP } from "node:net"; +import { fileURLToPath } from "node:url"; +import { CHATGPT_INTERCEPT_HOST } from "./listener"; + +export const CHATGPT_UNBLOCK_PAC_FILENAME = "chatgpt-unblock.pac"; + +/** gfwlist-style PACs run to a few hundred KiB; anything this large is not a PAC script. */ +const MAX_SYSTEM_PAC_BYTES = 4 * 1024 * 1024; +const SYSTEM_PAC_TIMEOUT_MS = 3_000; + +export interface SystemProxyChain { + /** e.g. "PROXY 127.0.0.1:7892; SOCKS5 127.0.0.1:7892" -- no trailing DIRECT. */ + entries: string[]; + /** True when a PAC file is configured system-wide. */ + autoConfig: boolean; + /** The system PAC's URL when `autoConfig` is on and one is set. */ + autoConfigUrl: string | null; +} + +interface ScutilValue { + get(key: string): string | null; +} + +export function parseScutilOutput(output: string): ScutilValue { + const map = new Map(); + for (const line of output.split("\n")) { + // `scutil --proxy` prints `Key : value` inside its dictionary; accept `=` too. + const match = /^\s*(\S+)\s*[:=]\s*(.*?)\s*$/.exec(line); + if (match) map.set(match[1]!, match[2]!); + } + return { get: key => map.get(key) ?? null }; +} + +function runScutil(): ScutilValue | null { + try { + const output = execFileSync("scutil", ["--proxy"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: SYSTEM_PAC_TIMEOUT_MS }); + return /^\s*\s*\{[\s\S]*\}\s*$/.test(output) ? parseScutilOutput(output) : null; + } catch { + return null; + } +} + +/** + * The system proxy as Chromium sees it in a PAC: HTTPS proxy first (browser traffic is + * HTTPS), then HTTP, then SOCKS, each once. + */ +export function systemProxyChain(scutil: ScutilValue | null = runScutil()): SystemProxyChain { + if (!scutil) throw new Error("Cannot read the system proxy configuration; refusing to create a direct fallback."); + for (const key of ["HTTPSEnable", "HTTPEnable", "SOCKSEnable", "ProxyAutoConfigEnable", "ProxyAutoDiscoveryEnable"]) { + const value = scutil.get(key); + if (value !== null && value !== "0" && value !== "1") throw new Error("The system proxy configuration is malformed; refusing to replace its route."); + } + if (scutil.get("ProxyAutoDiscoveryEnable") === "1") throw new Error("Automatic proxy discovery cannot be captured safely; refusing to replace its route."); + const autoConfig = scutil.get("ProxyAutoConfigEnable") === "1"; + const url = scutil.get("ProxyAutoConfigURLString"); + const entries = new Set(); + const push = (kind: "PROXY" | "SOCKS5", enabled: string | null, host: string | null, port: string | null) => { + if (enabled !== "1") return; + if (!host || host === "(null)" || !port || !/^\d+$/.test(port) || +port < 1 || +port > 65535) { + throw new Error("An enabled system proxy has an invalid address; refusing to create a direct fallback."); + } + const address = isIP(host) === 6 ? `[${host}]` : host; + let endpoint: URL; + try { endpoint = new URL(`http://${address}:${port}`); } catch { + throw new Error("An enabled system proxy has an invalid address; refusing to create a direct fallback."); + } + const hostname = endpoint.hostname; + const validHostname = isIP(hostname.replace(/^\[|\]$/g, "")) !== 0 + || (hostname.length <= 254 && hostname.replace(/\.$/, "").split(".") + .every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))); + if (endpoint.username || endpoint.password || endpoint.pathname !== "/" || endpoint.search || endpoint.hash + || /[\s;/?#@\\]/.test(host) || !validHostname) { + throw new Error("An enabled system proxy has an invalid address; refusing to create a direct fallback."); + } + entries.add(`${kind} ${hostname}:${+port}`); + }; + push("PROXY", scutil.get("HTTPSEnable"), scutil.get("HTTPSProxy"), scutil.get("HTTPSPort")); + push("PROXY", scutil.get("HTTPEnable"), scutil.get("HTTPProxy"), scutil.get("HTTPPort")); + push("SOCKS5", scutil.get("SOCKSEnable"), scutil.get("SOCKSProxy"), scutil.get("SOCKSPort")); + return { entries: [...entries], autoConfig, autoConfigUrl: autoConfig && url && url !== "(null)" ? url : null }; +} + +function fetchPacText(url: URL): Promise { + // node:http, not fetch: Bun's fetch honours HTTP(S)_PROXY even for loopback, and a local PAC + // server (the usual VPN-client setup) must be reached directly, the way Chromium reaches it. + const get = url.protocol === "https:" ? httpsGet : httpGet; + return new Promise(resolve => { + const request = get(url, { timeout: SYSTEM_PAC_TIMEOUT_MS }, response => { + if (response.statusCode !== 200) { + response.resume(); + resolve(null); + return; + } + const chunks: Buffer[] = []; + let size = 0; + response.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_SYSTEM_PAC_BYTES) { + request.destroy(); + resolve(null); + return; + } + chunks.push(chunk); + }); + response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + response.on("error", () => resolve(null)); + }); + request.on("timeout", () => request.destroy()); + request.on("error", () => resolve(null)); + }); +} + +/** + * The system PAC script's text: file:// from disk, http(s):// fetched directly. Null when it + * cannot be read, is empty, is implausibly large, or defines no `FindProxyForURL`. + */ +export async function loadSystemPac(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + let text: string | null = null; + try { + if (url.protocol === "file:") { + const path = fileURLToPath(url); + const info = await stat(path); + if (info.isFile() && info.size <= MAX_SYSTEM_PAC_BYTES) text = await readFile(path, "utf8"); + } else if (url.protocol === "http:" || url.protocol === "https:") { + text = await fetchPacText(url); + } + } catch { + return null; + } + return text && Buffer.byteLength(text) <= MAX_SYSTEM_PAC_BYTES && /\bFindProxyForURL\b/.test(text) ? text : null; +} + +/** + * The PAC script. For the intercepted host: the entry listener first (the intercept), then the + * system route (the fallback). For every other host: the system route, so login, telemetry and + * subdomains behave as they would without the feature. + * + * With a system PAC, its script is embedded inside a function scope: its own + * `FindProxyForURL` stays reachable as a local, and the final assignment makes the wrapper the + * global Chromium calls, however the embedded script declared its entry point. + */ +export function buildChatgptUnblockPac(entryPort: number, chain: SystemProxyChain, systemPac: string | null = null): string { + const header = `// Generated by opencodex for the ChatGPT desktop send-unblock fallback. +// Rewritten at every opencodex start; the ChatGPT app must be relaunched to pick up changes. +`; + if (systemPac) { + return `${header}// The system PAC below is embedded unchanged so every other host keeps its routing. +var __ocxSystemFindProxyForURL = (function () { +${systemPac} +;return FindProxyForURL; +})(); +var FindProxyForURL = function (url, host) { + var system = __ocxSystemFindProxyForURL(url, host) || "DIRECT"; + if (host == "${CHATGPT_INTERCEPT_HOST}") return "PROXY 127.0.0.1:${entryPort}; " + system; + return system; +}; +`; + } + if (chain.autoConfig) throw new Error("The configured system PAC could not be read; refusing to replace its route."); + const chainText = chain.entries.length ? chain.entries.join("; ") : "DIRECT"; + return `${header}function FindProxyForURL(url, host) { + if (host == "${CHATGPT_INTERCEPT_HOST}") return ${JSON.stringify(`PROXY 127.0.0.1:${entryPort}; ${chainText}`)}; + return ${JSON.stringify(chainText)}; +} +`; +} diff --git a/src/chatgpt/desktop-unblock/rewrite.ts b/src/chatgpt/desktop-unblock/rewrite.ts new file mode 100644 index 00000000000..58c8236b5dc --- /dev/null +++ b/src/chatgpt/desktop-unblock/rewrite.ts @@ -0,0 +1,180 @@ +import { isRecord, unlockRateLimitGate } from "../app-server-shim/gate-rewrite"; +export { unlockRateLimitGate } from "../app-server-shim/gate-rewrite"; + +/** + * Send-unblock rewriting for the ChatGPT desktop intercept. + * + * The ChatGPT desktop app disables the conversation composer from two backend data shapes: + * + * 1. Conversation metadata (`POST /backend-api/conversation/init`, and the + * `conversation_detail_metadata` events of the `/backend-api/f/conversation` stream) carries + * `blocked_features` entries named `send` (or `tpp_send`) and `limits_progress` entries for + * `send` with `remaining <= 0`. + * 2. The desktop usage snapshot (`/backend-api/wham/usage[/stream]`) carries + * `rate_limit.allowed: false` + `rate_limit.limit_reached: true` while the logged-in + * ChatGPT subscription quota is exhausted. The bundled `codex app-server` additionally + * derives its own "limit reached" state from the sibling `rate_limit_reached_type` object. + * + * Only the account's own usage quota is lifted -- data that is meaningless for turns whose + * model calls opencodex routes to third-party providers. Scope is deliberately narrow: + * + * - Only the endpoints above are rewritten (`rewriteSurfaceFor`); every other response passes + * through byte-identical, even when it happens to contain the same field names. + * - A send block is removed only when its `block_reason` says quota (or is absent, the shape + * of a plain usage limit). Eligibility blocks such as `work_subscription_required`, and any + * reason not recognised as quota, are preserved and reported, so the app keeps its real + * explanation and `ocx chatgpt status` can show why a composer stays locked. + * - Quota display stays honest: `banner_info` / `rate_limit_upsell`, `used_percent`, + * `reset_at` and window fields, `model_limits`, `model_usage` and every other key pass + * through untouched, so the app keeps showing the account's real usage. + */ + +/** `blocked_features[].name` values the desktop composer treats as a send lock. */ +const SEND_BLOCKED_FEATURE_NAMES = new Set(["send", "tpp_send"]); + +/** `limits_progress[].feature_name` value for the composer's send gate. */ +const SEND_LIMIT_FEATURE_NAME = "send"; + +/** + * `block_reason` values that describe the account's own usage quota. Anything else -- + * subscription, policy, a workspace or credit limit such as `workspace_owner_usage_limit_reached`, + * or a reason this code has never seen -- is left in place. + */ +const QUOTA_BLOCK_REASONS = new Set(["usage_limit", "usage_limit_reached", "rate_limit_exceeded", "rate_limit_reached", "quota_exhausted"]); + +/** Which part of the rewrite applies to a response. */ +export type RewriteSurface = "conversation" | "usage"; + +const CONVERSATION_PATHS = ["/backend-api/conversation/init", "/backend-api/conversation", "/backend-api/f/conversation"]; +const USAGE_PATHS = ["/backend-api/wham/usage", "/backend-api/wham/usage/stream"]; + +/** + * The rewrite that applies to a request path, or null for everything else. Conversation paths + * match exactly or as a prefix segment (`/backend-api/f/conversation/prepare`); the conversation + * list (`/backend-api/conversations`) and the other usage endpoints (thread usage, plan history) + * do not match. + */ +export function rewriteSurfaceFor(pathname: string): RewriteSurface | null { + if (USAGE_PATHS.includes(pathname)) return "usage"; + if (CONVERSATION_PATHS.some(path => pathname === path || pathname.startsWith(`${path}/`))) return "conversation"; + return null; +} + +/** A send block the rewrite deliberately left in place. */ +export interface PreservedSendBlock { + name: string; + reason: string; +} + +export interface RewriteResult { + value: unknown; + changed: boolean; +} + +function isSendBlockedFeature(entry: unknown): entry is Record { + return isRecord(entry) && SEND_BLOCKED_FEATURE_NAMES.has(String(entry.name ?? "")); +} + +/** Absent/empty reason is the plain usage-limit shape; otherwise the reason must say quota. */ +function isQuotaBlockReason(reason: unknown): boolean { + if (reason === undefined || reason === null || reason === "") return true; + return typeof reason === "string" && QUOTA_BLOCK_REASONS.has(reason); +} + +function isExhaustedSendLimit(entry: unknown): boolean { + if (!isRecord(entry) || (entry.feature_name ?? entry.featureName) !== SEND_LIMIT_FEATURE_NAME) return false; + const remaining = entry.remaining; + return typeof remaining === "number" && remaining <= 0; +} + +/** + * Recursively strip quota send-lock entries from any `blocked_features` / `limits_progress` + * arrays. Non-quota send blocks are kept and appended to `preserved`. Malformed entries are + * kept: the rewrite owns removal of known-shaped blocks, not validation. + */ +export function stripSendBlocks(value: unknown, preserved: PreservedSendBlock[] = []): RewriteResult { + if (Array.isArray(value)) { + let changed = false; + const items = value.map(item => { + const result = stripSendBlocks(item, preserved); + changed ||= result.changed; + return result.value; + }); + return { value: items, changed }; + } + if (!isRecord(value)) return { value, changed: false }; + let changed = false; + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + if ((key === "blocked_features" || key === "blockedFeatures") && Array.isArray(child)) { + const kept = child.filter(entry => { + if (!isSendBlockedFeature(entry)) return true; + const reason = entry.block_reason ?? entry.blockReason; + if (isQuotaBlockReason(reason)) return false; + preserved.push({ name: String(entry.name), reason: String(reason) }); + return true; + }); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + if ((key === "limits_progress" || key === "limitsProgress") && Array.isArray(child)) { + const kept = child.filter(entry => !isExhaustedSendLimit(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + const result = stripSendBlocks(child, preserved); + changed ||= result.changed; + out[key] = result.value; + } + return { value: out, changed }; +} + +/** + * Rewrite a JSON response body for `surface` (both parts when omitted). Returns `null` when the + * body is not valid JSON or contains nothing to rewrite, so callers can pass the original bytes + * through untouched. Non-quota send blocks left in place are appended to `preserved`. + */ +export function stripSendBlocksFromJson( + text: string, + surface?: RewriteSurface, + preserved: PreservedSendBlock[] = [], +): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + let value = parsed; + let changed = false; + if (surface !== "usage") { + const stripped = stripSendBlocks(value, preserved); + value = stripped.value; + changed ||= stripped.changed; + } + if (surface !== "conversation") changed = unlockRateLimitGate(value) || changed; + return changed ? JSON.stringify(value) : null; +} + +/** + * Rewrite a single SSE line. ChatGPT conversation and usage-stream events carry one JSON + * document per `data:` line; lines that parse to a payload with send blocks or a closed usage + * gate are replaced, everything else passes through byte-identical. A CRLF-framed line keeps + * its `\r`. Returns `null` when the line is unchanged. + */ +export function stripSendBlocksFromSseLine( + line: string, + surface?: RewriteSurface, + preserved: PreservedSendBlock[] = [], +): string | null { + const cr = line.endsWith("\r") ? "\r" : ""; + const body = cr ? line.slice(0, -1) : line; + // `s`: a JSON string may legally hold U+2028/U+2029, which `.` would otherwise refuse. + const match = /^(data: ?)(.*)$/s.exec(body); + if (!match) return null; + const rewritten = stripSendBlocksFromJson(match[2]!, surface, preserved); + if (rewritten === null) return null; + return `${match[1]}${rewritten}${cr}`; +} diff --git a/src/chatgpt/desktop-unblock/runtime.ts b/src/chatgpt/desktop-unblock/runtime.ts new file mode 100644 index 00000000000..c2b517fbe7f --- /dev/null +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -0,0 +1,228 @@ +import type { Server } from "bun"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { + claudeInterceptCaCertPath, + ensureLocalInterceptCaForStartup, + issueLocalInterceptLeaf, +} from "../../claude/intercept/local-ca"; +import { CHATGPT_INTERCEPT_HOST, startChatgptUnblockListener } from "./listener"; +import { startChatgptUnblockEntryProxy } from "./entry-proxy"; +import type { EntryProxyHandle } from "./entry-proxy"; +import { CHATGPT_UNBLOCK_PAC_FILENAME, buildChatgptUnblockPac, loadSystemPac, systemProxyChain } from "./pac"; +import type { SystemProxyChain } from "./pac"; +import type { WsRelaySocketData } from "./ws-relay"; + +/** + * Lifecycle for the ChatGPT desktop send-unblock listener. + * + * Opt-in via `chatgptDesktop.unblockSend`. The listener shares the Claude intercept authority + * (one trusted certificate covers both features) and binds a stable loopback port derived from + * the public port so the launcher's `--host-resolver-rules` value survives restarts. A bind + * failure degrades to a warning exactly like the Claude intercept pair: the proxy's other + * duties never depend on this listener existing. + */ + +export const CHATGPT_UNBLOCK_PORT_OFFSET = 200; + +/** The CONNECT entry listener sits right after the TLS origin listener. */ +export const CHATGPT_UNBLOCK_ENTRY_PORT_OFFSET = 1; + +export function chatgptUnblockEnabled(config: Pick): boolean { + if (process.platform !== "darwin" || config.runtimeRole === "client") return false; + return config.chatgptDesktop?.unblockSend === true; +} + +/** Whether the PAC-fallback launch mode is on (it requires `unblockSend`). */ +export function chatgptPacFallbackEnabled(config: Pick): boolean { + return chatgptUnblockEnabled(config) && config.chatgptDesktop?.pacFallback === true; +} + +/** + * The listener port: `chatgptDesktop.port` when valid, else the public port plus the offset. + * Throws when the derived port would leave the TCP range (a public port of 65336 or more); + * callers report that as the optional integration being unavailable. + */ +export function chatgptUnblockPort(config: Pick, publicPort: number): number { + const configured = config.chatgptDesktop?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + const derived = publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; + if (derived > 65535) { + throw new Error( + `the default ChatGPT unblock port (${publicPort} + ${CHATGPT_UNBLOCK_PORT_OFFSET} = ${derived}) is out of range; set chatgptDesktop.port to a free port`, + ); + } + return derived; +} + +/** The resolver rule to hand the ChatGPT desktop app at launch. */ +export function chatgptUnblockResolverRule(port: number): string { + return `MAP ${CHATGPT_INTERCEPT_HOST} 127.0.0.1:${port}`; +} + +/** + * The rule as the app's command-line switch. Chromium silently ignores a bare rule passed as + * a positional argument, so every launch path must pass this form. + */ +export function chatgptUnblockResolverArg(port: number): string { + return `--host-resolver-rules=${chatgptUnblockResolverRule(port)}`; +} + +/** + * The CONNECT entry listener's port: one after the origin listener, wrapped inside the TCP + * range the same way Desktop's picker proxy derives its neighbour port. + */ +export function chatgptUnblockEntryPort(config: Pick, publicPort: number): number { + const origin = chatgptUnblockPort(config, publicPort); + return origin < 65535 ? origin + CHATGPT_UNBLOCK_ENTRY_PORT_OFFSET : origin - CHATGPT_UNBLOCK_ENTRY_PORT_OFFSET; +} + +/** The PAC file lives beside the rest of the opencodex state; the app reads it at launch. */ +export function chatgptUnblockPacPath(configDir: string): string { + return join(configDir, CHATGPT_UNBLOCK_PAC_FILENAME); +} + +/** + * The PAC switch's fixed prefix. The script travels INLINE as a `data:` URL: on the ChatGPT + * desktop app (Chromium 154) a `--proxy-pac-url=file://...` switch is ignored -- the app then + * dials every host directly, bypassing both the intercept and the user's VPN chain -- and an + * `http://` PAC would need opencodex alive to be fetched, defeating the fallback. A `data:` PAC + * needs neither, and keeps working while opencodex is stopped. + */ +export const CHATGPT_UNBLOCK_PAC_ARG_PREFIX = "--proxy-pac-url=data:application/x-ns-proxy-autoconfig;base64,"; + +/** The PAC switch for a given script text. */ +export function chatgptUnblockPacArgFor(pacText: string | Uint8Array): string { + return `${CHATGPT_UNBLOCK_PAC_ARG_PREFIX}${Buffer.from(pacText).toString("base64")}`; +} + +/** + * The PAC as the app's command-line switch, from the file opencodex wrote at start. When the file + * is not there yet the bare prefix comes back, which matches no real command line. + */ +export function chatgptUnblockPacArg(configDir: string): string { + try { + return chatgptUnblockPacArgFor(readFileSync(chatgptUnblockPacPath(configDir))); + } catch { + return CHATGPT_UNBLOCK_PAC_ARG_PREFIX; + } +} + +/** + * Rewritten once the listener is up. The launch watcher wakes on it as well as on the app's own + * launch, so an app that started before opencodex (both opened at login) is still routed. + */ +export const CHATGPT_UNBLOCK_READY_FILENAME = "chatgpt-unblock.ready"; + +export function chatgptUnblockReadyPath(configDir: string): string { + return join(configDir, CHATGPT_UNBLOCK_READY_FILENAME); +} + + +export interface ChatgptUnblockState { + port: number; + caCertPath: string; + /** Set in PAC-fallback mode: the CONNECT entry listener the PAC points chatgpt.com at. */ + entryProxy?: EntryProxyHandle; + /** PAC mode: how the generated file routes every host other than the intercepted one. */ + pacRoute?: ChatgptUnblockPacRoute; +} + +export interface ChatgptUnblockHandle extends ChatgptUnblockState { + listener: Server; + stop(): Promise; +} + +/** + * `system-proxy`: the scutil proxies (or DIRECT only when none is configured); + * `system-pac`: the embedded system PAC. An unreadable or oversized PAC fails startup. + */ +export type ChatgptUnblockPacRoute = "system-proxy" | "system-pac"; + +/** + * The longest PAC switch handed to the app. The switch carries the script inline as base64, and + * macOS limits one exec's arguments plus environment to 1 MiB (`ARG_MAX`): past that `open` + * fails with E2BIG after the watcher has already quit the app. Half the limit leaves room for the + * environment and the other switches. + */ +export const CHATGPT_UNBLOCK_PAC_SWITCH_MAX_BYTES = 512 * 1024; + +/** + * The PAC script to write and the route it encodes. A system PAC is embedded only while the + * resulting switch fits {@link CHATGPT_UNBLOCK_PAC_SWITCH_MAX_BYTES}. + */ +export function chooseChatgptUnblockPac( + entryPort: number, + chain: SystemProxyChain, + systemPac: string | null, + maxSwitchBytes: number = CHATGPT_UNBLOCK_PAC_SWITCH_MAX_BYTES, +): { text: string; route: ChatgptUnblockPacRoute } { + if (systemPac) { + const text = buildChatgptUnblockPac(entryPort, chain, systemPac); + if (chatgptUnblockPacArgFor(text).length <= maxSwitchBytes) return { text, route: "system-pac" }; + throw new Error("The system PAC is too large for the app's launch switch; refusing to replace its route."); + } + return { text: buildChatgptUnblockPac(entryPort, chain, null), route: "system-proxy" }; +} + +export interface StartChatgptUnblockOptions { + config: OcxConfig; + /** Bound public port; the derived listener port is offset from it. */ + publicPort: number; + configDir?: string; +} + +/** + * Bind the listener. Resolves `null` when the feature is disabled. A bind failure is reported + * by rejecting; callers treat it as a degraded optional integration, never a startup failure. + */ +export async function startChatgptUnblock(options: StartChatgptUnblockOptions): Promise | null> { + if (!chatgptUnblockEnabled(options.config)) return null; + const port = chatgptUnblockPort(options.config, options.publicPort); + const configDir = options.configDir ?? getConfigDir(); + const ca = await ensureLocalInterceptCaForStartup(configDir); + const leaf = issueLocalInterceptLeaf(ca, [CHATGPT_INTERCEPT_HOST]); + // The port must be the configured one, not ephemeral: the launcher's launch arguments name it. + const listener = startChatgptUnblockListener({ leaf, port }); + let entryProxy: EntryProxyHandle | undefined; + let pacRoute: ChatgptUnblockPacRoute | undefined; + if (chatgptPacFallbackEnabled(options.config)) { + try { + // The PAC names this port; it must be the derived entry port, not ephemeral, and it + // must be bound before the app ever reads the file. + entryProxy = await startChatgptUnblockEntryProxy({ originPort: listener.port ?? port, port: chatgptUnblockEntryPort(options.config, options.publicPort) }); + // Regenerated at every start: the entry port and the captured system route (the + // user's VPN state) are what the file encodes. A stale file after a config or + // network change would point the app at a dead chain. + const chain = systemProxyChain(); + const systemPac = chain.autoConfigUrl ? await loadSystemPac(chain.autoConfigUrl) : null; + const pac = chooseChatgptUnblockPac(entryProxy.port, chain, systemPac); + pacRoute = pac.route; + writeFileSync(chatgptUnblockPacPath(configDir), pac.text, { mode: 0o644 }); + } catch (error) { + await entryProxy?.stop(); + await listener.stop(true); + throw error; + } + } + // After the PAC file: the watcher wakes on this marker and relaunches with the switch it reads + // from that file. Best effort: without the marker the watcher still acts on the app's next launch. + try { + writeFileSync(chatgptUnblockReadyPath(configDir), `${port} ${new Date().toISOString()}\n`, { mode: 0o644 }); + } catch { + // An unwritable config dir already failed the CA write; nothing to add here. + } + return { + port, + caCertPath: claudeInterceptCaCertPath(configDir), + ...(entryProxy ? { entryProxy } : {}), + ...(pacRoute ? { pacRoute } : {}), + listener, + stop: async () => { + await entryProxy?.stop(); + await listener.stop(true); + }, + }; +} diff --git a/src/chatgpt/desktop-unblock/ws-frame.ts b/src/chatgpt/desktop-unblock/ws-frame.ts new file mode 100644 index 00000000000..f1ac430843b --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-frame.ts @@ -0,0 +1,156 @@ +/** + * Minimal RFC 6455 framing for the ChatGPT desktop intercept's WebSocket relay. + * + * The listener hands WebSocket upgrades to Bun's server-side stack on the client side, + * so only the upstream side needs hand-rolled framing: parse the upstream's (unmasked) + * frames off the tunnel socket, and encode the client's messages as masked frames back. + * + * Frames larger than WEBSOCKET_MAX_FRAME_BYTES are treated as a protocol violation and + * end the relay rather than being buffered indefinitely: the buffer model is "concatenate + * everything the socket has given us", so a runaway length field would otherwise grow + * memory until the connection is torn down. Voice/dictation audio frames are small + * (a few KB at most), far under this ceiling. + */ + +export const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +export const WEBSOCKET_MAX_FRAME_BYTES = 16 * 1024 * 1024; + +export const WsOpcode = { + CONTINUATION: 0x0, + TEXT: 0x1, + BINARY: 0x2, + CLOSE: 0x8, + PING: 0x9, + PONG: 0xa, +} as const; + +export type WsOpcode = (typeof WsOpcode)[keyof typeof WsOpcode]; + +export interface WsFrame { + opcode: WsOpcode; + fin: boolean; + payload: Buffer; +} + +/** + * Parse as many complete frames as `chunk` (plus anything left over from previous + * chunks) yields. Returns the frames parsed and, when the byte stream ends in a + * protocol violation (bad RSV/opcode bits, oversize length, or a masked server frame), + * the reason; the caller tears the relay down when it is non-null. Nothing about the + * parse is retryable, so a violation discards the remaining buffer. + */ +export function parseWsFrames(chunk: Buffer, pending: Buffer): { frames: WsFrame[]; violation: string | null; rest: Buffer } { + const buffer = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + const frames: WsFrame[] = []; + let offset = 0; + while (true) { + const header = parseFrameHeader(buffer, offset); + if (header.error !== null) return { frames, violation: header.error, rest: EMPTY }; + if (!header.complete) return { frames, violation: null, rest: buffer.subarray(offset) }; + const { opcode, fin, length, mask, payloadStart } = header; + let payload = buffer.subarray(payloadStart, payloadStart + length); + if (mask !== null) payload = unmask(payload, mask); + frames.push({ opcode, fin, payload: Buffer.from(payload) }); + offset = payloadStart + length; + } +} + +const EMPTY = Buffer.alloc(0); + +interface WsFrameHeader { + opcode: WsOpcode; + fin: boolean; + length: number; + mask: Buffer | null; + payloadStart: number; + complete: boolean; + error: string | null; +} + +function parseFrameHeader(buffer: Buffer, offset: number): WsFrameHeader { + if (buffer.length < offset + 2) return INCOMPLETE; + const b0 = buffer[offset]!; + const b1 = buffer[offset + 1]!; + const rsv = b0 & 0x70; + if (rsv !== 0) return violation("RSV bits set without a negotiated extension"); + const opcode = b0 & 0x0f; + const known = opcode === WsOpcode.CONTINUATION || opcode === WsOpcode.TEXT || opcode === WsOpcode.BINARY + || opcode === WsOpcode.CLOSE || opcode === WsOpcode.PING || opcode === WsOpcode.PONG; + if (!known) return violation("unknown opcode"); + const control = opcode >= WsOpcode.CLOSE; + if (control && (b0 & 0x80) === 0) return violation("control frame without FIN"); + const fin = (b0 & 0x80) !== 0; + // Upstream here is the server role: its frames are unmasked per RFC 6455 §5.1. A masked + // frame is still parsed rather than rejected -- the relay's job is to pass bytes, not to + // police the server -- but only the unmasked shape is expected in practice. + const masked = (b1 & 0x80) !== 0; + let length = b1 & 0x7f; + let cursor = offset + 2; + if (control && length > 0x7d) return violation("control frame payload over 125 bytes"); + if (length === 126) { + if (buffer.length < cursor + 2) return INCOMPLETE; + length = buffer.readUInt16BE(cursor); + cursor += 2; + } else if (length === 127) { + if (buffer.length < cursor + 8) return INCOMPLETE; + const big = buffer.readBigUInt64BE(cursor); + if (big > BigInt(WEBSOCKET_MAX_FRAME_BYTES)) return violation("frame exceeds the relay's size ceiling"); + length = Number(big); + cursor += 8; + } + if (length > WEBSOCKET_MAX_FRAME_BYTES) return violation("frame exceeds the relay's size ceiling"); + const mask: Buffer | null = masked + ? (buffer.length < cursor + 4 ? null : buffer.subarray(cursor, cursor + 4)) + : null; + if (masked) { + if (mask === null) return INCOMPLETE; + cursor += 4; + } + if (buffer.length < cursor + length) return INCOMPLETE; + return { opcode, fin, length, mask, payloadStart: cursor, complete: true, error: null }; +} + +const INCOMPLETE: WsFrameHeader = { opcode: 0, fin: false, length: 0, mask: null, payloadStart: 0, complete: false, error: null }; + +function violation(reason: string): WsFrameHeader { + return { opcode: 0, fin: false, length: 0, mask: null, payloadStart: 0, complete: false, error: reason }; +} + +function unmask(payload: Buffer, mask: Buffer): Buffer { + const out = Buffer.from(payload); + for (let i = 0; i < out.length; i++) out[i] = out[i]! ^ mask[i % 4]!; + return out; +} + +/** + * Encode a frame. Client-to-server frames are masked as RFC 6455 requires; the tunnel + * speaks the client role, so `mask` defaults to true and only tests turn it off. + */ +export function encodeWsFrame(opcode: WsOpcode, payload: Buffer, mask = true): Buffer { + let length: number; + let extended: Buffer; + if (payload.length < 126) { + length = payload.length; + extended = EMPTY; + } else if (payload.length <= 0xffff) { + length = 126; + extended = Buffer.alloc(2); + extended.writeUInt16BE(payload.length); + } else { + length = 127; + extended = Buffer.alloc(8); + extended.writeBigUInt64BE(BigInt(payload.length)); + } + const b0 = Buffer.from([0x80 | opcode]); + const b1 = Buffer.from([(mask ? 0x80 : 0) | length]); + if (!mask) return Buffer.concat([b0, b1, extended, payload]); + const key = randomMask(); + return Buffer.concat([b0, b1, extended, key, unmask(payload, key)]); +} + +function randomMask(): Buffer { + const key = Buffer.alloc(4); + crypto.getRandomValues(key); + return key; +} diff --git a/src/chatgpt/desktop-unblock/ws-relay.ts b/src/chatgpt/desktop-unblock/ws-relay.ts new file mode 100644 index 00000000000..057b7992f67 --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-relay.ts @@ -0,0 +1,347 @@ +import type { Server, ServerWebSocket } from "bun"; +import { randomBytes } from "node:crypto"; +import type { TLSSocket } from "node:tls"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_MAX_FRAME_BYTES, WsOpcode } from "./ws-frame"; +import type { WsFrame } from "./ws-frame"; +import { CHATGPT_UPSTREAM_HOST, dialUpstreamTunnel } from "./ws-upstream"; +import type { DialUpstreamOptions, UpstreamTunnel } from "./ws-upstream"; + +/** + * WebSocket upgrade relay for the ChatGPT desktop intercept. + * + * The app's voice/dictation stream (wss://chatgpt.com/dictation/stream) and any other + * WebSocket endpoint on the intercepted apex host reach this listener as HTTP upgrades, + * which the fetch-based relay cannot carry: it strips hop-by-hop headers. This module + * performs the upgrade itself. It dials chatgpt.com (directly or through the configured + * proxy, the same selection as every other outbound request), forwards the app's + * handshake headers, awaits the upstream's 101, and only then completes the app's own + * upgrade through Bun's WebSocket stack. From there messages pipe both directions: Bun + * speaks WebSocket to the app, hand-rolled RFC 6455 framing speaks it to the upstream. + * + * Path-agnostic by design: every WebSocket endpoint on the intercepted host takes the + * same pipe, so endpoints the app adds later need no allowlist. Nothing is logged and + * no payload is inspected or rewritten. + */ + +const HANDSHAKE_TIMEOUT_MS = 10_000; +/** How long a client-initiated close waits for the upstream's closing handshake. */ +const CLOSE_DRAIN_MS = 500; +/** Continuation frames accepted for one message before the relay gives up on it. */ +const MAX_MESSAGE_CHUNKS = 1024; +/** Total payload bytes accepted for one fragmented message, mirroring the per-frame ceiling. */ +const MAX_MESSAGE_BYTES = WEBSOCKET_MAX_FRAME_BYTES; + +/** + * Handshake headers the relay regenerates for the upstream leg. Extensions are dropped + * so the upstream never negotiates permessage-deflate, which the hand-rolled framing + * does not implement; Bun negotiates the app leg independently. + */ +const HANDSHAKE_REGENERATED_HEADERS = new Set([ + "host", + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", +]); + +/** Response headers that describe the upstream body or connection, not the relay's reply. */ +const REFUSAL_STRIP_HEADERS = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", + "content-length", + "upgrade", +]); + +/** Data each upgraded app socket carries: its live relay. */ +export interface WsRelaySocketData { + relay: WsRelay; +} + +/** + * Whether the relay should take a request. Version 13 is the only RFC 6455 version; an + * upgrade asking for anything else falls through to the HTTP relay unchanged. + */ +export function isRelayableUpgrade(request: Request): boolean { + if ((request.headers.get("upgrade") ?? "").toLowerCase() !== "websocket") return false; + return request.headers.get("sec-websocket-version") === "13"; +} + +/** + * Handshake headers forwarded upstream: everything the app sent except the ones the relay + * regenerates. Cookies, Origin, User-Agent and the offered subprotocols pass through. + */ +export function forwardedHandshakeHeaders(request: Request): Headers { + const headers = new Headers(); + request.headers.forEach((value, name) => { + if (!HANDSHAKE_REGENERATED_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +export type UpstreamHandshakeResult = + | { ok: true; tunnel: UpstreamTunnel; protocol: string | null; early: Buffer } + /** `head` is the upstream's response head when it answered without upgrading. */ + | { ok: false; head: string | null }; + +/** + * Upstream half of the handshake: dial, send the app's request with a fresh key, await + * the response head. Never throws; a failed dial or a refusal comes back as `ok: false`. + * On success the tunnel is left paused so frames arriving before the app's socket + * attaches wait in the socket instead of being dropped. + */ +export async function performUpstreamHandshake( + request: Request, + dialOptions: DialUpstreamOptions = {}, +): Promise { + const tunnel = await dialUpstreamTunnel(dialOptions); + if (!tunnel) return { ok: false, head: null }; + const url = new URL(request.url); + const lines = [`GET ${url.pathname}${url.search} HTTP/1.1`, `Host: ${CHATGPT_UPSTREAM_HOST}`]; + forwardedHandshakeHeaders(request).forEach((value, name) => lines.push(`${name}: ${value}`)); + lines.push( + "Connection: Upgrade", + "Upgrade: websocket", + `Sec-WebSocket-Key: ${randomBytes(16).toString("base64")}`, + "Sec-WebSocket-Version: 13", + ); + tunnel.socket.write(`${lines.join("\r\n")}\r\n\r\n`); + const response = await readResponseHead(tunnel.socket, HANDSHAKE_TIMEOUT_MS); + if (response === null || !/^HTTP\/1\.[01] 101\b/.test(response.head)) { + tunnel.socket.destroy(); + return { ok: false, head: response?.head ?? null }; + } + const protocol = /^sec-websocket-protocol:[ \t]*([^\r\n]+)/im.exec(response.head)?.[1]?.trim() ?? null; + return { ok: true, tunnel, protocol, early: response.early }; +} + +/** Read through the blank line; bytes after it are the first frames. Pauses the socket. */ +export function readResponseHead(socket: TLSSocket, timeoutMs: number): Promise<{ head: string; early: Buffer } | null> { + return new Promise(resolve => { + let buffer = Buffer.alloc(0); + let settled = false; + const finish = (result: { head: string; early: Buffer } | null) => { + if (settled) return; + settled = true; + socket.removeListener("data", onData); + socket.removeListener("error", onFailure); + socket.removeListener("close", onFailure); + // Until WsRelay.attach() installs its own handlers (or the socket is destroyed), a late + // 'error' with no listener is thrown by the emitter and would take the whole proxy down. + socket.on("error", () => {}); + socket.setTimeout(0); + socket.pause(); + resolve(result); + }; + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end !== -1) finish({ head: buffer.subarray(0, end).toString("latin1"), early: buffer.subarray(end + 4) }); + }; + const onFailure = () => finish(null); + socket.on("data", onData); + socket.once("error", onFailure); + socket.once("close", onFailure); + socket.setTimeout(timeoutMs, onFailure); + }); +} + +/** + * One live relay between an upgraded app socket and its upstream tunnel. Upstream + * fragments are reassembled into whole messages because Bun's server socket sends whole + * messages; text stays text and binary stays binary in both directions. + */ +export class WsRelay { + private client: ServerWebSocket | null = null; + private pendingParse: Buffer = Buffer.alloc(0); + private fragmentation: { opcode: WsOpcode; chunks: Buffer[]; bytes: number } | null = null; + private clientClosed = false; + private closed = false; + private drainTimer: ReturnType | null = null; + + constructor( + private readonly tunnel: UpstreamTunnel, + private readonly early: Buffer, + ) {} + + /** Bun's `open` handler: start piping, beginning with any frames that beat the upgrade. */ + attach(client: ServerWebSocket): void { + this.client = client; + const socket = this.tunnel.socket; + socket.on("data", this.onTunnelData); + socket.on("error", this.onTunnelFailure); + socket.on("close", this.onTunnelFailure); + if (this.early.length > 0) this.onTunnelData(this.early); + socket.resume(); + } + + /** Bun's `message` handler: app -> upstream, message type preserved. */ + clientMessage(message: string | Buffer): void { + if (this.clientClosed || this.closed) return; + const frame = typeof message === "string" + ? encodeWsFrame(WsOpcode.TEXT, Buffer.from(message, "utf8")) + : encodeWsFrame(WsOpcode.BINARY, message); + this.tunnel.socket.write(frame); + } + + /** Bun's `close` handler: forward the app's close, then give the upstream a moment to answer. */ + clientClose(code: number, reason: string): void { + if (this.clientClosed || this.closed) return; + this.clientClosed = true; + const reasonBytes = Buffer.from(reason ?? "", "utf8"); + const payload = Buffer.alloc(2 + reasonBytes.length); + payload.writeUInt16BE(sendableCloseCode(code), 0); + reasonBytes.copy(payload, 2); + this.tunnel.socket.write(encodeWsFrame(WsOpcode.CLOSE, payload)); + this.drainTimer = setTimeout(() => this.shutdown(), CLOSE_DRAIN_MS); + } + + /** Tear down a relay whose app-side upgrade never completed. */ + abort(): void { + this.shutdown(); + } + + private readonly onTunnelData = (chunk: Buffer): void => { + const { frames, violation, rest } = parseWsFrames(chunk, this.pendingParse); + this.pendingParse = rest; + for (const frame of frames) this.handleUpstreamFrame(frame); + if (violation !== null) this.failClient(1002, "upstream protocol error"); + }; + + /** Upstream vanished without a closing handshake: the app sees an abnormal closure. */ + private readonly onTunnelFailure = (): void => { + if (this.closed) return; + this.shutdown(); + try { + this.client?.terminate(); + } catch { + // Already gone. + } + }; + + private handleUpstreamFrame(frame: WsFrame): void { + if (this.closed) return; + const { opcode, fin, payload } = frame; + if (opcode === WsOpcode.TEXT || opcode === WsOpcode.BINARY) { + // A new data frame mid-fragmentation is a protocol violation; the relay restarts + // assembly rather than tearing the connection down over it. + this.fragmentation = { opcode, chunks: [payload], bytes: payload.length }; + if (fin) this.flushMessage(); + return; + } + if (opcode === WsOpcode.CONTINUATION) { + if (this.fragmentation === null) return; + this.fragmentation.chunks.push(payload); + this.fragmentation.bytes += payload.length; + if (this.fragmentation.chunks.length > MAX_MESSAGE_CHUNKS || this.fragmentation.bytes > MAX_MESSAGE_BYTES) { + this.failClient(1009, "message too fragmented"); + return; + } + if (fin) this.flushMessage(); + return; + } + if (opcode === WsOpcode.PING) { + this.tunnel.socket.write(encodeWsFrame(WsOpcode.PONG, payload)); + return; + } + if (opcode === WsOpcode.CLOSE) { + const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000; + const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : ""; + this.shutdown(); + try { + this.client?.close(sendableCloseCode(code), reason); + } catch { + // The app already closed. + } + } + // PONG: a keepalive answer to nothing the relay sent; ignore. + } + + private flushMessage(): void { + const { opcode, chunks } = this.fragmentation!; + this.fragmentation = null; + const message = chunks.length === 1 ? chunks[0]! : Buffer.concat(chunks); + try { + // Send the Buffer itself: for a view, `message.buffer` spans bytes outside the message. + if (opcode === WsOpcode.TEXT) this.client!.send(message.toString("utf8")); + else this.client!.send(message); + } catch { + this.onTunnelFailure(); + } + } + + private failClient(code: number, reason: string): void { + if (this.closed) return; + this.shutdown(); + try { + this.client?.close(code, reason); + } catch { + // Already gone. + } + } + + private shutdown(): void { + this.closed = true; + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer); + this.drainTimer = null; + } + this.tunnel.socket.destroy(); + } +} + +/** + * Codes an endpoint may put in a close frame. 1005/1006/1015 are reserved for reporting + * and 1004 is undefined; anything outside the registered and application ranges maps to 1000. + */ +export function sendableCloseCode(code: number): number { + if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return 1000; + if (code >= 1000 && code <= 1014) return code; + if (code >= 3000 && code <= 4999) return code; + return 1000; +} + +/** + * The listener's fetch-handler entry for WebSocket upgrades. The upstream handshake + * finishes first, so the app's upgrade only succeeds once chatgpt.com has accepted; + * an unreachable or refusing upstream surfaces as an ordinary failed upgrade. + */ +export async function handleWebSocketUpgrade( + request: Request, + server: Server, + dialOptions: DialUpstreamOptions = {}, +): Promise { + const handshake = await performUpstreamHandshake(request, dialOptions); + if (!handshake.ok) return refusalResponse(handshake.head); + const relay = new WsRelay(handshake.tunnel, handshake.early); + const upgraded = server.upgrade(request, { + data: { relay }, + // Bun rejects an empty headers object, so omit it when there is no subprotocol. + ...(handshake.protocol ? { headers: { "sec-websocket-protocol": handshake.protocol } } : {}), + }); + if (!upgraded) { + relay.abort(); + return new Response("websocket upgrade failed", { status: 400 }); + } + return undefined; +} + +/** Answer a failed upstream handshake: the upstream's own status and headers, or a 502. */ +function refusalResponse(head: string | null): Response { + if (head === null) return new Response("chatgpt upstream unreachable", { status: 502 }); + const [statusLine = "", ...headerLines] = head.split("\r\n"); + const match = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/.exec(statusLine); + const status = match ? Number(match[1]) : 502; + // A Response cannot carry a 1xx status; anything but 101 still means "no upgrade". + const safeStatus = status >= 200 && status <= 599 ? status : 502; + const headers = new Headers(); + for (const line of headerLines) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const name = line.slice(0, colon).trim(); + if (!REFUSAL_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, line.slice(colon + 1).trim()); + } + return new Response(null, { status: safeStatus, statusText: match?.[2] ?? "", headers }); +} diff --git a/src/chatgpt/desktop-unblock/ws-upstream.ts b/src/chatgpt/desktop-unblock/ws-upstream.ts new file mode 100644 index 00000000000..84f0f44afa4 --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-upstream.ts @@ -0,0 +1,282 @@ +import { connect as connectSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { effectiveProxyFor } from "../../lib/proxy-env"; +import { socks5Credentials, socks5Handshake } from "../../lib/socks5-handshake"; +import type { Socket } from "node:net"; +import type { TLSSocket } from "node:tls"; + +/** + * Upstream transport for the ChatGPT desktop intercept's WebSocket relay. + * + * Bun's WebSocket client ignores proxy environment variables and has no proxy option + * (verified on Bun 1.4.0), so the relay dials chatgpt.com itself over a raw socket it + * fully controls. The dial honors the same proxy selection as every other outbound + * request the server makes: `effectiveProxyFor` reads HTTP(S)_PROXY/ALL_PROXY, which + * `applyProxyEnv` populates from `config.proxy` at startup. A configured http(s) proxy + * is reached through an HTTP CONNECT tunnel; a SOCKS5 ALL_PROXY through a SOCKS5 CONNECT + * (sharing `src/lib/socks5-handshake.ts` with the fetch tunnel, so a credentialed + * `socks5://` proxy authenticates on both routes); no proxy means a direct TLS + * connection. The VPN's own mode (system proxy / TUN / off) therefore never has to be + * detected: the tunnel rides whatever egress opencodex already uses for provider traffic. + */ + +export const CHATGPT_UPSTREAM_HOST = "chatgpt.com"; +export const CHATGPT_UPSTREAM_TLS_PORT = 443; + +/** How the tunnel reached chatgpt.com; surfaced for tests and diagnostics. */ +export interface UpstreamTunnel { + socket: TLSSocket; + route: "direct" | "http-connect" | "socks5"; +} + +export interface DialUpstreamOptions { + /** Override the proxy picked from the environment; tests use it to point at a local proxy. */ + proxy?: string | null; + /** Connect timeout for the TCP dial and the proxy handshake, milliseconds. */ + connectTimeoutMs?: number; + /** Test seam: dial this address instead of chatgpt.com:443 (SNI still names chatgpt.com). */ + target?: { host: string; port: number }; + /** Test seam: trust this CA for the upstream certificate instead of the system store. */ + ca?: string; +} + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + +const CRLF = "\r\n"; + +class HttpProxyHandshakeError extends Error { + override readonly name = "HttpProxyHandshakeError"; +} + +/** + * Establish the TLS connection to chatgpt.com the relay pipes frames through. + * Resolves null when the TCP dial, the proxy handshake, or the TLS handshake fails + * within the timeout, so the fetch handler can answer the app with a plain 502 + * instead of hanging the upgrade. + */ +export async function dialUpstreamTunnel(options: DialUpstreamOptions = {}): Promise { + const timeout = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + const proxy = options.proxy !== undefined + ? options.proxy + : effectiveProxyFor(new URL(`https://${CHATGPT_UPSTREAM_HOST}`), process.env); + const route: UpstreamTunnel["route"] = socks5Route(proxy) ? "socks5" : proxy ? "http-connect" : "direct"; + try { + const target = options.target ?? { host: CHATGPT_UPSTREAM_HOST, port: CHATGPT_UPSTREAM_TLS_PORT }; + const raw = await dialRaw(target, proxy, route, timeout, options.ca); + const socket = await wrapTls(raw, timeout, options.ca); + return { socket, route }; + } catch { + return null; + } +} + +interface RawTarget { + host: string; + port: number; +} + +function socks5Route(proxy: string | null): boolean { + return proxy !== null && /^socks5h?:\/\//i.test(proxy.trim()); +} + +function isIpLiteral(host: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":"); +} + +/** + * Port to dial for a proxy URL. `URL.port` is empty both when the port is omitted and when it + * equals the scheme default, so `http://proxy:80` must fall back to 80, never to an invented + * 8080; that would diverge from the fetch tunnel, which honors the URL as written. + */ +export function proxyDialPort(proxyUrl: URL, route: UpstreamTunnel["route"]): number { + const explicit = Number(proxyUrl.port); + if (explicit) return explicit; + if (route === "socks5") return 1080; + return proxyUrl.protocol === "https:" ? 443 : 80; +} + +async function dialRaw(target: RawTarget, proxy: string | null, route: UpstreamTunnel["route"], timeout: number, ca: string | undefined): Promise { + if (route === "direct") return tcpConnect(target.host, target.port, timeout); + const proxyUrl = new URL(proxy!); + const proxyHost = proxyUrl.hostname.replace(/^\[|\]$/g, ""); + const proxyPort = proxyDialPort(proxyUrl, route); + const proxySocket = await tcpConnect(proxyHost, proxyPort, timeout); + // An https:// proxy speaks TLS on its own port before any handshake, so the CONNECT + // request must ride that TLS session, with the proxy's hostname as the SNI. + const plain = proxyUrl.protocol === "https:" ? await wrapProxyTls(proxySocket, proxyHost, timeout, ca) : proxySocket; + if (plain !== proxySocket) { + plain.once("error", () => proxySocket.destroy()); + } + const reader = new ProxyHandshakeReader(plain, timeout); + const socks5 = route === "socks5"; + try { + // A socks5:// URL with credentials must authenticate here exactly as the fetch tunnel + // would with the same URL; refusing selection instead would disable voice and dictation + // for proxies fetch traffic handles fine. Credential decoding stays inside the try so a + // malformed URL destroys the proxy socket instead of leaking it. + const credentials = socks5 ? socks5Credentials(proxyUrl) : {}; + if (!socks5) await httpConnectThrough(reader, target, proxyUrl); + else await socks5Handshake(reader, target, credentials); + } catch (error) { + reader.dispose(); + plain.destroy(); + throw error; + } + // Handshake done: hand leftover bytes and data events back to the socket so the TLS + // layer above starts from a clean stream. + reader.dispose(); + return plain; +} + +async function tcpConnect(host: string, port: number, timeout: number): Promise { + return new Promise((resolve, reject) => { + const socket = connectSocket({ host, port }); + const onError = (error: Error) => { socket.destroy(); reject(error); }; + socket.setTimeout(timeout, () => onError(new Error("tcp connect timeout"))); + socket.once("error", onError); + socket.once("connect", () => { + socket.setTimeout(0); + socket.removeListener("error", onError); + resolve(socket); + }); + }); +} + +/** + * Accumulates proxy-handshake bytes until each awaited step has what it needs, then + * hands any leftover bytes back to the socket so the TLS layer above sees a clean + * stream. A socket error, timeout, or a proxy that closes the connection before finishing + * its reply fails every pending step, so the upgrade answers 502 instead of hanging; after + * `dispose()` the reader no longer owns the socket's data, end, or close events. + */ +class ProxyHandshakeReader { + private buffer: Buffer = Buffer.alloc(0); + private pending: { ready: (buffer: Buffer) => boolean; resolve: () => void; reject: (error: Error) => void } | null = null; + private failure: Error | null = null; + private readonly onData = (chunk: Buffer) => this.feed(chunk); + private readonly onError = (error: Error) => this.fail(error); + private readonly onTimeout = () => this.fail(new Error("proxy handshake timeout")); + private readonly onClosed = () => this.fail(new Error("proxy closed the connection during the handshake")); + + constructor(private readonly socket: Socket, timeout: number) { + socket.on("data", this.onData); + socket.on("error", this.onError); + socket.on("end", this.onClosed); + socket.on("close", this.onClosed); + socket.setTimeout(timeout, this.onTimeout); + } + + write(bytes: Uint8Array | string): void { + this.socket.write(bytes); + } + + /** Await until the buffer holds at least `bytes` bytes, then consume exactly that many. */ + readExact(bytes: number): Promise> { + return this.wait(buffer => buffer.length >= bytes, () => this.consume(bytes)); + } + + /** Await the full HTTP response head (through the blank line), consuming it. */ + readHttpHead(): Promise { + return this.wait( + buffer => buffer.indexOf("\r\n\r\n") !== -1, + () => this.consume(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"), + ); + } + + /** Only one handshake step is ever outstanding, so one pending slot suffices. */ + private wait(ready: (buffer: Buffer) => boolean, take: () => T): Promise { + if (this.failure) return Promise.reject(this.failure); + if (ready(this.buffer)) return Promise.resolve(take()); + return new Promise((resolve, reject) => { + this.pending = { ready, resolve: () => resolve(take()), reject }; + }); + } + + private consume(bytes: number): Buffer { + const consumed: Buffer = this.buffer.subarray(0, bytes); + this.buffer = this.buffer.subarray(bytes); + return consumed; + } + + private feed(chunk: Buffer): void { + this.buffer = this.buffer.length === 0 ? (chunk satisfies Buffer) : Buffer.concat([this.buffer, chunk]); + if (this.pending && this.pending.ready(this.buffer)) { + const waiter = this.pending; + this.pending = null; + waiter.resolve(); + } + } + + private fail(error: Error): void { + this.failure = error; + const waiter = this.pending; + this.pending = null; + waiter?.reject(error); + } + + dispose(): void { + this.socket.removeListener("data", this.onData); + this.socket.removeListener("error", this.onError); + this.socket.removeListener("end", this.onClosed); + this.socket.removeListener("close", this.onClosed); + this.socket.setTimeout(0); + if (this.buffer.length > 0) this.socket.unshift(this.buffer); + this.buffer = Buffer.alloc(0); + this.fail(new Error("proxy handshake reader disposed")); + } +} + +async function httpConnectThrough(reader: ProxyHandshakeReader, target: RawTarget, proxyUrl: URL): Promise { + const authority = `${target.host}:${target.port}`; + const lines = [ + `CONNECT ${authority} HTTP/1.1`, + `Host: ${authority}`, + `Proxy-Connection: Keep-Alive`, + ]; + // Credentials in the proxy URL become Basic Proxy-Authorization; an unauthenticated + // proxy never sees the header. + if (proxyUrl.username || proxyUrl.password) { + let credentials: string; + try { + credentials = Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString("base64"); + } catch { + throw new HttpProxyHandshakeError("HTTP proxy credentials contain invalid percent encoding"); + } + lines.push(`Proxy-Authorization: Basic ${credentials}`); + } + reader.write([...lines, "", ""].join(CRLF)); + const head = await reader.readHttpHead(); + const statusLine = head.split(CRLF)[0]!; + if (!/^HTTP\/1\.[01] 2\d\d/.test(statusLine)) throw new Error(`proxy refused CONNECT: ${statusLine}`); +} + +async function wrapTls(raw: Socket, timeout: number, ca: string | undefined): Promise { + return new Promise((resolve, reject) => { + const tls = connectTls({ socket: raw, servername: CHATGPT_UPSTREAM_HOST, ...(ca ? { ca } : {}) }); + const onError = (error: Error) => { tls.destroy(); reject(error); }; + tls.setTimeout(timeout, () => onError(new Error("TLS handshake timeout"))); + tls.once("error", onError); + tls.once("secureConnect", () => { + tls.setTimeout(0); + tls.removeListener("error", onError); + resolve(tls); + }); + }); +} + +/** TLS-wrap the proxy's own socket before the CONNECT handshake (https:// proxy URLs). */ +async function wrapProxyTls(raw: Socket, proxyHost: string, timeout: number, ca: string | undefined): Promise { + // An IP-literal proxy has no hostname to name in SNI; node:tls forbids it outright. + const servername = isIpLiteral(proxyHost) ? undefined : proxyHost; + return new Promise((resolve, reject) => { + const tls = connectTls({ socket: raw, ...(servername ? { servername } : {}), ...(ca ? { ca } : {}) }); + const onError = (error: Error) => { tls.destroy(); reject(error); }; + tls.setTimeout(timeout, () => onError(new Error("proxy TLS handshake timeout"))); + tls.once("error", onError); + tls.once("secureConnect", () => { + tls.setTimeout(0); + tls.removeListener("error", onError); + resolve(tls); + }); + }); +} diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index e80b99f6335..57597da7ee5 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -21,10 +21,10 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [ export const CAPABILITIES: readonly Capability[] = [ { command: ["chatgpt"], - summary: "Experimental ChatGPT app-server shim: launch, restore or status (macOS only).", + summary: "Experimental ChatGPT shim/intercept: launch, restore, status and watcher management (macOS only).", routes: [], flags: [], mutates: true, json: "none", - bannerLines: ["ocx chatgpt Experimental app-server shim: launch|restore|status (macOS)"], - details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."], + bannerLines: ["ocx chatgpt Experimental shim/intercept: launch|restore|status|install-watcher|uninstall-watcher (macOS)"], + details: ["Default off; launch requires chatgptDesktop.appServerShim or unblockSend. Intercept needs the running proxy and manual CA trust. Watcher manages intercept launches only; restore removes the shim launcher."], }, {"command": ["link","probe"],"summary": "Probe an SSH host fingerprint and OpenCodex version without trusting it.","routes": [{"method": "POST","path": "/api/link/probe"}],"flags": [{"name": "--json","value": "boolean","summary": "Emit alias, fingerprint, key type and version as JSON."}],"mutates": true,"json": "payload","usage": "ocx link probe [--json]","details": ["Requires loopback admin authority on the Home. Stores only a temporary pending offer; does not persist host trust or issue a link key."]}, {"command": ["link","add"],"summary": "Connect an SSH Child to this Home after confirming its fingerprint.","routes": [{"method": "POST","path": "/api/link/probe"},{"method": "POST","path": "/api/link/confirm-host"},{"method": "POST","path": "/api/link/apply"}],"flags": [{"name": "--expect-fingerprint","value": "string","summary": "Exact SHA256 host fingerprint; required without an interactive TTY."},{"name": "--json","value": "boolean","summary": "Emit the verified link result as JSON."}],"mutates": true,"json": "payload","usage": "ocx link add [--expect-fingerprint SHA256:…] [--json]","details": ["Requires loopback admin authority on the Home. Without a fingerprint, requires a TTY and explicit yes after showing the offered fingerprint. Never prints the link key.","Preflights bound Child restart support and curl, connects, restarts once and verifies a fresh authenticated catalog relay before success. Post-connect failures can leave Child config; run ocx disconnect and ocx restart there before retrying."]}, diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 3006f44638c..cf880314b1e 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -2,6 +2,18 @@ import { spawnSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "../config"; +import type { OcxConfig } from "../types"; +import { findLiveProxy } from "../server/proxy-liveness"; +import { getConfigDir } from "../config/paths"; +import { claudeInterceptCaCertPath } from "../claude/intercept/local-ca"; +import { chatgptCaTrustCommand, inspectChatgptCaTrust } from "../chatgpt/desktop-unblock/ca-trust"; +import { chatgptPacFallbackEnabled, chatgptUnblockEntryPort, chatgptUnblockPacArg, chatgptUnblockPort, chatgptUnblockResolverArg } from "../chatgpt/desktop-unblock/runtime"; +import { + chatgptAppCommandLine, chatgptCommandLineHasPac, chatgptCommandLineHasRule, chatgptUnblockWatcherLoaded, chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, launchChatgptWithRule, probeChatgptUnblockListener, + uninstallChatgptUnblockWatcher, +} from "../chatgpt/desktop-unblock/launch-watcher"; +import { interactiveConfirm } from "./interactive-confirm"; import { readConfigFileSnapshot } from "../config/diagnostics"; import { chatgptDesktopConfigIssue } from "../config/schema/chatgpt-desktop"; import { @@ -14,9 +26,27 @@ import { darwinDefaultExec, darwinDesktopAppAdapter } from "../codex/desktop-app import type { DesktopAppInstall } from "../codex/desktop-app/types"; const USAGE = `Usage (experimental, macOS only): - ocx chatgpt launch Relaunch ChatGPT with the experimental app-server shim (requires appServerShim: true) - ocx chatgpt restore Relaunch ChatGPT without the experimental shim and remove its launcher - ocx chatgpt status Inspect experimental flag, launcher and running app environment`; + ocx chatgpt launch Relaunch with configured app-server shim and/or TLS intercept + ocx chatgpt restore Relaunch with native networking and remove the shim launcher + ocx chatgpt status Inspect flags, listener, CA trust, watcher and app environment + ocx chatgpt install-watcher [--yes] Install the intercept launch watcher (requires unblockSend: true; + with pacFallback it launches through the PAC instead) + ocx chatgpt uninstall-watcher Remove the intercept launch watcher`; + +export function resolveChatgptUnblockPort(config: OcxConfig, livePort: number | undefined): number { + return chatgptUnblockPort(config, livePort ?? config.port ?? 10100); +} + +/** PAC-fallback mode: the CONNECT entry port the PAC points chatgpt.com at, or undefined when off. */ +function resolveChatgptPacEntry(config: OcxConfig, livePort: number | undefined): { entryPort: number } | undefined { + if (!chatgptPacFallbackEnabled(config)) return undefined; + return { entryPort: chatgptUnblockEntryPort(config, livePort ?? config.port ?? 10100) }; +} + +const WATCHER_CONSENT = `The experimental watcher quits and reopens ChatGPT after a Dock/Spotlight launch +without intercept switches while opencodex's listener is running. This can interrupt +startup work. It manages the TLS intercept only; use 'ocx chatgpt launch' for the shim. +Remove it with 'ocx chatgpt uninstall-watcher'.`; function run(command: string, args: string[]) { const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); @@ -103,16 +133,28 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat console.log(USAGE); return sub ? 0 : 64; } - if (!["launch", "restore", "status"].includes(sub) || args.length !== 1) { + if (!["launch", "restore", "status", "install-watcher", "uninstall-watcher"].includes(sub) + || (sub === "install-watcher" ? args.slice(1).some(arg => arg !== "--yes") || args.length > 2 : args.length !== 1)) { console.error(USAGE); return 64; } if (platform !== "darwin") { - console.error("ChatGPT app-server shim (experimental): macOS only."); + console.error("ChatGPT desktop integrations (experimental): macOS only."); return 1; } try { + // Removal remains available even if the hand-edited config or derived port is invalid. + if (sub === "uninstall-watcher") { + uninstallChatgptUnblockWatcher(); + console.log("Experimental intercept launch watcher removed."); + return 0; + } const config = loadConfig(); + const intercept = config.chatgptDesktop?.unblockSend === true; + const shim = config.chatgptDesktop?.appServerShim === true; + const live = intercept || sub === "status" ? await findLiveProxy().catch(() => null) : null; + const port = intercept && sub !== "restore" && sub !== "status" ? resolveChatgptUnblockPort(config, live?.port) : undefined; + const pac = port !== undefined ? resolveChatgptPacEntry(config, live?.port) : undefined; const launcher = chatgptShimLauncherPath(); const install = discoverApp(); if (sub === "status") { @@ -125,6 +167,30 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat launcher: ${existsSync(launcher) ? "present" : "absent"} app: ${install === undefined ? "unknown (app discovery failed)" : install ? (app.running === null ? "unknown (process inspection failed)" : app.running ? "running" : "not running") : "not installed"} CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "no"}`); + try { + await printInterceptStatus(config, resolveChatgptUnblockPort(config, live?.port), live?.port, install?.root); + } catch (error) { + console.log(`Intercept status unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + return 0; + } + if (sub === "install-watcher") { + if (!intercept || config.runtimeRole === "client") { + console.error("Enable chatgptDesktop.unblockSend in a server config before installing the watcher."); + return 1; + } + if (!install) { + console.error("ChatGPT bundle discovery failed or no app was found; no watcher was changed."); + return 1; + } + console.log(WATCHER_CONSENT); + if (!args.includes("--yes") && (!process.stdin.isTTY + || !(await interactiveConfirm({ question: "Install the experimental intercept watcher?", defaultYes: false })))) { + console.error("Watcher not installed; re-run with --yes to confirm."); + return 1; + } + installChatgptUnblockWatcher({ port: port!, appPath: install.root, ...(pac ? { entryPort: pac.entryPort } : {}) }); + console.log(`Experimental intercept launch watcher installed for port ${port}${pac ? ` (PAC fallback, entry port ${pac.entryPort})` : ""}.`); return 0; } if (install === undefined) { @@ -138,25 +204,38 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n } let binary: string | undefined; if (sub === "launch") { - if (config.chatgptDesktop?.appServerShim !== true) { + if (!shim && !intercept) { // Read the file only to explain a refusal; restore and a valid launch never touch it. const configIssue = chatgptDesktopIssueInFile(); console.error(configIssue - ? `Experimental shim disabled: config.json ${configIssue}. The whole chatgptDesktop block is ignored until that is fixed.` - : "Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching."); + ? `ChatGPT desktop launch disabled: config.json ${configIssue}. The whole chatgptDesktop block is ignored until that is fixed.` + : "Enable chatgptDesktop.appServerShim or chatgptDesktop.unblockSend before launching."); return 1; } - binary = resolveChatgptCodexBinary(install.root) ?? undefined; - if (!binary) { - console.error(`No bundled app-server binary was found in ${install.root}; the shim cannot launch this build.`); - return 1; + if (intercept) { + if (config.runtimeRole === "client" || (await probeChatgptUnblockListener(port!)).state !== "ours") { + console.error("Experimental intercept listener is not answering; start opencodex in server mode first."); + return 1; + } + } + if (shim) { + binary = resolveChatgptCodexBinary(install!.root) ?? undefined; + if (!binary) { + console.error(`No bundled app-server binary was found in ${install!.root}; the shim cannot launch this build.`); + return 1; + } } } - // Both relaunch paths execute the discovered bundle. Restore validates the app - // shell without requiring an app-server binary or the experimental opt-in flag. - const untrusted = untrustedChatgptBundleReason(install.root, binary); + // Every relaunch path executes the discovered bundle, the intercept one included. Restore + // validates the app shell without requiring an app-server binary or an experimental opt-in. + const untrusted = untrustedChatgptBundleReason(install!.root, binary); if (untrusted) { - console.error(`Refusing to ${sub === "launch" ? "launch the shim" : "restore ChatGPT"}: ${untrusted}.`); + console.error(`Refusing to ${sub === "launch" ? (shim ? "launch the shim" : "launch ChatGPT") : "restore ChatGPT"}: ${untrusted}.`); + return 1; + } + // A loaded watcher would immediately put the intercept switches back on restore. + if (sub === "restore" && chatgptUnblockWatcherLoaded()) { + console.error("Uninstall the launch watcher before restoring native networking."); return 1; } // Preflight before changing the launcher. quitApp rechecks at the action boundary. @@ -165,6 +244,12 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n return 1; } if (binary) writeChatgptShimLauncher(undefined, binary); + if (sub === "launch" && intercept) { + // The intercept script owns the restart under its lock so the watcher cannot race it. + const result = launchChatgptWithRule(port!, undefined, shim, pac, install.root); + if (result.output) (result.ok ? console.log : console.error)(result.output); + return result.ok ? 0 : 1; + } if (!(await quitApp(install, launcher))) { console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry."); return 1; @@ -173,15 +258,46 @@ CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "n const env = { ...process.env }; delete env.CODEX_CLI_PATH; // Open the verified bundle by path, so the relaunch is the same app that was quit. - const result = spawnSync("/usr/bin/open", ["-a", install.root, ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { + const result = spawnSync("/usr/bin/open", ["-a", install!.root, ...(sub === "launch" && shim ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { encoding: "utf8", env, timeout: 10000, }); if (result.status !== 0) throw new Error(result.error?.message ?? (result.stderr?.trim() || "open failed")); if (sub === "restore") rmSync(launcher, { force: true }); - console.log(`ChatGPT relaunched ${sub === "launch" ? "with" : "without"} the experimental app-server shim.`); + console.log(`ChatGPT relaunched ${sub === "launch" ? "with" : "without"} the configured experimental integrations.`); return 0; } catch (error) { - console.error(`ChatGPT shim (experimental): ${error instanceof Error ? error.message : String(error)}`); + console.error(`ChatGPT desktop (experimental): ${error instanceof Error ? error.message : String(error)}`); return 1; } } + +async function printInterceptStatus(config: OcxConfig, port: number, livePort?: number, appPath?: string): Promise { + const configDir = getConfigDir(); + const pac = resolveChatgptPacEntry(config, livePort); + const listener = await probeChatgptUnblockListener(port); + const watcher = chatgptUnblockWatcherStatus(port, undefined, false, pac, appPath); + const app = chatgptAppCommandLine(); + const flagged = app !== null && (pac ? chatgptCommandLineHasPac(app, configDir) : chatgptCommandLineHasRule(app, port)); + const caPath = claudeInterceptCaCertPath(configDir); + const trust = await inspectChatgptCaTrust(caPath); + // The PAC switch carries the whole script inline; show its shape, not kilobytes of base64. + const pacSwitch = pac ? chatgptUnblockPacArg(configDir) : ""; + const launchSwitch = pac ? `${pacSwitch.slice(0, pacSwitch.indexOf(",") + 1)}… (${pacSwitch.length} bytes)` : chatgptUnblockResolverArg(port); + console.log(`send-unblock TLS intercept (experimental): ${config.chatgptDesktop?.unblockSend === true ? "on" : "off"} +PAC fallback: ${pac ? `on (entry port ${pac.entryPort}; falls back to the captured system routes (embedded PAC errors may still fall back directly), when opencodex is down)` : "off"} +listener: ${port} (${listener.state === "ours" ? "listening" : listener.state === "foreign" ? "held by another process" : "not listening"}) +launch switch: ${launchSwitch} +CA trust: ${trust} +watcher script: ${watcher.scriptInstalled ? watcher.scriptUpToDate ? "installed" : "outdated; reinstall" : "absent"} +watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "absent"} +app intercept switches: ${flagged ? "yes" : "no"}`); + if (trust === "untrusted") console.log(`Trust manually with: ${chatgptCaTrustCommand(caPath)}`); + if (listener.state === "ours") { + for (const block of listener.preservedSendBlocks) console.log(`Send block kept: ${block.name}: ${block.reason} (last seen ${block.lastSeen})`); + } + if (flagged && listener.state !== "ours") { + console.log(pac + ? "ChatGPT is launched through opencodex's PAC, but the listener is not answering: it falls back to the system chain, and may bypass opencodex on later launches until the PAC is refreshed. Start opencodex again or run ocx chatgpt restore." + : "ChatGPT is routed to a closed or foreign port; start opencodex again or run ocx chatgpt restore."); + } +} diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 1030fbee473..74cffa8651a 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -26,6 +26,8 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str // `resolve` is read-only inspection for embedding shells: a lookup made to populate // a consent surface must not trigger a shim repair side effect first. if (command === "resolve") return true; + // Watcher identity validation must remain read-only before any app action. + if (command === "internal" && args[1] === "chatgpt-bundle-preflight") return true; // The desktop's approval-bound stop must validate its snapshot before any unrelated // CLI repair changes the installation it was approved against. if (command === "stop" && args.some(arg => arg.startsWith("--expect-"))) return true; diff --git a/src/cli/help.ts b/src/cli/help.ts index 9f20b1c8853..f154b2b2f57 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -100,7 +100,7 @@ Usage: ocx advisor Advisor sidecar settings and context-sharing consent ocx companion Menu-bar and widget companion usage settings ocx lab Inspect Lab evidence and control local automation - ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) + ocx chatgpt Experimental shim/intercept: launch|restore|status|install-watcher|uninstall-watcher (macOS) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index 7799ceb52b2..28d28c65c50 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -25,6 +25,11 @@ function optionValue(args: readonly string[], name: string): string | undefined export async function handleInternalCommand(args: readonly string[]): Promise { const sub = args[0]; + if (sub === "chatgpt-bundle-preflight") { + if (args.length !== 2 || !args[1]) return 2; + const { runChatgptWatcherBundlePreflight } = await import("../chatgpt/desktop-unblock/bundle-preflight"); + return runChatgptWatcherBundlePreflight(args[1]); + } if (sub === "chatgpt-app-server-filter") { if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { console.error(USAGE); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 17adee29ee6..feb7d4bf08f 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -16,9 +16,9 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "chatgpt", - usage: "ocx chatgpt ", - summary: "Experimental ChatGPT app-server shim (macOS only, default off).", - details: ["Experimental launch requires chatgptDesktop.appServerShim: true; restore removes its launcher."], + usage: "ocx chatgpt ", + summary: "Experimental ChatGPT app-server shim and TLS intercept (macOS only, default off).", + details: ["Launch requires appServerShim or unblockSend; intercept needs a running proxy and manual CA trust. With pacFallback the intercept launches through a generated PAC instead of the host-resolver rule, so the app falls back to the system chain while opencodex is down. install-watcher [--yes] manages intercept only; uninstall-watcher removes it. Restore removes the shim launcher."], }, { name: "init", diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index aca8261db5e..4797ceb8a54 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -132,6 +132,12 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") { warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform"); } + if (normalized.chatgptDesktop?.unblockSend === true && process.platform !== "darwin") { + warnings.push("chatgptDesktop.unblockSend is experimental and macOS only; ignored on this platform"); + } + if (normalized.chatgptDesktop?.pacFallback === true && normalized.chatgptDesktop.unblockSend !== true) { + warnings.push("chatgptDesktop.pacFallback requires chatgptDesktop.unblockSend: true; PAC fallback is inactive"); + } const chatgptDesktopIssue = chatgptDesktopConfigIssue(rawParsed); if (chatgptDesktopIssue) warnings.push(`${chatgptDesktopIssue}; the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`); warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); @@ -689,8 +695,13 @@ function skillsConfigError(value: unknown): string | null { export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; - if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { - return { ok: false, error: "schema_invalid: chatgptDesktop: requires an optional boolean appServerShim and no other fields" }; + if (chatgptDesktop !== undefined) { + const parsed = chatgptDesktopSchema.safeParse(chatgptDesktop); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = issue?.path.map(String).join("."); + return { ok: false, error: `schema_invalid: chatgptDesktop${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}` }; + } } const compactionRouting = rawConfigRecord(value)?.compactionRouting; if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { diff --git a/src/config/schema/chatgpt-desktop.ts b/src/config/schema/chatgpt-desktop.ts index fe1e9ff77bb..e12e6fc2a38 100644 --- a/src/config/schema/chatgpt-desktop.ts +++ b/src/config/schema/chatgpt-desktop.ts @@ -1,14 +1,22 @@ import * as z from "zod/v4"; import { redactSecretString } from "../../lib/redact"; -/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ -export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); +/** + * Experimental macOS ChatGPT desktop integration: the app-server shim, the send-unblock intercept, + * and its PAC-fallback launch mode. + */ +export const chatgptDesktopSchema = z.object({ + appServerShim: z.boolean().optional(), + unblockSend: z.boolean().optional(), + pacFallback: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), +}).strict(); /** * Why a present `chatgptDesktop` block fails the schema, or null when it is valid or absent. The * read path degrades a failing block to absent, so the integration reads as off while the file * still says `appServerShim: true`; this names the field so that is never silent. A leftover key - * from an older or ported config (for example `unblockSend`) is the usual cause. + * from an older or ported config (for example a misspelled `unblockPort`) is the usual cause. */ export function chatgptDesktopConfigIssue(rawParsed: unknown): string | null { if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) return null; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index e53f721e76a..efa821df9c0 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -93,6 +93,7 @@ export const protocolConfigSchema = z.object({ }).strict(); export const configSchema = z.object({ + // A malformed desktop leaf disables its optional integrations on read; writes validate strictly. chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined), codexNativeSteering: z.boolean().optional().catch(false), codexNativeInjection: z.boolean().optional().catch(false), diff --git a/src/lib/socks5-handshake.ts b/src/lib/socks5-handshake.ts index a917af02e00..7fc1966d70b 100644 --- a/src/lib/socks5-handshake.ts +++ b/src/lib/socks5-handshake.ts @@ -1,11 +1,12 @@ /** - * The SOCKS5 handshake both raw outbound transports share: method negotiation + * The SOCKS5 handshake raw outbound transports share: method negotiation * (RFC 1928), the optional RFC 1929 username/password subnegotiation, and the * CONNECT exchange. * - * `src/lib/socks5-fetch.ts` (the HTTP tunnel) and - * `src/lib/desktop-upstream-tunnel.ts` (the native desktop relay's raw - * dial) each own their socket, their timeouts, and what happens after CONNECT; + * `src/lib/socks5-fetch.ts` (the HTTP tunnel), + * `src/lib/desktop-upstream-tunnel.ts` (the native desktop relay), and + * `src/chatgpt/desktop-unblock/ws-upstream.ts` (the ChatGPT intercept relay) + * each own their socket, their timeouts, and what happens after CONNECT; * this module only frames bytes through the reader each hands it, so a * credentialed `socks5://` proxy URL authenticates identically whether the caller * is a fetch or a raw WebSocket upgrade. When the URL carries credentials the @@ -27,6 +28,7 @@ const SOCKS5_SUCCESS = 0x00; /** RFC 1929 credentials decoded from the proxy URL, each bounded to one length byte. */ export interface Socks5Credentials { username?: Uint8Array; + /** Omission preserves the empty-password bytes produced by a username-only proxy URL. */ password?: Uint8Array; } @@ -77,13 +79,14 @@ export async function socks5Handshake( reader.write(Buffer.from([SOCKS5_VERSION, methods.length, ...methods])); const greeting = await reader.readExact(2, signal); if (greeting[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid greeting"); - if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { + if (greeting[1] === SOCKS5_USER_PASS && credentials.username) { + const password = credentials.password ?? new Uint8Array(0); reader.write(Buffer.from([ 0x01, credentials.username.byteLength, ...credentials.username, - credentials.password.byteLength, - ...credentials.password, + password.byteLength, + ...password, ])); const auth = await reader.readExact(2, signal); if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5HandshakeError("SOCKS5 proxy authentication failed"); diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts new file mode 100644 index 00000000000..1d30aab4068 --- /dev/null +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -0,0 +1,90 @@ +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; + +/** + * Owns the ChatGPT desktop send-unblock listener on behalf of `startServer`. The listener is + * an optional integration: a bind failure degrades to a warning, never to a startup failure, + * because every other duty keeps working without it. `startServer` stays synchronous, so the + * start is fire-and-forget and `stop()` awaits whatever it produced. Each instance is + * single-shot: the first start owns its configuration and promise, later starts are no-ops, + * and stopping (even before starting) permanently closes it. Repeated stops join one shutdown. + * + * The intercept modules (TLS listener, local CA, WebSocket relay, launch watcher) load only when + * the feature is enabled. A default install starts the server without evaluating any of them, + * the same way the other optional subsystems stay off the core startup path. + * + * Stopping cannot take the resolver switch back out of a running ChatGPT app, so a stop that + * leaves the app routed at the now-closed port says so and names the way back to native + * networking. + */ +export interface ChatgptUnblockLifecycle { + start(options: StartChatgptUnblockOptions): void; + stop(): Promise; +} + +/** Mirrors `chatgptUnblockEnabled` without loading the runtime module to ask. */ +function unblockRequested(config: Pick): boolean { + return process.platform === "darwin" && config.runtimeRole !== "client" && config.chatgptDesktop?.unblockSend === true; +} + +export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { + let pending: Promise | null> = Promise.resolve(null); + let started = false; + let stopping: Promise | undefined; + let pacMode = false; + return { + start(options) { + // Latch before the feature gate: a disabled or concurrent start must not replace + // the promise whose eventual listener this instance is responsible for stopping. + if (started || stopping) return; + started = true; + if (!unblockRequested(options.config)) return; + // Mirrors `chatgptPacFallbackEnabled`, which implies the request checked above. + pacMode = options.config.chatgptDesktop?.pacFallback === true; + pending = import("../../chatgpt/desktop-unblock/runtime") + .then(runtime => runtime.startChatgptUnblock(options)) + .then(handle => { + if (handle) { + console.log(`🔓 ChatGPT send-unblock active on https://127.0.0.1:${handle.port} (CA: ${handle.caCertPath})`); + if (handle.entryProxy) { + const route = handle.pacRoute === "system-pac" ? "the system PAC" : "the system proxy chain"; + console.log(` PAC fallback on http://127.0.0.1:${handle.entryProxy.port} (other hosts, and chatgpt.com while opencodex is down, follow ${route})`); + } + console.log(" Launch the ChatGPT app with: ocx chatgpt launch (or `ocx chatgpt install-watcher` for Dock launches)"); + } + return handle; + }) + .catch((error: unknown) => { + console.warn(`⚠ ChatGPT send-unblock could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + stop() { + return stopping ??= pending.then(async handle => { + if (!handle) return; + await handle.stop(); + await warnIfAppStillRouted(handle.port, pacMode); + }); + }, + }; +} + +async function warnIfAppStillRouted(port: number, pacMode: boolean): Promise { + if (process.platform !== "darwin") return; + try { + const { chatgptAppCommandLine, chatgptCommandLineHasPac, chatgptCommandLineHasRule } = await import("../../chatgpt/desktop-unblock/launch-watcher"); + const app = chatgptAppCommandLine(); + if (app === null) return; + if (pacMode) { + if (!chatgptCommandLineHasPac(app, getConfigDir())) return; + console.warn("⚠ The ChatGPT app is still launched with opencodex's PAC; its chatgpt.com traffic now follows the captured system policy and fails if that route is unavailable."); + console.warn(" To return it to native networking: ocx chatgpt restore"); + return; + } + if (!chatgptCommandLineHasRule(app, port)) return; + console.warn(`⚠ The ChatGPT app is still routed to port ${port}; its chatgpt.com requests fail until opencodex listens again.`); + console.warn(" To return it to native networking: ocx chatgpt restore"); + } catch { // no-excuse-ok: catch -- a diagnostic at shutdown must never fail the stop itself. + } +} diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index b62c1eb65cd..d5759d4b730 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -1,3 +1,4 @@ +import { createChatgptUnblockLifecycle } from "./chatgpt-unblock-lifecycle"; import { serviceApiTokenFingerprint } from "../../lib/service-secrets"; import type { Server } from "bun"; import type { OcxConfig } from "../../types"; @@ -49,6 +50,7 @@ export interface OptionalListenerSet { export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): OptionalListenerSet { const claudeIntercept: ClaudeInterceptLifecycle = createClaudeInterceptLifecycle(); + const chatgptUnblock = createChatgptUnblockLifecycle(); const linkListener: LinkListenerLifecycle = createLinkListenerLifecycle(linkDeps); let activeConfig: OcxConfig | undefined; const supervisor = createLinkSupervisor({ @@ -114,6 +116,7 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O maxRequestBodySize: ctx.maxRequestBodySize, dispatch: ctx.dispatch, }); + chatgptUnblock.start({ config: ctx.config, publicPort: ctx.publicPort }); }, ensureStarted: () => linkListener.ensureStarted(), close: () => linkListener.close(), @@ -130,6 +133,7 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O } try { await linkListener.stop(); } catch (error) { failure ??= error; } try { await claudeIntercept.stop(); } catch (error) { failure ??= error; } + try { await chatgptUnblock.stop(); } catch (error) { failure ??= error; } unregisterSupervisorAdmission?.(); unregisterSupervisorAdmission = undefined; if (failure) throw failure; diff --git a/src/types/config.ts b/src/types/config.ts index c5d86ac2e89..a4d71dae7b9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -513,8 +513,11 @@ export interface OcxClientConnectionConfig { } export interface OcxConfig { - /** Experimental macOS app-server stdout shim; absent or false disables it. */ - chatgptDesktop?: { appServerShim?: boolean }; + /** + * Independent experimental macOS shim/intercept flags, default off; optional fixed TLS port. + * `pacFallback` launches the intercepted app with a generated PAC instead of a resolver rule. + */ + chatgptDesktop?: { appServerShim?: boolean; unblockSend?: boolean; pacFallback?: boolean; port?: number }; port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ runtimeRole?: OcxRuntimeRole; diff --git a/structure/INDEX.md b/structure/INDEX.md index 83b95545d6d..c325f4fc3e2 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -99,7 +99,7 @@ The dashboard, the management API, and third-party client config ownership. | [`pricing.md`](pricing.md) | User price policies, prompt-length rate selection, persistence and estimate cache invalidation. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | | [`clients/codex-desktop.md`](clients/codex-desktop.md) | Windows package activation, restart context, and protected compatibility authority persistence. | -| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | +| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim and local-CA TLS intercept, opt-in launch/watcher, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. | | [`clients/qoder.md`](clients/qoder.md) | Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules. | @@ -193,6 +193,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lib/` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-spend.md`](transports/responses-spend.md)
[`transports/inventory.md`](transports/inventory.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/integrations.md`](clients/integrations.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/lib/advisor-activation.ts` | [`advisor.md`](advisor.md) | | `src/lib/gui-pair-intent.ts` | [`remote-link.md`](remote-link.md) | +| `src/lib/socks5-handshake.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/lib/windows-owner-acl.ts` | [`remote-link.md`](remote-link.md) | | `src/link/` | [`remote-link.md`](remote-link.md) | | `src/messaging/` | [`local-messaging.md`](local-messaging.md) | @@ -223,6 +224,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | +| `src/server/index/chatgpt-unblock-lifecycle.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | +| `src/server/index/optional-listeners.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/server/management/` | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | | `src/server/management/anthropic-pool-settings.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index dca30b1d079..63bd4219fd7 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -1,9 +1,10 @@ -# ChatGPT Desktop app-server shim +# ChatGPT Desktop integrations The experimental macOS integration is owned by `src/chatgpt/` and exposed through -`src/cli/chatgpt-command.ts`. It is default off and requires -`chatgptDesktop.appServerShim === true` for an explicit launch. Its config leaf -accepts only an optional boolean; malformed reads disable the leaf, while live +`src/cli/chatgpt-command.ts`. It is default off; an explicit launch requires +`chatgptDesktop.appServerShim === true` or `chatgptDesktop.unblockSend === true`, and only the +shim launcher requires `appServerShim`. The strict config leaf accepts optional boolean `appServerShim`, `unblockSend` and `pacFallback` +flags and an optional integer `port` in 1..65535; malformed reads disable the leaf, while live writes reject malformed values and unknown fields. The launcher under the config directory re-enters the current CLI using @@ -66,13 +67,108 @@ are unknown rather than evidence of an absent app. Launch preflights process ins before writing the launcher; both operations refuse to open when exit cannot be confirmed. Restore keeps the launcher on those failures. A later probe may fail after launch has atomically updated it; this is not a transaction spanning the launcher and app restart. -Other platforms reject all three operations. +Other platforms reject the public operations. -The launcher and its executable paths are local code-execution inputs. This -integration installs no network listener, CA, PAC or background watcher, and -starts no proxy timer. Explicit launches are its only activation point. +The launcher and its executable paths are local code-execution inputs. The shim alone +installs no network listener, CA or background watcher and starts no proxy timer. +Explicit launches are its only activation point. Tests cover rewriting, byte framing, passthrough degradation, source/compiled launcher text, stub launcher execution, hidden preflight, and strict config writes in tests/clients/desktop-*.test.ts. Pipe-crash behavior is mock evidence only; no bundled-app respawn guarantee is asserted. + +## Optional local-CA send-unblock intercept + +`src/chatgpt/desktop-unblock/runtime.ts` enables the macOS listener only for +`chatgptDesktop.unblockSend === true` outside the client runtime role. The +independent `appServerShim` flag does not enable it. The TLS listener binds +127.0.0.1 on the explicit desktop port or public port + 200; an overflowing +port is rejected before creating a CA. It reuses the Claude local authority, +issues a leaf for chatgpt.com and never installs OS trust. The operator manually +adds that authority to the login keychain; this permits TLS termination of the +account's credentials and message traffic. Restore leaves the shared CA and +keychain trust in place. + +`src/server/index/chatgpt-unblock-lifecycle.ts` owns the pending startup promise +and awaited stop. Each lifecycle is single-shot: the first start owns the promise and configuration, +subsequent starts do nothing, and all stops share one shutdown, including a stop during startup. +Stopping before startup permanently closes the instance. `src/server/index/optional-listeners.ts` starts it without +suspending startServer; startup failures warn and keep other services available. +Shutdown warns when the app still carries the resolver rule for the closed port, or opencodex's PAC +in PAC-fallback mode. +The three core entry points do not reach the new optional modules. The activation +window rationale in `tests/lab/core-lab-boundary.test.ts` records the synchronous +composition-root boundary. + +`src/chatgpt/desktop-unblock/listener.ts` forwards HTTP to the fixed chatgpt.com +upstream without following redirects. Only the conversation and usage surfaces +selected by `rewrite.ts` are rewritten. Conversation send blocks with known quota +reasons and exhausted send progress entries are removed; unknown non-quota blocks +remain. Usage gate rewriting imports the existing app-server shim gate owner +instead of maintaining a duplicate. Display data remains unchanged. JSON bodies +above the bounded rewrite ceiling stream through. SSE lines preserve untouched +framing. A local identity endpoint reports preserved block names/reasons and +timestamps; request bodies and credentials are not logged. + +`src/chatgpt/desktop-unblock/ws-relay.ts`, `ws-frame.ts` and `ws-upstream.ts` +relay upgrades and voice/dictation messages without rewriting. Upstream TLS +verification remains enabled; direct, HTTP(S) CONNECT and SOCKS5 routes follow +opencodex's outbound proxy selection. The shared method/auth/CONNECT framing +owner is `src/lib/socks5-handshake.ts`, also used by `src/lib/socks5-fetch.ts`. +Each transport still owns its socket, timeout, buffering and post-CONNECT work. +HTTP CONNECT sends Basic proxy authorization when either URL credential is present; invalid percent +encoding fails before CONNECT is written and closes the proxy socket. SOCKS username-only credentials +retain the shared empty-password compatibility behavior. + +`src/chatgpt/desktop-unblock/launch-watcher.ts` owns resolver/system-proxy and PAC launch +arguments and the optional launchd script. The watcher has no shim mode. Its launchd agent wakes on the app's `SingletonLock` and on the +`chatgpt-unblock.ready` marker that `startChatgptUnblock` rewrites once the listener is up, so an +app that opened before opencodex is still routed. Plist freshness compares both watched paths against +the installed generation; a legacy lock-only plist is stale. Installation records the discovered absolute +bundle path. Before quit/open, `src/chatgpt/desktop-unblock/bundle-preflight.ts` rechecks that path using +the shared ownership, ancestor and strict OpenAI-signature policy plus the `com.openai.codex` identifier. +The generated script re-enters the current source/compiled CLI through a hidden preflight command; +a missing runtime or failed validation leaves the app untouched. Process matching, AppleScript quit, +and open all use the recorded bundle path, including paths containing spaces or quotes. +Each run authenticates the listener against the published local CA and `chatgpt.com` hostname before +checking its service identity; missing/invalid CA material fails closed without generating or trusting a CA. +The direct probe pins loopback and bypasses environment proxies. Each run leaves native +launches alone while the intercept is down, and serializes corrective restarts; an explicit launch +that meets another run's lock fails with a message instead of reporting success. In watch mode it +restarts only an app no more than five minutes old (`ps -o etime=`); a missing or unparseable age +counts as fresh, and explicit launch is not age-limited. A failed `open` exits non-zero rather +than printing success. +Explicit CLI launch can supply the existing shim's launcher environment along +with intercept arguments. A loaded watcher must be uninstalled before CLI +restore; this guard queries launchd directly without reading installed watcher files or rendering a +script. The CLI preserves shim-only launch without a running proxy and reports +intercept port, trust, listener identity, watcher freshness and app switches. + +PAC fallback (`chatgptDesktop.pacFallback`, effective only when `unblockSend` is enabled) replaces the resolver rule so +the app keeps a route while opencodex is down. `src/chatgpt/desktop-unblock/entry-proxy.ts` binds a +CONNECT entry normally on the listener port + 1 (65534 when the listener port is 65535) +that splices `chatgpt.com:443` onto the TLS listener; +`pac.ts` reads the scutil proxy chain and any system PAC; `runtime.ts` rewrites +`chatgpt-unblock.pac` at every start, before the readiness marker, embedding the system PAC only +while the base64 `data:` switch stays within 512 KiB. An unreadable system proxy configuration, +unreadable/oversized system PAC, entry bind or PAC write failure releases both listeners and fails +this optional integration's startup. An explicit system proxy chain receives no synthetic DIRECT: +proxy exhaustion fails the request; only a no-proxy/TUN configuration uses normal DIRECT. +Automatic proxy discovery cannot be captured and also fails PAC-mode startup. Embedded system +PACs retain non-mandatory Chromium semantics, including direct fallback on invalid results or +execution failure; that mode does not establish a fail-closed policy. The script launches with the inline PAC switch alone, requires the entry +to complete a real CONNECT and authenticated TLS identity exchange. A mere TCP connection, empty +response, foreign authority, wrong hostname or curl failure is not readiness. Both curl probes disable +curlrc overrides, and the CONNECT probe forces its explicit loopback proxy despite NO_PROXY. After deciding a launch is needed, it prepares +all launch arguments before quitting the app, rejecting missing, unreadable, non-file or empty PAC +input and failed encoding. The same prepared PAC snapshot is passed to open; a failed preflight +leaves the running app untouched. `native` mode requires no PAC file and undoes any PAC switch, +inline or the older `file://` form. Resolver-rule launches also reject unreadable/invalid system +proxy configuration before quitting the app and never append `direct://` to an explicit proxy. +Native restore does not depend on reading the proxy configuration. + +Intercept regression files use the existing `tests/clients/` domain alongside +the shim tests; shared handshake coverage is `tests/lib/socks5-handshake.test.ts`. +Both explicit layout maps register every added test basename. No duplicate app-server shim is part +of this integration; the PAC generator and entry proxy run only with `pacFallback`. diff --git a/structure/config.md b/structure/config.md index a2bc0723340..a920dd94932 100644 --- a/structure/config.md +++ b/structure/config.md @@ -597,4 +597,4 @@ so wrong types and unknown nested fields are rejected rather than silently saved `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. -Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts only optional boolean `appServerShim`, default off. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. `src/config/diagnostics.ts` reports the reason a whole `chatgptDesktop` block is dropped on both normal and salvaged reads; salvage retains its fallback source and schema error. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. A malformed `asideProfileSync` still loads as `{ allProfiles: false }`, which turns every Aside profile off, so `warnDegradedTopLevelOptIns` in `src/config/load-degrade.ts` now reports it once per load without echoing a key or value. +Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts optional boolean `appServerShim`, `unblockSend` and `pacFallback` flags, all default off, and an optional integer `port` in 1..65535. `pacFallback` takes effect only with `unblockSend`; valid file diagnostics warn non-fatally when PAC fallback is true but send unblock is omitted or false, preserving the configured flags and unrelated settings. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. `src/config/diagnostics.ts` reports the reason a whole `chatgptDesktop` block is dropped on both normal and salvaged reads; salvage retains its fallback source and schema error. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). `claudeCode.subagentModelForce` is an optional safe roster-style id. Invalid hand edits degrade with a warning without losing other config; stale but syntactically valid targets remain stored for repair and are skipped at launch. Management force updates are field-scoped against current disk state, preserving concurrent Claude sibling edits. A config absent at request start uses canonical create-only initialization; a racing file or invalid initial state refuses the save, and an existing-file mutation never recreates deleted config. Clearing removes only that leaf. A malformed `asideProfileSync` still loads as `{ allProfiles: false }`, which turns every Aside profile off, so `warnDegradedTopLevelOptIns` in `src/config/load-degrade.ts` now reports it once per load without echoing a key or value. diff --git a/structure/manifest.json b/structure/manifest.json index 883dc88f14f..fc2a50dfbe2 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -637,11 +637,14 @@ "path": "clients/chatgpt-desktop.md", "tier": 5, "title": "ChatGPT Desktop", - "scope": "Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries.", + "scope": "Experimental macOS app-server stdout shim and local-CA TLS intercept, opt-in launch/watcher, restore and failure boundaries.", "documents": [ "src/chatgpt/", "src/cli/", - "src/codex/" + "src/codex/", + "src/server/index/chatgpt-unblock-lifecycle.ts", + "src/server/index/optional-listeners.ts", + "src/lib/socks5-handshake.ts" ] }, { diff --git a/structure/runtime.md b/structure/runtime.md index dc720c31f82..c3852277d9e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -237,7 +237,7 @@ The `hub-link` socket is HTTP-only and default-denies all but the fixed data rou usage, and `GET /readyz`; every `Upgrade` header, management, GUI, session, health, and unknown `/v1/*` route is rejected before dispatch. Its `opencodex-link.invalid` policy admits only configured key ids recorded by `links.json`, never the environment token. Context relays rebuild that policy for their post-body admission check, so key revocation stops an in-flight request before dispatch. `ensureStarted()` is single-flight, -final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener and the Claude intercept pair. +final deletion closes the listener, and `src/server/index/optional-listeners.ts` runs supervisor teardown before closing this listener, the Claude intercept pair and the optional ChatGPT send-unblock listener. ### Claude intercept pair At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair @@ -597,4 +597,4 @@ registration succeeds. Busy lease diagnostics name the recorded PID, current liv Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). -Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. Generic OAuth conversation ownership follows [the generic account-pool contract](providers/generic-account-pool.md#conversation-affinity). +Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The live main-account policy projection is fetched from the attested account-list management API. `src/cli/status-oauth.ts` renders its state and effective thresholds with an optional outside-usage advisory; `src/cli/status.ts` includes the same whitelisted projection in `ocx status --json`. No policy state is inferred from the CLI process's local quota cache when the live read is unavailable. The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter and read-only watcher bundle preflight remain hidden from public capability discovery. The optional send-unblock listener joins synchronous startup and awaited shutdown through `src/server/index/chatgpt-unblock-lifecycle.ts`, starts only for `chatgptDesktop.unblockSend === true` outside the client role, owns one startup and one shared shutdown per lifecycle instance, and degrades failures to warnings; its CA trust and credential-relay boundaries follow [ChatGPT Desktop](clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). Generic OAuth conversation ownership follows [the generic account-pool contract](providers/generic-account-pool.md#conversation-affinity). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index d892a8ddd3e..51fee242010 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -29,6 +29,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | +| Shared SOCKS5 handshake | `src/lib/socks5-handshake.ts`, `src/lib/socks5-fetch.ts`, `src/lib/desktop-upstream-tunnel.ts`, `src/chatgpt/desktop-unblock/ws-upstream.ts` | Fetch tunnels, the native desktop relay and the optional ChatGPT intercept WebSocket relay share method negotiation, bounded URL-credential decoding, RFC 1929 authentication and CONNECT framing. Callers own socket disposal, deadlines and bytes after CONNECT; the handshake imports no optional integration. | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | | Responses custom-tool preview | `src/bridge/sse.ts`, `src/server/responses-custom-tool-repair.ts`, `src/responses/progressive-freeform-input.ts`, `src/responses/freeform-wrapper-scan.ts` | Direct adapter events and routed function restoration share one progressive wrapper decoder over one bounded JSON classification of the prefix, so property order and escaped key spellings preview as the wrapper completion unwraps them. Fence-shaped `exec`/`apply_patch` prefixes stay held until authoritative completion normalization, while each caller retains its own patch-envelope and byte-budget policy. | | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | @@ -320,9 +321,12 @@ and `tests/server/account-pool-management-api.test.ts`. ## SOCKS5 dispatch boundary -`src/lib/socks5-handshake.ts` shares RFC1928/RFC1929 framing between the fetch tunnel and -native desktop raw TLS dial. Credential decoding is bounded and failures retain the fetch +`src/lib/socks5-handshake.ts` shares RFC1928/RFC1929 framing among the fetch tunnel, native +desktop raw TLS dial and optional ChatGPT intercept WebSocket relay. Credential decoding is bounded and failures retain the fetch transport's public `Socks5FetchError` contract. This extraction reuses lcxhh521's PR5947 work. +A handshake caller's omitted password produces the same empty-password bytes as a +username-only proxy URL; the proxy's authentication reply still decides acceptance. +`tests/lib/socks5-handshake.test.ts` pins both the wire bytes and refusal before CONNECT. HTTPS tunnels in `src/lib/socks5-fetch.ts` give the TLS handshake its own absolute 30-second deadline after SOCKS CONNECT. Successful TLS clears that deadline before @@ -337,7 +341,9 @@ mutates proxy environment keys. Anthropic token-host DNS failure proves an unsen only when both this immutable startup snapshot and the current environment are proxy-free. `src/server/responses/fetch-helpers.ts` routes the built-in HTTP executor through configured outbound fetch, preserving physical-send admission and dispatch override. -Native WebSocket selection stays on HTTP SSE while SOCKS5 is configured. +Native provider WebSocket selection stays on HTTP SSE while SOCKS5 is configured. +The optional ChatGPT intercept has a separate raw upgrade relay whose SOCKS5 CONNECT +shares `src/lib/socks5-handshake.ts` with fetch; see [ChatGPT Desktop](../clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). Proxy-selected discovery peers remain unpinnable, and private destinations still require explicit private-network permission plus NO_PROXY before direct transport. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index db53fc7fd0b..87098dc4147 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -199,6 +199,24 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]); }); } + test("restore refuses while the intercept watcher is loaded, before quit or open", () => { + // The watcher would put the intercept switches straight back on the relaunched app. + const result = run({ flag: false, missingBinary: true, running: true, watcherLoaded: true }); + expect(result.code).toBe(1); + expect(result.launcherExists).toBe(true); + expect(result.stderr).toContain("Uninstall the launch watcher"); + expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]); + }); + for (const watcherLoaded of [false, true]) { + test(`restore checks launchd without reading watcher files (loaded=${watcherLoaded})`, () => { + const result = run({ flag: false, missingBinary: true, running: true, watcherLoaded, watcherFilesUnreadable: true }); + expect(result.code).toBe(watcherLoaded ? 1 : 0); + expect(result.launcherExists).toBe(watcherLoaded); + expect(result.calls.some(call => call.command === "/usr/bin/open")).toBe(!watcherLoaded); + if (watcherLoaded) expect(result.stderr).toContain("Uninstall the launch watcher"); + expect(result.stderr).not.toContain("cannot be read"); + }); + } for (const sub of ["launch", "restore"]) { for (const scenario of [ { pgrepFails: true }, { pgrepFails: true, pgrepTimeout: true }, @@ -276,9 +294,9 @@ describe("restore validates the discovered app before any quit or open", () => { expect(result.calls).toEqual([]); }); test("status names a dropped chatgptDesktop block next to the off flag", () => { - const result = run({ sub: "status", configRaw: JSON.stringify({ chatgptDesktop: { appServerShim: true, unblockSend: true } }) }); + const result = run({ sub: "status", configRaw: JSON.stringify({ chatgptDesktop: { appServerShim: true, unblockPort: true } }) }); expect(result.code).toBe(0); - expect(result.stdout).toContain('app-server shim (experimental): off (config.json chatgptDesktop: Unrecognized key: "unblockSend"'); + expect(result.stdout).toContain('app-server shim (experimental): off (config.json chatgptDesktop: Unrecognized key: "unblockPort"'); expect(result.stdout).toContain("the whole chatgptDesktop block is ignored)"); }); test("status shows a plain off for a valid, absent or unreadable block", () => { @@ -292,7 +310,7 @@ describe("restore validates the discovered app before any quit or open", () => { test("a launch refused with no dropped block keeps the ordinary opt-in message", () => { const result = run({ sub: "launch", flag: false, configRaw: "{}" }); expect(result.code).toBe(1); - expect(result.stderr).toContain("set chatgptDesktop.appServerShim: true before launching"); + expect(result.stderr).toContain("Enable chatgptDesktop.appServerShim or chatgptDesktop.unblockSend before launching"); expect(result.stderr).not.toContain("whole chatgptDesktop block is ignored"); }); test("launch still requires opt-in and an app-server binary", () => { diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts index fb4f94ab22a..1f32ee01265 100644 --- a/tests/clients/desktop-chatgpt-config.test.ts +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -17,7 +17,7 @@ describe("experimental ChatGPT desktop config", () => { } }); test("malformed reads disable only the experimental feature; writes reject unknown and malformed fields", () => { - for (const chatgptDesktop of [null, true, [], "yes", { appServerShim: "true" }, { appServerShim: true, unblockSend: true }, { port: 1234 }]) { + for (const chatgptDesktop of [null, true, [], "yes", { appServerShim: "true" }, { appServerShim: true, unblocksend: true }, { port: 0 }]) { const candidate = { ...base, port: 12345, chatgptDesktop }; const parsed = configSchema.parse(candidate); expect(parsed.chatgptDesktop).toBeUndefined(); @@ -29,21 +29,21 @@ describe("experimental ChatGPT desktop config", () => { }); test("a block the read path drops says why instead of reading as silently off", () => { // A key left over from an older or ported config is the usual cause (#6196). - const leftover = { ...base, chatgptDesktop: { appServerShim: true, unblockSend: true } }; + const leftover = { ...base, chatgptDesktop: { appServerShim: true, unblockPort: true } }; expect(configSchema.parse(leftover).chatgptDesktop).toBeUndefined(); - expect(chatgptDesktopConfigIssue(leftover)).toContain("unblockSend"); + expect(chatgptDesktopConfigIssue(leftover)).toContain("unblockPort"); expect(chatgptDesktopConfigIssue({ ...base, chatgptDesktop: { appServerShim: "true" } })).toContain("chatgptDesktop.appServerShim"); for (const fine of [base, { ...base, chatgptDesktop: { appServerShim: true } }, { ...base, chatgptDesktop: {} }, null, []]) { expect(chatgptDesktopConfigIssue(fine)).toBeNull(); } const diagnostics = configDiagnosticsFromRaw(JSON.stringify(leftover)); expect(diagnostics.error).toBeNull(); - expect(diagnostics.warnings?.join(" ")).toContain("unblockSend"); + expect(diagnostics.warnings?.join(" ")).toContain("unblockPort"); expect(diagnostics.warnings?.join(" ")).toContain("the whole chatgptDesktop block is ignored"); const warn = spyOn(console, "warn").mockImplementation(() => {}); try { warnDegradedTopLevelOptIns(leftover, configSchema.parse(leftover)); - expect(warn.mock.calls.map(call => String(call[0])).join(" ")).toContain("unblockSend"); + expect(warn.mock.calls.map(call => String(call[0])).join(" ")).toContain("unblockPort"); warn.mockClear(); warnDegradedTopLevelOptIns(base, configSchema.parse(base)); expect(warn.mock.calls.length).toBe(0); @@ -56,7 +56,7 @@ describe("experimental ChatGPT desktop config", () => { const fallback = configDiagnosticsFromRaw(JSON.stringify(candidate)); expect(fallback.source).toBe("fallback"); expect(fallback.error).toContain("routingProfiles.bad"); - for (const chatgptDesktop of [{ appServerShim: true, unblockSend: true }, { appServerShim: "true" }]) { + for (const chatgptDesktop of [{ appServerShim: true, unblockPort: true }, { appServerShim: "true" }]) { const normal = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop })); const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...candidate, chatgptDesktop })); expect(normal.warnings?.join(" ")).toContain("the whole chatgptDesktop block is ignored"); @@ -75,8 +75,50 @@ describe("experimental ChatGPT desktop config", () => { expect(diagnostics.error).toBeNull(); if (process.platform !== "darwin") expect(diagnostics.warnings?.join(" ")).toContain("macOS only"); }); + for (const unblockSend of [undefined, false]) { + test(`PAC fallback warns without enabling send unblock when unblockSend is ${unblockSend}`, () => { + const chatgptDesktop = { pacFallback: true, ...(unblockSend === undefined ? {} : { unblockSend }) }; + const candidate = { ...base, port: 12345, chatgptDesktop }; + const diagnostics = configDiagnosticsFromRaw(JSON.stringify(candidate)); + expect(diagnostics.warnings?.join(" ")).toContain("chatgptDesktop.pacFallback requires chatgptDesktop.unblockSend"); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.chatgptDesktop).toEqual(chatgptDesktop); + expect(diagnostics.config.providers.xai).toEqual(base.providers.xai); + expect(diagnostics.config.defaultProvider).toBe(base.defaultProvider); + expect(diagnostics.config.port).toBe(12345); + expect(validateConfigCandidate(candidate).ok).toBe(true); + }); + } + test("enabled send unblock and disabled PAC fallback do not warn about an inactive PAC", () => { + for (const chatgptDesktop of [{ pacFallback: true, unblockSend: true }, { pacFallback: false }, { pacFallback: false, unblockSend: false }]) { + const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop })); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.chatgptDesktop).toEqual(chatgptDesktop); + expect(diagnostics.warnings?.some(warning => warning.includes("chatgptDesktop.pacFallback")) ?? false).toBe(false); + if (chatgptDesktop.unblockSend === true && process.platform !== "darwin") { + expect(diagnostics.warnings?.join(" ")).toContain("chatgptDesktop.unblockSend is experimental and macOS only"); + } + } + }); + test("invalid desktop leaves warn only about the dropped block rather than an inactive PAC", () => { + for (const chatgptDesktop of [{ pacFallback: true, unblockSend: "true" }, { pacFallback: true, unknown: true }, { pacFallback: "true" }]) { + const candidate = { ...base, chatgptDesktop }; + const diagnostics = configDiagnosticsFromRaw(JSON.stringify(candidate)); + expect(diagnostics.config.chatgptDesktop).toBeUndefined(); + expect(diagnostics.config.providers.xai).toEqual(base.providers.xai); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.warnings?.join(" ")).toContain("the whole chatgptDesktop block is ignored"); + expect(diagnostics.warnings?.some(warning => warning.includes("requires chatgptDesktop.unblockSend")) ?? false).toBe(false); + expect(validateConfigCandidate(candidate).ok).toBe(false); + } + }); test("all operations reject non-macOS without app side effects", async () => { - for (const sub of ["launch", "restore", "status"]) expect(await handleChatgptCommand([sub], "linux")).toBe(1); + for (const sub of ["launch", "restore", "status", "install-watcher", "uninstall-watcher"]) expect(await handleChatgptCommand([sub], "linux")).toBe(1); + }); + test("unexpected watcher arguments are rejected before app side effects", async () => { + expect(await handleChatgptCommand(["install-watcher", "--unknown"], "darwin")).toBe(64); + expect(await handleChatgptCommand(["uninstall-watcher", "--yes"], "darwin")).toBe(64); }); test("hidden self-test works and rejects extra options", async () => { expect(await handleInternalCommand(["chatgpt-app-server-filter", "--self-test"])).toBe(0); diff --git a/tests/clients/desktop-rewrite.test.ts b/tests/clients/desktop-rewrite.test.ts new file mode 100644 index 00000000000..b9a0c572136 --- /dev/null +++ b/tests/clients/desktop-rewrite.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test"; +import { + rewriteSurfaceFor, + stripSendBlocks, + stripSendBlocksFromJson, + stripSendBlocksFromSseLine, + unlockRateLimitGate, + type PreservedSendBlock, +} from "../../src/chatgpt/desktop-unblock/rewrite"; + +const blockedPayload = { + banner_info: { + name: "codex_limit_reached", + banner_type: "text", + resets_after: "2026-09-26T19:46:00Z", + }, + blocked_features: [ + { name: "send", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + { name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }, + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ], + limits_progress: [ + { feature_name: "send", remaining: 0, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ], + model_limits: [{ model_slug: "gpt-5-codex" }], +}; + +describe("stripSendBlocks", () => { + test("removes quota send locks, keeps eligibility blocks and quota display data", () => { + const preserved: PreservedSendBlock[] = []; + const result = stripSendBlocks(blockedPayload, preserved); + expect(result.changed).toBe(true); + const value = result.value as typeof blockedPayload; + // The usage-limit send lock goes; a work-subscription requirement is not a quota and stays. + expect(value.blocked_features).toEqual([ + { name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }, + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ]); + expect(preserved).toEqual([{ name: "tpp_send", reason: "work_subscription_required" }]); + // An exhausted `send` limit is removed; a non-exhausted one and other features stay. + expect(value.limits_progress).toEqual([ + { feature_name: "reason", remaining: 3, reset_after: "2026-09-26T19:46:00Z" }, + { feature_name: "send", remaining: 2, reset_after: "2026-09-26T19:46:00Z" }, + ]); + // Display data is untouched. + expect(value.banner_info).toEqual(blockedPayload.banner_info); + expect(value.model_limits).toEqual(blockedPayload.model_limits); + }); + + test("reports unchanged payloads and leaves non-object input alone", () => { + expect(stripSendBlocks(blockedPayload).changed).toBe(true); + expect(stripSendBlocks({ blocked_features: [] }).changed).toBe(false); + expect(stripSendBlocks({ limits_progress: [{ feature_name: "send", remaining: 1 }] }).changed).toBe(false); + expect(stripSendBlocks("text").changed).toBe(false); + expect(stripSendBlocks(null).changed).toBe(false); + }); + + test("an open quota with a non-quota send block changes nothing (preserved, not removed)", () => { + // Reproduction from review: open usage + subscription-required block must pass untouched. + const payload = { + rate_limit: { allowed: true, limit_reached: false }, + limits_progress: [{ feature_name: "send", remaining: 2 }], + blocked_features: [{ name: "tpp_send", block_reason: "work_subscription_required", resets_after: null }], + }; + const preserved: PreservedSendBlock[] = []; + expect(stripSendBlocks(payload, preserved).changed).toBe(false); + expect(preserved).toEqual([{ name: "tpp_send", reason: "work_subscription_required" }]); + }); + + test("unrecognised send-block reasons are preserved; absent or quota reasons are removed", () => { + const preserved: PreservedSendBlock[] = []; + const result = stripSendBlocks({ + blocked_features: [ + { name: "send", block_reason: "policy_violation" }, + { name: "send", block_reason: null }, + { name: "send" }, + { name: "send", block_reason: "rate_limit_exceeded" }, + { name: "send", block_reason: "quota_exhausted" }, + ], + }, preserved); + expect((result.value as { blocked_features: unknown[] }).blocked_features).toEqual([ + { name: "send", block_reason: "policy_violation" }, + ]); + expect(preserved).toEqual([{ name: "send", reason: "policy_violation" }]); + }); + + test("a workspace or credit send block is preserved even though its reason mentions a limit", () => { + const preserved: PreservedSendBlock[] = []; + const blocks = [ + { name: "send", block_reason: "workspace_owner_usage_limit_reached" }, + { name: "send", block_reason: "workspace_member_credits_depleted" }, + { name: "send", block_reason: "usage_limit" }, + ]; + const result = stripSendBlocks({ blocked_features: blocks }, preserved); + expect((result.value as { blocked_features: unknown[] }).blocked_features).toEqual(blocks.slice(0, 2)); + expect(preserved).toEqual([ + { name: "send", reason: "workspace_owner_usage_limit_reached" }, + { name: "send", reason: "workspace_member_credits_depleted" }, + ]); + }); + + test("keeps malformed entries and recurses into nested payloads", () => { + const nested = { conversation: { blocked_features: [{ name: "send" }, "junk", 7] } }; + const result = stripSendBlocks(nested); + expect(result.changed).toBe(true); + expect((result.value as typeof nested).conversation.blocked_features).toEqual(["junk", 7]); + }); +}); + +describe("stripSendBlocksFromJson", () => { + test("rewrites a conversation-init style body", () => { + const rewritten = stripSendBlocksFromJson(JSON.stringify(blockedPayload), "conversation"); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!) as typeof blockedPayload; + expect(parsed.blocked_features.map(entry => entry.name)).toEqual(["tpp_send", "image_gen"]); + expect(parsed.banner_info).toEqual(blockedPayload.banner_info); + }); + + test("each surface applies only its own rewrite", () => { + const window = { used_percent: 100 }; + const mixed = JSON.stringify({ + rate_limit: { allowed: false, limit_reached: true, primary_window: window }, + blocked_features: [{ name: "send", block_reason: "usage_limit" }], + }); + const usage = JSON.parse(stripSendBlocksFromJson(mixed, "usage")!); + expect(usage.rate_limit).toEqual({ allowed: true, limit_reached: false, primary_window: window }); + expect(usage.blocked_features).toHaveLength(1); + const conversation = JSON.parse(stripSendBlocksFromJson(mixed, "conversation")!); + expect(conversation.rate_limit).toEqual({ allowed: false, limit_reached: true, primary_window: window }); + expect(conversation.blocked_features).toEqual([]); + }); + + test("returns null for invalid JSON and clean payloads", () => { + expect(stripSendBlocksFromJson("not json")).toBeNull(); + expect(stripSendBlocksFromJson(JSON.stringify({ banner_info: null }))).toBeNull(); + }); +}); + +describe("unlockRateLimitGate", () => { + // Shape captured from a real /backend-api/wham/usage/stream snapshot event. + const usageSnapshot = { + version: 1, + stream_id: "d485cc87-f9f6-431c-9908-8b99a854e252", + sequence: 1, + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: 100, limit_window_seconds: 604800, reset_after_seconds: 205162, reset_at: 1790423160 }, + secondary_window: null, + }, + model_usage: { "gpt-6-astra": { available: false, available_at: "2026-09-26T11:46:01Z", credits_would_enable: true } }, + spend_control: { reached: false, individual_limit: null }, + rate_limit_upsell: { banner_type: "pro_rate_limit_reached", title: "Codex 和工作使用额度已用完", reset_at: 1790423160 }, + rate_limit_reset_credits: { available_count: 1, applicable_available_count: 1 }, + }, + generated_at_ms: 1790217999865, + }; + + test("flips the gate flags and keeps every display field", () => { + const value = structuredClone(usageSnapshot); + expect(unlockRateLimitGate(value)).toBe(true); + const gate = (value.usage as typeof usageSnapshot.usage).rate_limit; + expect(gate.allowed).toBe(true); + expect(gate.limit_reached).toBe(false); + // Display data is untouched. + expect(gate.primary_window).toEqual(usageSnapshot.usage.rate_limit.primary_window); + expect((value.usage as typeof usageSnapshot.usage).rate_limit_upsell).toEqual(usageSnapshot.usage.rate_limit_upsell); + expect((value.usage as typeof usageSnapshot.usage).model_usage).toEqual(usageSnapshot.usage.model_usage); + }); + + test("reports no change for open gates and unrelated payloads", () => { + const open = structuredClone(usageSnapshot); + (open.usage.rate_limit as Record).allowed = true; + (open.usage.rate_limit as Record).limit_reached = false; + expect(unlockRateLimitGate(open)).toBe(false); + expect(unlockRateLimitGate({ usage: { plan_type: "pro" } })).toBe(false); + expect(unlockRateLimitGate("text")).toBe(false); + }); + + test("handles snapshot endpoints with a top-level rate_limit", () => { + const snapshot = { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot.rate_limit.allowed).toBe(true); + expect(snapshot.rate_limit.primary_window.used_percent).toBe(100); + }); + + test("a web snapshot gate closed without plain-quota evidence stays closed", () => { + // The web snapshot spells the window `used_percent`; below 100% the payload does not show the + // subscription quota as the reason, so the flags stay as the server sent them. + const snapshot = { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 42 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot.rate_limit).toEqual({ allowed: false, limit_reached: true, primary_window: { used_percent: 42 } }); + }); + + test("drops a plain-quota rate_limit_reached_type, which the bundled app-server reads", () => { + // `codex app-server` derives its own limit-reached state from this sibling object, so the + // allowed/limit_reached flip alone left it reporting `rate_limit_reached` (#6196). + const snapshot = { + rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } }, + rate_limit_reached_type: { type: "rate_limit_reached" }, + }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot).toEqual({ rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } }); + }); + + test("leaves the gate flags closed when a workspace, credit or spend-control reason stands beside them", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted"]) { + const snapshot = { rate_limit: { allowed: false, limit_reached: true }, rate_limit_reached_type: { type } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot).toEqual({ rate_limit: { allowed: false, limit_reached: true }, rate_limit_reached_type: { type } }); + } + // The reason may sit in another branch than the flags; the whole payload counts. + const apart = { usage: { rate_limit: { allowed: false, limit_reached: true } }, rate_limit_reached_type: { type: "workspace_owner_usage_limit_reached" } }; + expect(unlockRateLimitGate(apart)).toBe(false); + expect(apart.usage.rate_limit).toEqual({ allowed: false, limit_reached: true }); + const spend = structuredClone(usageSnapshot); + (spend.usage.spend_control as Record).reached = true; + expect(unlockRateLimitGate(spend)).toBe(false); + expect(spend.usage.rate_limit.allowed).toBe(false); + expect(spend.usage.rate_limit.limit_reached).toBe(true); + }); + + test("keeps workspace and credit reached types, which are not the subscription quota", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted", "workspace_owner_credits_depleted"]) { + const snapshot = { rate_limit_reached_type: { type } }; + expect(unlockRateLimitGate(snapshot)).toBe(false); + expect(snapshot).toEqual({ rate_limit_reached_type: { type } }); + } + }); +}); + +describe("stripSendBlocksFromSseLine", () => { + test("rewrites data lines carrying send locks", () => { + const event = { type: "conversation.limit", blocked_features: [{ name: "send", block_reason: "usage_limit" }] }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).toBe("data: " + JSON.stringify({ type: "conversation.limit", blocked_features: [] })); + }); + + test("rewrites usage-stream events carrying a closed rate limit gate", () => { + const event = { + version: 1, + sequence: 1, + usage: { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }, + }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!.slice("data: ".length)) as typeof event; + expect(parsed.usage.rate_limit.allowed).toBe(true); + expect(parsed.usage.rate_limit.limit_reached).toBe(false); + expect(parsed.usage.rate_limit.primary_window.used_percent).toBe(100); + }); + + test("CRLF-framed data lines are rewritten and keep their carriage return", () => { + const event = { blocked_features: [{ name: "send", block_reason: "usage_limit" }] }; + expect(stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}\r`)).toBe('data: {"blocked_features":[]}\r'); + }); + + test("a JSON string holding U+2028 still matches the data line", () => { + const event = { note: "a\u2028b", blocked_features: [{ name: "send" }] }; + const rewritten = stripSendBlocksFromSseLine(`data: ${JSON.stringify(event)}`); + expect(JSON.parse(rewritten!.slice("data: ".length))).toEqual({ note: "a\u2028b", blocked_features: [] }); + }); + + test("passes through non-data lines, clean data and malformed JSON", () => { + expect(stripSendBlocksFromSseLine("event: conversation.limit")).toBeNull(); + expect(stripSendBlocksFromSseLine('data: {"type":"delta"}')).toBeNull(); + expect(stripSendBlocksFromSseLine("data: [partial")).toBeNull(); + expect(stripSendBlocksFromSseLine(": keep-alive")).toBeNull(); + }); +}); + +describe("rewriteSurfaceFor", () => { + test("only the composer's conversation and usage endpoints are rewritten", () => { + expect(rewriteSurfaceFor("/backend-api/conversation/init")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/f/conversation")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/f/conversation/prepare")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/conversation")).toBe("conversation"); + expect(rewriteSurfaceFor("/backend-api/wham/usage")).toBe("usage"); + expect(rewriteSurfaceFor("/backend-api/wham/usage/stream")).toBe("usage"); + }); + + test("lookalike and unrelated paths pass through", () => { + for (const path of [ + "/backend-api/conversations", + "/backend-api/conversation-history", + "/backend-api/wham/usage/thread_usage/query", + "/backend-api/wham/usage/plan_limit_history", + "/backend-api/wham/tasks/list", + "/review-fixture/not-a-composer-endpoint", + "/", + ]) expect(rewriteSurfaceFor(path)).toBeNull(); + }); +}); diff --git a/tests/clients/desktop-unblock-bundle-preflight.test.ts b/tests/clients/desktop-unblock-bundle-preflight.test.ts new file mode 100644 index 00000000000..ed6c1a442f4 --- /dev/null +++ b/tests/clients/desktop-unblock-bundle-preflight.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chatgptWatcherBundleProblem, type WatcherBundlePreflightDeps } from "../../src/chatgpt/desktop-unblock/bundle-preflight"; +import { buildChatgptUnblockWatcherScript } from "../../src/chatgpt/desktop-unblock/launch-watcher"; +import { helperPath, repoPath } from "../helpers/repo-root"; +import { maybeAutoRestoreCodexShim } from "../../src/cli/codex-shim-autorestore"; + +const ROOT = "/Applications/Other installs/ChatGPT.app"; +const deps: WatcherBundlePreflightDeps = { platform: "darwin", trustReason: () => null, bundleIdentifier: () => "com.openai.codex" }; + +describe("watcher bundle preflight", () => { + test("hidden preflight never reads config or repairs the Codex shim", () => { + const unexpected = () => { throw new Error("read-only preflight reached automatic repair"); }; + expect(() => maybeAutoRestoreCodexShim("internal", ["internal", "chatgpt-bundle-preflight", ROOT], { + env: {}, warn: unexpected, restore: unexpected, readConfig: unexpected, + })).not.toThrow(); + }); + test("reuses the shared bundle trust policy on the exact recorded path", () => { + const paths: string[] = []; + expect(chatgptWatcherBundleProblem(ROOT, { ...deps, trustReason(path) { paths.push(path); return null; } })).toBeNull(); + expect(paths).toEqual([ROOT]); + }); + for (const reason of ["is missing", "is writable by group or others", "failed code-signature verification", "is not signed by OpenAI"]) { + test(`refuses a changed bundle: ${reason}`, () => { + expect(chatgptWatcherBundleProblem(ROOT, { ...deps, trustReason: () => reason })).toBe(reason); + }); + } + for (const path of ["ChatGPT.app", "/Applications/../tmp/ChatGPT.app", "/Applications//ChatGPT.app"]) { + test(`refuses a noncanonical path: ${path}`, () => { + expect(chatgptWatcherBundleProblem(path, { ...deps, trustReason() { throw new Error("must not inspect"); } })).toContain("absolute and normalized"); + }); + } + for (const id of [null, "com.openai.chat", "com.example.codex"]) { + test(`refuses missing or substituted identity: ${id}`, () => { + expect(chatgptWatcherBundleProblem(ROOT, { ...deps, bundleIdentifier: () => id })).toContain("not com.openai.codex"); + }); + } + test("unsupported platforms fail before inspecting a bundle", () => { + expect(chatgptWatcherBundleProblem(ROOT, { ...deps, platform: "linux", trustReason() { throw new Error("must not inspect"); } })).toContain("only supported on macOS"); + }); + test("source-mode watcher re-enters the canonical CLI instead of the caller's script", () => { + const script = buildChatgptUnblockWatcherScript(10300, "/tmp/ocx-preflight"); + expect(script).toContain(repoPath("src/cli/index.ts")); + expect(script).toContain("'internal' 'chatgpt-bundle-preflight'"); + expect(script).not.toContain("open -a ChatGPT"); + expect(script).not.toContain("quit app \"ChatGPT\""); + }); +}); + +describe("CLI passes the discovered bundle to the intercept", () => { + for (const sub of ["launch", "install-watcher"]) { + test(`${sub} binds the exact discovered path`, () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-watcher-bundle-")); + try { + const child = spawnSync(process.execPath, [helperPath("desktop-app-server-shim-command-child.ts"), dir, JSON.stringify({ sub, intercept: true })], { + encoding: "utf8", timeout: 15000, env: { ...process.env, OPENCODEX_HOME: dir }, + }); + expect({ status: child.status, stderr: child.stderr }).toEqual({ status: 0, stderr: "" }); + const result = JSON.parse(child.stdout.trim().split("\n").at(-1)!); + expect(result.code).toBe(0); + expect(result.calls.find((call: { command: string }) => call.command === (sub === "launch" ? "intercept-launch" : "watcher-install"))?.args).toEqual([result.root]); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + } +}); diff --git a/tests/clients/desktop-unblock-ca-trust.test.ts b/tests/clients/desktop-unblock-ca-trust.test.ts new file mode 100644 index 00000000000..2a0c6c464c5 --- /dev/null +++ b/tests/clients/desktop-unblock-ca-trust.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { certificateSha1, chatgptCaTrustCommand, inspectChatgptCaTrust } from "../../src/chatgpt/desktop-unblock/ca-trust"; +import { createLocalInterceptCa } from "../../src/claude/intercept/local-ca"; +import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; + +let dir: string; +let caPath: string; +let sha1: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-ca-trust-")); + caPath = join(dir, "ca.pem"); + const ca = createLocalInterceptCa(); + writeFileSync(caPath, ca.certPem); + sha1 = certificateSha1(ca.certPem); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** `security trust-settings-export ` stand-in that writes the given trusted fingerprints. */ +function exporting(fingerprints: string[]): SecurityRunner { + return async args => { + expect(args[0]).toBe("trust-settings-export"); + const entries = fingerprints.map(f => `${f}trustSettings`).join(""); + writeFileSync(args[1]!, `trustList${entries}`); + return { code: 0, stdout: "", stderr: "" }; + }; +} + +test("a CA whose fingerprint has user trust settings is trusted", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting(["00".repeat(20), sha1]), "darwin")).toBe("trusted"); +}); + +test("trust for a different certificate does not count", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting(["AB".repeat(20)]), "darwin")).toBe("untrusted"); +}); + +test("a user domain with no trust settings at all is untrusted, not unknown", async () => { + const none: SecurityRunner = async () => ({ code: 1, stdout: "", stderr: "SecTrustSettingsCreateExternalRepresentation: No Trust Settings were found." }); + expect(await inspectChatgptCaTrust(caPath, none, "darwin")).toBe("untrusted"); +}); + +test("any other export failure is reported as unknown, never as trusted", async () => { + const failing: SecurityRunner = async () => ({ code: 1, stdout: "", stderr: "User interaction is not allowed." }); + expect(await inspectChatgptCaTrust(caPath, failing, "darwin")).toBe("unknown"); + const throwing: SecurityRunner = async () => { throw new Error("spawn failed"); }; + expect(await inspectChatgptCaTrust(caPath, throwing, "darwin")).toBe("unknown"); +}); + +test("a CA that was never created is reported as missing", async () => { + expect(await inspectChatgptCaTrust(join(dir, "absent.pem"), exporting([]), "darwin")).toBe("missing"); +}); + +test("other platforms are not applicable", async () => { + expect(await inspectChatgptCaTrust(caPath, exporting([sha1]), "linux")).toBe("unsupported"); +}); + +test("the restore command trusts this CA for TLS as a root in the login keychain", () => { + const command = chatgptCaTrustCommand(caPath); + expect(command).toStartWith("security add-trusted-cert -r trustRoot -p ssl -k "); + expect(command).toContain("login.keychain-db"); + expect(command).toEndWith(`"${caPath}"`); +}); diff --git a/tests/clients/desktop-unblock-config-boundary.test.ts b/tests/clients/desktop-unblock-config-boundary.test.ts new file mode 100644 index 00000000000..c0af2506eff --- /dev/null +++ b/tests/clients/desktop-unblock-config-boundary.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + getDefaultConfig, + loadConfig, + validateConfigCandidate, +} from "../../src/config"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * The write-boundary regression for the chatgptDesktop block. The read path degrades a + * malformed block to absent (`.catch(undefined)` in the schema), which is right for a + * hand-edited file — but the same silence at the write boundary would accept + * `{ unblockSend: true, port: 65536 }`, report success, and persist a config whose + * integration is silently gone. The boundary must reject instead, naming the field. + */ + +let home = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-chatgpt-desktop-config-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function candidate(chatgptDesktop: unknown) { + return { + ...getDefaultConfig(), + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + note: "keep me", + }, + }, + ...(chatgptDesktop === undefined ? {} : { chatgptDesktop }), + }; +} + +test("validateConfigCandidate rejects an out-of-range port naming the field", () => { + const result = validateConfigCandidate(candidate({ unblockSend: true, port: 65536 })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("schema_invalid: chatgptDesktop.port"); +}); + +test("validateConfigCandidate rejects a typo'd key and a wrong-typed flag", () => { + // .strict(): a typo'd key must surface as a rejected write rather than a silently + // ignored key that leaves the operator believing they enabled something. + const typo = validateConfigCandidate(candidate({ unblockSend: true, unblocksend: true })); + expect(typo.ok).toBe(false); + + const wrongType = validateConfigCandidate(candidate({ unblockSend: "yes" })); + expect(wrongType.ok).toBe(false); +}); + +test("validateConfigCandidate accepts a well-formed block and its absence", () => { + expect(validateConfigCandidate(candidate({ unblockSend: true, pacFallback: true, appServerShim: true, port: 10300 })).ok).toBe(true); + expect(validateConfigCandidate(candidate({ unblockSend: true, appServerShim: "yes" })).ok).toBe(false); + expect(validateConfigCandidate(candidate({})).ok).toBe(true); + expect(validateConfigCandidate(candidate(undefined)).ok).toBe(true); +}); + +test("a rejected write leaves the persisted config unchanged", () => { + // Seed the on-disk config with a valid block, attempt the save a caller would make + // with an invalid one, and prove the boundary verdict is what stands between them: + // the file still carries the previous valid block afterwards. + writeFileSync(getConfigPath(), JSON.stringify(candidate({ unblockSend: true })), "utf8"); + + const rejected = validateConfigCandidate(candidate({ unblockSend: true, port: 65536 })); + expect(rejected.ok).toBe(false); + // A caller that persists only on ok never rewrote the file. + const loaded = loadConfig(); + expect(loaded.chatgptDesktop).toEqual({ unblockSend: true }); + expect(loaded.providers.xai.note).toBe("keep me"); + + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as { chatgptDesktop?: unknown }; + expect(onDisk.chatgptDesktop).toEqual({ unblockSend: true }); +}); + +test("load drops only a malformed block and preserves the rest of the config", () => { + // The read path keeps its degrade-to-off behavior: a hand-edited typo costs the + // operator the desktop integration, never their providers. + writeFileSync(getConfigPath(), JSON.stringify(candidate({ unblockSend: true, port: 99999 })), "utf8"); + + const loaded = loadConfig(); + expect(loaded.chatgptDesktop).toBeUndefined(); + expect(loaded.providers.xai.note).toBe("keep me"); +}); + +test("both integrations remain independently opt-in and pacFallback is a strict boolean", () => { + for (const block of [{ unblockSend: false }, { appServerShim: true }, { unblockSend: true }, { appServerShim: true, unblockSend: true, port: 10300 }, { unblockSend: true, pacFallback: true }]) { + expect(validateConfigCandidate(candidate(block)).ok).toBe(true); + } + expect(validateConfigCandidate(candidate({ unblockSend: true, pacFallback: "yes" })).ok).toBe(false); + for (const port of [0, -1, 1.5, 65536, "10300"]) { + const result = validateConfigCandidate(candidate({ unblockSend: true, port })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("chatgptDesktop.port"); + } +}); diff --git a/tests/clients/desktop-unblock-entry-proxy.test.ts b/tests/clients/desktop-unblock-entry-proxy.test.ts new file mode 100644 index 00000000000..b4b880dbdfa --- /dev/null +++ b/tests/clients/desktop-unblock-entry-proxy.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test"; +import { connect as bunConnect, type Socket } from "bun"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { connect as connectNet, type Socket as NetSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { startChatgptUnblockEntryProxy } from "../../src/chatgpt/desktop-unblock/entry-proxy"; +import { ChatgptUnblockDiagnostics, startChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/listener"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +/** + * The entry proxy is the PAC fallback's first hop: it must splice a CONNECT tunnel onto the + * TLS origin listener so the app's request reaches the relay byte-for-byte, and refuse + * everything else so it never becomes a general forward proxy. + */ + +const ca = createLocalInterceptCa(); +const leaf = issueLocalInterceptLeaf(ca, ["chatgpt.com"]); + +interface ReadState { chunks: Buffer[]; waiters: ((data: Buffer | null) => void)[]; done: boolean } +const readers = new WeakMap(); + +/** A raw client socket whose received bytes can be awaited incrementally. */ +async function dial(port: number, tls: { ca: string; servername: string } | null = null): Promise { + const socket = await bunConnect({ + hostname: "127.0.0.1", + port, + ...(tls ? { tls: { ca: tls.ca, servername: tls.servername } } : {}), + data: { chunks: [], waiters: [], done: false }, + socket: { + data(_s, chunk) { + const state = _s.data; + state.chunks.push(Buffer.from(chunk)); + const waiting = state.waiters.shift(); + if (waiting) waiting(Buffer.from(chunk)); + }, + close(_s) { + const state = _s.data; + state.done = true; + for (const waiter of state.waiters.splice(0)) waiter(null); + }, + error() { /* close follows */ }, + }, + }); + readers.set(socket, socket.data); + return socket; +} + +async function received(socket: Socket, atLeast: number): Promise { + const state = readers.get(socket)!; + const total = () => state.chunks.reduce((sum, c) => sum + c.length, 0); + while (total() < atLeast && !state.done) { + await new Promise(resolve => state.waiters.push(resolve)); + } + return Buffer.concat(state.chunks); +} + +describe("chatgpt unblock entry proxy", () => { + test("a wrong-host CONNECT is refused with 403", async () => { + const diagnostics = new ChatgptUnblockDiagnostics(); + const origin = startChatgptUnblockListener({ leaf, diagnostics }); + const entry = await startChatgptUnblockEntryProxy({ originPort: origin.port! }); + try { + const client = await dial(entry.port); + client.write(`CONNECT evil.example:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n`); + const head = await received(client, "HTTP/1.1 403".length); + expect(head.toString("latin1")).toContain("HTTP/1.1 403 Forbidden"); + } finally { + await entry.stop(); + await origin.stop(true); + } + }); + + test("a plain GET (no CONNECT) is refused", async () => { + const diagnostics = new ChatgptUnblockDiagnostics(); + const origin = startChatgptUnblockListener({ leaf, diagnostics }); + const entry = await startChatgptUnblockEntryProxy({ originPort: origin.port! }); + try { + const client = await dial(entry.port); + client.write(`GET http://chatgpt.com/ HTTP/1.1\r\nHost: chatgpt.com\r\n\r\n`); + const head = await received(client, "HTTP/1.1 403".length); + expect(head.toString("latin1")).toContain("403"); + } finally { + await entry.stop(); + await origin.stop(true); + } + }); + + test("a dead origin answers 502 instead of hanging the client", async () => { + // Port 1 on loopback: nothing listens there. + const entry = await startChatgptUnblockEntryProxy({ originPort: 1 }); + try { + const client = await dial(entry.port); + client.write(`CONNECT chatgpt.com:443 HTTP/1.1\r\nHost: chatgpt.com:443\r\n\r\n`); + const head = await received(client, "HTTP/1.1 502".length); + expect(head.toString("latin1")).toContain("502"); + } finally { + await entry.stop(); + } + }); +}); + +/** Read one HTTP head off a raw socket, leaving the socket ready for a TLS upgrade. */ +function readHead(socket: NetSocket): Promise { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + socket.removeListener("data", onData); + socket.removeListener("error", reject); + socket.pause(); + resolve(buffer.subarray(0, end).toString("latin1")); + }; + socket.on("data", onData); + socket.once("error", reject); + }); +} + +/** Split a raw HTTP/1.1 response into head and (de-chunked) body. */ +function httpResponse(raw: Buffer): { head: string; body: Buffer } { + const end = raw.indexOf("\r\n\r\n"); + const head = raw.subarray(0, end).toString("latin1"); + let rest = raw.subarray(end + 4); + if (!/^transfer-encoding:[ \t]*chunked/im.test(head)) return { head, body: rest }; + const parts: Buffer[] = []; + for (;;) { + const lineEnd = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.subarray(0, lineEnd).toString("latin1"), 16); + if (!size) break; + parts.push(rest.subarray(lineEnd + 2, lineEnd + 2 + size)); + rest = rest.subarray(lineEnd + 2 + size + 2); + } + return { head, body: Buffer.concat(parts) }; +} + +/** CONNECT through the entry, then run one HTTPS request over the tunnel the way Chromium does. */ +async function requestThroughTunnel(entryPort: number, path: string, options: { pauseMs?: number; idleMs?: number } = {}) { + const raw = connectNet({ host: "127.0.0.1", port: entryPort }); + await once(raw, "connect"); + raw.write("CONNECT chatgpt.com:443 HTTP/1.1\r\nHost: chatgpt.com:443\r\n\r\n"); + const established = await readHead(raw); + if (options.idleMs) await Bun.sleep(options.idleMs); + const tls = connectTls({ socket: raw, servername: "chatgpt.com", ca: ca.certPem }); + await once(tls, "secureConnect"); + tls.write(`GET ${path} HTTP/1.1\r\nHost: chatgpt.com\r\nConnection: close\r\n\r\n`); + const chunks: Buffer[] = []; + let paused = false; + const done = new Promise((resolve, reject) => { + tls.on("data", (chunk: Buffer) => { + chunks.push(chunk); + if (options.pauseMs && !paused) { + // A slow app-side reader: the tunnel's socket buffers fill while this side is paused. + paused = true; + tls.pause(); + setTimeout(() => tls.resume(), options.pauseMs); + } + }); + tls.once("end", resolve); + tls.once("error", reject); + }); + await done; + tls.destroy(); + return { established, ...httpResponse(Buffer.concat(chunks)) }; +} + +const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +describe("chatgpt unblock entry proxy tunnel", () => { + test("a multi-MiB TLS response reaches a slow reader byte for byte", async () => { + const payload = Buffer.alloc(8 * 1024 * 1024); + for (let offset = 0; offset < payload.length; offset += 4) payload.writeUInt32LE(offset, offset); + const origin = startChatgptUnblockListener({ + leaf, + diagnostics: new ChatgptUnblockDiagnostics(), + fetchImpl: (async () => new Response(payload, { headers: { "content-type": "application/octet-stream" } })) as unknown as typeof fetch, + }); + const entry = await startChatgptUnblockEntryProxy({ originPort: origin.port! }); + try { + const response = await requestThroughTunnel(entry.port, "/backend-api/files/blob", { pauseMs: 500 }); + expect(response.established).toStartWith("HTTP/1.1 200"); + expect(response.head).toStartWith("HTTP/1.1 200"); + expect(response.body.length).toBe(payload.length); + expect(sha256(response.body)).toBe(sha256(payload)); + } finally { + await entry.stop(); + await origin.stop(true); + } + }, 30_000); + + test("a live tunnel outlasts the request-head deadline", async () => { + const origin = startChatgptUnblockListener({ + leaf, + diagnostics: new ChatgptUnblockDiagnostics(), + fetchImpl: (async () => new Response("still here")) as unknown as typeof fetch, + }); + const entry = await startChatgptUnblockEntryProxy({ originPort: origin.port!, headTimeoutSeconds: 1 }); + try { + // Bun fires socket deadlines on a ~4 s tick, so idle well past it before using the tunnel. + const response = await requestThroughTunnel(entry.port, "/backend-api/me", { idleMs: 6_000 }); + expect(response.body.toString()).toBe("still here"); + } finally { + await entry.stop(); + await origin.stop(true); + } + }, 20_000); + + test("a client that never finishes its head is closed at the deadline", async () => { + const entry = await startChatgptUnblockEntryProxy({ originPort: 1, headTimeoutSeconds: 1 }); + try { + const raw = connectNet({ host: "127.0.0.1", port: entry.port }); + await once(raw, "connect"); + raw.write("CONNECT chatgpt.com:443 HTTP/1.1\r\n"); + const started = Date.now(); + await once(raw, "close"); + expect(Date.now() - started).toBeLessThan(10_000); + } finally { + await entry.stop(); + } + }, 20_000); + + test("payload sent right behind the CONNECT head is tunnelled, never answered as a second CONNECT", async () => { + const diagnostics = new ChatgptUnblockDiagnostics(); + const origin = startChatgptUnblockListener({ leaf, diagnostics }); + const entry = await startChatgptUnblockEntryProxy({ originPort: origin.port! }); + try { + const client = await dial(entry.port); + client.write("CONNECT chatgpt.com:443 HTTP/1.1\r\nHost: chatgpt.com:443\r\n\r\n"); + client.write("\x16\x03\x01\x00\x05hello\r\n\r\n"); + client.write("CONNECT chatgpt.com:443 HTTP/1.1\r\n\r\n"); + const reply = (await received(client, "HTTP/1.1 200".length)).toString("latin1"); + await Bun.sleep(100); + const all = Buffer.concat(readers.get(client)!.chunks).toString("latin1"); + expect(reply).toStartWith("HTTP/1.1 200"); + expect(all.split("HTTP/1.1 200").length - 1).toBe(1); + client.end(); + } finally { + await entry.stop(); + await origin.stop(true); + } + }); +}); diff --git a/tests/clients/desktop-unblock-launch-script.test.ts b/tests/clients/desktop-unblock-launch-script.test.ts new file mode 100644 index 00000000000..4a62eb9f725 --- /dev/null +++ b/tests/clients/desktop-unblock-launch-script.test.ts @@ -0,0 +1,792 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildChatgptUnblockWatcherPlist, + buildChatgptUnblockWatcherScript, + chatgptCommandLineHasPac, + chatgptCommandLineHasRule, + checkChatgptWatcherScriptSyntax, +} from "../../src/chatgpt/desktop-unblock/launch-watcher"; +import { chatgptUnblockPacArgFor } from "../../src/chatgpt/desktop-unblock/runtime"; + +const PORT = 10300; +const RESOLVER = "--host-resolver-rules=MAP chatgpt.com 127.0.0.1:10300"; +const PAC_TEXT = "function FindProxyForURL(url, host) { return \"PROXY 127.0.0.1:10301; DIRECT\"; }\n"; +const PAC_SWITCH = chatgptUnblockPacArgFor(PAC_TEXT); + +const SCUTIL_NO_PROXY = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 0 +}`; + +// Shape of a VPN client in system-proxy mode (captured from Clash on macOS). +const SCUTIL_SYSTEM_PROXY = ` { + ExceptionsList : { + 0 : 127.0.0.1 + 1 : localhost + } + HTTPEnable : 1 + HTTPPort : 7892 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 7892 + HTTPSProxy : 127.0.0.1 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 7892 + SOCKSProxy : 127.0.0.1 +}`; + +const SCUTIL_SOCKS_ONLY = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 1080 + SOCKSProxy : 10.0.0.2 +}`; + +const SCUTIL_PAC = ` { + HTTPEnable : 0 + HTTPSEnable : 0 + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://127.0.0.1:7890/proxy.pac + SOCKSEnable : 0 +}`; + +const SHIM_PATH = () => `${dir}/chatgpt-codex-shim.sh`; + +type AppState = "none" | "plain" | "flagged" | "pac" | "flagged-shim" | "shim-only"; + +const APP_BINARY = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; +// A shell whose command line mentions the rule, e.g. someone grepping for it. Matching on +// command lines mistook exactly this for a correctly launched app. +const DECOY = `zsh -c pgrep -f 'ChatGPT.app/Contents/MacOS/ChatGPT .*${RESOLVER}'`; + +let stubs: string; +let dir: string; + +function stub(name: string, body: string): void { + const path = join(stubs, name); + writeFileSync(path, `#!/bin/bash\n${body}\n`); + chmodSync(path, 0o755); +} + +// Stubs are written once: macOS scans every new executable on first run, which costs ~1s each. +beforeAll(() => { + stubs = mkdtempSync(join(tmpdir(), "ocx-chatgpt-launch-bin-")); + // A process table of "pid|name|command" lines; pgrep and ps answer from it like the real ones. + // Like the real one, it skips the caller's ancestors (STUB_ANCESTORS) unless -a is given. + stub("pgrep", `[ "$1" = -a ] && { ancestors=1; shift; } || ancestors=0 +[ "$1" = -x ] && { exact=1; shift; } || exact=0 +[ $# -eq 1 ] || { echo "stub pgrep needs exactly one name" >&2; exit 2; } +found=1 +while IFS='|' read -r pid name command; do + if [ "$name" = "$1" ]; then + if [ $exact = 1 ] || [ "$command" = "$1" ] || [ "\${command##*/ }" = "$1" ]; then + if [ $ancestors = 0 ]; then case " $STUB_ANCESTORS " in *" $pid "*) continue ;; esac; fi + echo "$pid"; found=0 + fi + fi +done < "$STUB_DIR/processes" +exit $found`); + stub("ps", `pid="\${@: -1}" +case "$*" in *etime=*) [ -n "$STUB_APP_ETIME" ] && { echo "$STUB_APP_ETIME"; exit 0; }; exit 1 ;; esac +while IFS='|' read -r p name command; do + [ "$p" = "$pid" ] && { echo "$command"; exit 0; } +done < "$STUB_DIR/processes" +exit 1`); + // Only the loopback identity path is probed; no external service is contacted. + // The PAC probe requires a successful authenticated CONNECT response, not just TCP presence. + stub("curl", `[ "$STUB_AFTER_PREFLIGHT_DOWN" = 1 ] && [ -f "$STUB_DIR/preflight-args" ] && exit 7 +case " $* " in *" --proxy "*) + case "$STUB_ENTRY" in + up) echo '{"service":"opencodex-chatgpt-unblock","preservedSendBlocks":[]}'; exit 0 ;; + up56) exit 56 ;; + *) exit 7 ;; + esac ;; +esac +case "$STUB_LISTENER" in + ours) echo '{"service":"opencodex-chatgpt-unblock","preservedSendBlocks":[]}' ;; + foreign) echo 'another server' ;; + *) exit 7 ;; +esac`); + stub("scutil", `[ "$STUB_SCUTIL_FAILS" = 1 ] && exit 1\ncat "$STUB_DIR/scutil.txt"`); + stub("base64", `[ "$STUB_PAC_ENCODING_FAILS" = 1 ] && { printf 'partial'; exit 1; } +exec /usr/bin/base64 "$@"`); + // Quitting removes the app's main process (helpers exit with it only in reality; irrelevant here). + stub("osascript", `echo quit >> "$STUB_DIR/calls" +printf '%s\\n' "$@" > "$STUB_DIR/quit-all-args" +[ "$STUB_REMOVE_PAC_ON_QUIT" = 1 ] && rm -f "$STUB_PAC_FILE" +if [ "$STUB_QUIT_IGNORED" != 1 ]; then + grep -v '|ChatGPT|' "$STUB_DIR/processes" > "$STUB_DIR/processes.tmp" + mv -f "$STUB_DIR/processes.tmp" "$STUB_DIR/processes" +fi`); + // Records what follows --args (what the app itself receives) and, apart, the --env pairs. The + // pairs are appended to the process line: `ps eww` shows a process's environment after its command. + stub("open", `echo open >> "$STUB_DIR/calls" +printf '%s\\n' "$@" > "$STUB_DIR/open-all-args" +[ "$STUB_OPEN_FAILS" = 1 ] && { echo "stub open: cannot launch" >&2; exit 1; } +# What open inherits, separate from the --env pairs it is asked to pass on. +echo "\${CODEX_CLI_PATH-}" > "$STUB_DIR/open-inherited" +args=(); envs=(); after=0; prev="" +for a in "$@"; do + if [ $after = 1 ]; then args+=("$a") + elif [ "$a" = --args ]; then after=1 + elif [ "$prev" = --env ]; then envs+=("$a"); fi + prev="$a" +done +[ \${#args[@]} -gt 0 ] && printf '%s\\n' "\${args[@]}" > "$STUB_DIR/open-args" +[ \${#envs[@]} -gt 0 ] && printf '%s\\n' "\${envs[@]}" > "$STUB_DIR/open-env" +echo "500|ChatGPT|${APP_BINARY} \${args[*]} \${envs[*]}" >> "$STUB_DIR/processes"`); + stub("bundle-preflight", `[ "$STUB_BUNDLE_TRUST" != rejected ] || exit 1 +printf '%s\\n' "$@" > "$STUB_DIR/preflight-args"`); + stub("sleep", ":"); +}); + +afterAll(() => { + rmSync(stubs, { recursive: true, force: true }); +}); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-launch-")); + mkdirSync(join(dir, "tmp")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function run(mode: "watch" | "launch" | "native", options: { + app: AppState; + scutil?: string; + scutilFails?: boolean; + listener?: "ours" | "foreign" | "down"; + quitIgnored?: boolean; + /** `open` fails, as it does when the bundle is gone or LaunchServices refuses it. */ + openFails?: boolean; + decoy?: boolean; + configDir?: string; + /** Start the app through the app-server shim; a launcher file is written when on. */ + shim?: boolean; + /** With the shim on, whether its launcher exists (opencodex writes it once the bundle checks pass). */ + launcher?: boolean; + /** What `ps -o etime=` reports for the app; unset makes the age unreadable. */ + appEtime?: string; + /** The app is an ancestor of the caller, as for `ocx` run in a terminal inside the app. */ + appIsAncestor?: boolean; + /** A `CODEX_CLI_PATH` the script inherits, as from a terminal inside a shimmed app. */ + inheritedCliPath?: string; + /** PAC-fallback mode: the PAC file is written and the script launches through it. */ + pac?: boolean; + /** PAC mode: what the CONNECT entry probe sees. */ + entry?: "up" | "up56" | "down"; + entryPort?: number; + /** Synthetic PAC preflight failures; no live application or network is involved. */ + pacFile?: "missing" | "unreadable" | "directory" | "empty"; + pacEncodingFails?: boolean; + removePacOnQuit?: boolean; + appPath?: string; + bundleTrust?: "trusted" | "rejected"; + listenerStopsDuringPreflight?: boolean; +}) { + const pacFile = join(options.configDir ?? dir, "chatgpt-unblock.pac"); + if (options.pac === true && options.pacFile !== "missing") { + if (options.pacFile === "directory") mkdirSync(pacFile); + else { + writeFileSync(pacFile, options.pacFile === "empty" ? "" : PAC_TEXT); + if (options.pacFile === "unreadable") chmodSync(pacFile, 0o000); + } + } + const appBinary = `${options.appPath ?? "/Applications/ChatGPT.app"}/Contents/MacOS/ChatGPT`; + const processes = [ + options.app === "plain" ? `400|ChatGPT|${appBinary}` : null, + options.app === "flagged" ? `400|ChatGPT|${appBinary} ${RESOLVER} --proxy-bypass-list=chatgpt.com` : null, + options.app === "pac" ? `400|ChatGPT|${appBinary} ${PAC_SWITCH}` : null, + options.app === "flagged-shim" ? `400|ChatGPT|${appBinary} ${RESOLVER} --proxy-bypass-list=chatgpt.com CODEX_CLI_PATH=${SHIM_PATH()}` : null, + options.app === "shim-only" ? `400|ChatGPT|${appBinary} CODEX_CLI_PATH=${SHIM_PATH()}` : null, + // Helpers share the bundle but not the process name; they must never count as the app. + options.app !== "none" ? `401|ChatGPT Helper|/Applications/ChatGPT.app/Contents/Frameworks/ChatGPT Helper.app/Contents/MacOS/ChatGPT Helper --type=utility ${RESOLVER}` : null, + options.decoy ? `300|zsh|${DECOY}` : null, + ].filter(Boolean); + writeFileSync(join(dir, "processes"), processes.map(line => `${line}\n`).join("")); + writeFileSync(join(dir, "scutil.txt"), options.scutil ?? SCUTIL_NO_PROXY); + if (options.shim === true && options.launcher !== false) { + writeFileSync(join(options.configDir ?? dir, "chatgpt-codex-shim.sh"), "#!/bin/bash\n", { mode: 0o755 }); + } + const script = join(dir, "launch.sh"); + writeFileSync(script, buildChatgptUnblockWatcherScript( + PORT, + options.configDir ?? dir, + options.shim === true, + options.pac === true ? { entryPort: options.entryPort ?? 10301 } : undefined, + { appPath: options.appPath ?? "/Applications/ChatGPT.app", preflightArgv: [join(stubs, "bundle-preflight")] }, + )); + const result = spawnSync("/bin/bash", [script, mode], { + encoding: "utf8", + env: { + PATH: `${stubs}:/usr/bin:/bin`, + TMPDIR: join(dir, "tmp"), + STUB_DIR: dir, + STUB_SCUTIL_FAILS: options.scutilFails ? "1" : "0", + STUB_BUNDLE_TRUST: options.bundleTrust ?? "trusted", + STUB_AFTER_PREFLIGHT_DOWN: options.listenerStopsDuringPreflight ? "1" : "0", + STUB_LISTENER: options.listener ?? "ours", + STUB_QUIT_IGNORED: options.quitIgnored ? "1" : "0", + STUB_OPEN_FAILS: options.openFails ? "1" : "0", + STUB_APP_ETIME: options.appEtime ?? "", + STUB_ANCESTORS: options.appIsAncestor ? "400" : "", + STUB_ENTRY: options.entry ?? (options.pac === true ? "up" : "n/a"), + STUB_PAC_ENCODING_FAILS: options.pacEncodingFails ? "1" : "0", + STUB_REMOVE_PAC_ON_QUIT: options.removePacOnQuit ? "1" : "0", + STUB_PAC_FILE: pacFile, + ...(options.inheritedCliPath === undefined ? {} : { CODEX_CLI_PATH: options.inheritedCliPath }), + }, + }); + const read = (name: string) => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), "utf8") : ""); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + calls: read("calls").split("\n").filter(Boolean), + openArgs: read("open-args").split("\n").filter(Boolean), + openAllArgs: read("open-all-args").split("\n").filter(Boolean), + quitAllArgs: read("quit-all-args").split("\n").filter(Boolean), + preflightArgs: read("preflight-args").split("\n").filter(Boolean), + openEnv: read("open-env").split("\n").filter(Boolean), + openInherited: read("open-inherited").trim(), + log: read("chatgpt-unblock-watcher.log"), + processes: read("processes"), + }; +} + +describe.skipIf(process.platform === "win32")("chatgpt launch arguments per network mode", () => { + test("no system proxy: the resolver switch alone", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY }); + expect(r.status).toBe(0); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + test("system proxy: explicit proxy without synthetic direct fallback, apex host bypassed", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_SYSTEM_PROXY }); + expect(r.openArgs).toEqual([ + RESOLVER, + "--proxy-server=http://127.0.0.1:7892", + "--proxy-bypass-list=chatgpt.com", + ]); + }); + + test("SOCKS-only system proxy is passed as socks5", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_SOCKS_ONLY }); + expect(r.openArgs).toEqual([ + RESOLVER, + "--proxy-server=socks5://10.0.0.2:1080", + "--proxy-bypass-list=chatgpt.com", + ]); + }); + + for (const host of ["::1", "[::1]"]) { + test(`IPv6 system proxy is bracketed once (${host})`, () => { + const r = run("launch", { app: "none", scutil: SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", `HTTPSProxy : ${host}`) }); + expect(r.status).toBe(0); + expect(r.openArgs).toContain("--proxy-server=http://[::1]:7892"); + }); + } + + test("PAC proxy: the resolver switch alone, PAC left in charge", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_PAC }); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + for (const mode of ["launch", "watch"] as const) { + for (const scutil of ["", "unparseable proxy settings", SCUTIL_NO_PROXY.slice(0, -1), SCUTIL_NO_PROXY.replace("HTTPSEnable : 0", "HTTPSEnable : unknown"), SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", "HTTPSProxy :"), SCUTIL_SYSTEM_PROXY.replace("HTTPSPort : 7892", "HTTPSPort : invalid"), SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", "HTTPSProxy : proxy.test;DIRECT"), SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", "HTTPSProxy : ::::1"), SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", "HTTPSProxy : proxy wrong"), SCUTIL_SYSTEM_PROXY.replace("HTTPSPort : 7892", "HTTPSPort : 8080 garbage")]) { + test(`${mode}: unreadable or incomplete proxy policy leaves the app untouched (${JSON.stringify(scutil.slice(0, 25))})`, () => { + const r = run(mode, { app: "plain", scutil }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.log).toContain("cannot read the system proxy policy"); + }); + } + test(`${mode}: scutil failure leaves the app untouched despite partial output`, () => { + const r = run(mode, { app: "plain", scutilFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + }); + } + + for (const host of [":1:2:3:4:5:6:7:8", "1:2:3:4:5:6:7:8:", "[::1", "::1]", "999.1.2.3", "proxy..invalid", "-proxy.invalid", "099.1.2.3"]) { + test(`malformed system proxy leaves the app untouched (${host})`, () => { + const r = run("launch", { app: "plain", scutil: SCUTIL_SYSTEM_PROXY.replace("HTTPSProxy : 127.0.0.1", `HTTPSProxy : ${host}`) }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + }); + } + + test("native restore does not depend on reading the system proxy policy", () => { + const r = run("native", { app: "flagged", scutilFails: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + }); + + test("PAC launch uses its preflighted snapshot without rereading scutil", () => { + const r = run("launch", { app: "plain", pac: true, scutilFails: true }); + expect(r.status).toBe(0); + expect(r.openArgs).toEqual([PAC_SWITCH]); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt launch watcher", () => { + test("a Dock launch without the rule is quit, then relaunched with it", () => { + const r = run("watch", { app: "plain", scutil: SCUTIL_SYSTEM_PROXY }); + expect(r.status).toBe(0); + // `open` on a still-running app would only activate it, so quit must come first. + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs[0]).toBe(RESOLVER); + expect(r.log).toContain("restarting it"); + }); + + test("an app already carrying the rule is left alone", () => { + const r = run("watch", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("the watcher does not start an app that is not running", () => { + const r = run("watch", { app: "none" }); + expect(r.calls).toEqual([]); + }); + + test("with the intercept not answering the app is left native", () => { + expect(run("watch", { app: "plain", listener: "down" }).calls).toEqual([]); + const launch = run("launch", { app: "none", listener: "down" }); + expect(launch.status).toBe(1); + expect(launch.calls).toEqual([]); + expect(launch.stderr).toContain("not answering on port 10300"); + }); + + test("another process holding the port is never treated as the intercept", () => { + // Routing the app there would break every chatgpt.com request it makes. + expect(run("watch", { app: "plain", listener: "foreign" }).calls).toEqual([]); + expect(run("launch", { app: "none", listener: "foreign" }).status).toBe(1); + }); + + test("a config dir with quotes and ampersands still yields a working script", () => { + const odd = join(dir, "it's & co"); + mkdirSync(odd); + const r = run("watch", { app: "plain", configDir: odd }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(readFileSync(join(odd, "chatgpt-unblock-watcher.log"), "utf8")).toContain("restarting it"); + }); + + test("an app that refuses to quit is never re-opened (no activation-only restart loop)", () => { + const r = run("watch", { app: "plain", quitIgnored: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "quit", "quit"]); + expect(r.log).toContain("did not quit"); + }); + + test("a shell mentioning the rule is not mistaken for a correctly launched app", () => { + const r = run("watch", { app: "plain", decoy: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs[0]).toBe(RESOLVER); + }); + + test("a concurrent run holding the lock makes this one a no-op", () => { + mkdirSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock")); + const r = run("watch", { app: "plain" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("an explicit launch that finds another run holding the lock says so and fails", () => { + mkdirSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock")); + const r = run("launch", { app: "plain" }); + expect(r.status).toBe(1); + expect(r.stderr).toContain("another ChatGPT launch is in progress"); + expect(r.calls).toEqual([]); + }); + + test("a launch whose open fails after the quit reports the failure instead of success", () => { + const r = run("launch", { app: "plain", openFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.stdout).toContain("could not launch ChatGPT"); + expect(r.stdout).not.toContain("launched ChatGPT with"); + }); + + test("a restore whose open fails reports the failure instead of success", () => { + const r = run("native", { app: "flagged", openFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.stdout).toContain("could not relaunch ChatGPT with native networking"); + expect(r.stdout).not.toContain("relaunched ChatGPT with native networking"); + }); + + test("the lock is released after a run", () => { + run("watch", { app: "plain" }); + expect(existsSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock"))).toBe(false); + }); + + test("launch mode reports an already correct app without restarting it", () => { + const r = run("launch", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.stdout).toContain("already running with the launch switches"); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt restore (native networking)", () => { + test("restore undoes a PAC switch after pacFallback was turned off", () => { + const r = run("native", { app: "pac" }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + }); + + test("restore undoes a resolver rule after pacFallback was turned on", () => { + const r = run("native", { app: "flagged", pac: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + }); + + test("a mapped app is quit and reopened without any arguments", () => { + const r = run("native", { app: "flagged" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + expect(r.stdout).toContain("native networking"); + }); + + test("restore works while the listener is gone, which is when it is needed", () => { + const r = run("native", { app: "flagged", listener: "down" }); + expect(r.calls).toEqual(["quit", "open"]); + }); + + test("an app already on native networking, or not running, is left alone", () => { + expect(run("native", { app: "plain" }).calls).toEqual([]); + expect(run("native", { app: "none" }).calls).toEqual([]); + }); + + test("an app that refuses to quit is reported, not reopened", () => { + const r = run("native", { app: "flagged", quitIgnored: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual(["quit", "quit", "quit"]); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt launch helpers", () => { + test("the launchd agent runs the script in watch mode", () => { + const plist = buildChatgptUnblockWatcherPlist("/x/launch.sh", "/x/SingletonLock", "/x/err"); + expect(plist).toContain("/x/launch.sh\n watch"); + }); + + test("the agent wakes on every watched path: the app's lock and the readiness marker", () => { + const plist = buildChatgptUnblockWatcherPlist("/x/launch.sh", ["/x/SingletonLock", "/x/chatgpt-unblock.ready"], "/x/err"); + expect(plist).toContain("/x/SingletonLock"); + expect(plist).toContain("/x/chatgpt-unblock.ready"); + }); + + test("plist paths are XML-escaped", () => { + const plist = buildChatgptUnblockWatcherPlist("/a&b/.sh", "/x/SingletonLock", "/x/\"err\""); + expect(plist).toContain("/a&b/<launch>.sh"); + expect(plist).toContain("/x/"err""); + expect(plist).not.toContain("/a&b"); + }); + + test("status counts only the switch form of the rule on the app's command line", () => { + expect(chatgptCommandLineHasRule(`${APP_BINARY} ${RESOLVER} --proxy-bypass-list=chatgpt.com`, PORT)).toBe(true); + // The bare rule the original launcher passed is ignored by Chromium; it must not count. + expect(chatgptCommandLineHasRule(`${APP_BINARY} MAP chatgpt.com 127.0.0.1:10300`, PORT)).toBe(false); + expect(chatgptCommandLineHasRule(APP_BINARY, PORT)).toBe(false); + expect(chatgptCommandLineHasRule(`${APP_BINARY} ${RESOLVER.replace("10300", "10301")}`, PORT)).toBe(false); + }); +}); + + +describe.skipIf(process.platform === "win32")("chatgpt launch with the app-server shim", () => { + test("launch starts the app with CODEX_CLI_PATH pointing at the launcher, next to the usual switch", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY, shim: true }); + expect(r.status).toBe(0); + expect(r.openArgs).toEqual([RESOLVER]); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); + + test("without the shim the app gets no extra environment", () => { + const r = run("launch", { app: "none", scutil: SCUTIL_NO_PROXY }); + expect(r.openEnv).toEqual([]); + }); + + test("watch corrects an app that has the switch but was started without the shim", () => { + const r = run("watch", { app: "flagged", shim: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); + + test("an app that already carries the switch and the shim is left alone", () => { + const r = run("watch", { app: "flagged-shim", shim: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("with the shim off, an app that has the switch is not restarted for lacking it", () => { + const r = run("watch", { app: "flagged" }); + expect(r.calls).toEqual([]); + }); + + test("restore hands back an app that only carries the shim, even after the shim was switched off", () => { + const r = run("native", { app: "shim-only" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openEnv).toEqual([]); + }); +}); + +describe.skipIf(process.platform === "win32")("the watcher's five-minute guard and readiness wake", () => { + test("watch restarts an app that started within the last five minutes", () => { + const r = run("watch", { app: "plain", appEtime: "04:59" }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([RESOLVER]); + }); + + test("watch leaves an app the user has been working in, whatever the elapsed-time format", () => { + for (const appEtime of ["05:01", "02:03:04", "1-02:03:04"]) { + const r = run("watch", { app: "plain", appEtime }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.log).toContain("without the launch switches; leaving it"); + } + }); + + test("a missing age counts as a fresh launch", () => { + expect(run("watch", { app: "plain" }).calls).toEqual(["quit", "open"]); + }); + + test("an unparseable age counts as a fresh launch", () => { + expect(run("watch", { app: "plain", appEtime: "garbage" }).calls).toEqual(["quit", "open"]); + }); + + test("the explicit launch command restarts an old app too", () => { + const r = run("launch", { app: "plain", appEtime: "1-00:00:00" }); + expect(r.calls).toEqual(["quit", "open"]); + }); + + test("launch sees the app that is its own ancestor and leaves a correctly launched one alone", () => { + const r = run("launch", { app: "flagged", appIsAncestor: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.stdout).toContain("already running with the launch switches"); + }); + + test("restore finds the ancestor app and hands it back native", () => { + const r = run("native", { app: "flagged", appIsAncestor: true }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([]); + }); +}); + +test.skipIf(process.platform === "win32")("the shim's CODEX_CLI_PATH rides the explicit relaunch after a quit", () => { + const result = run("launch", { app: "flagged", shim: true }); + expect(result.status).toBe(0); + expect(result.calls).toEqual(["quit", "open"]); + expect(result.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + expect(result.openArgs).toEqual([RESOLVER]); +}); + +describe("an inherited CODEX_CLI_PATH never reaches a relaunch", () => { + const INHERITED = "/tmp/inherited-codex-cli"; + test("native restore drops it and passes no override", () => { + const r = run("native", { app: "flagged", inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([]); + }); + test("a launch without the shim drops it and passes no override", () => { + const r = run("launch", { app: "plain", inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([]); + }); + test("a shim launch drops it and passes only the shim launcher", () => { + const r = run("launch", { app: "plain", shim: true, inheritedCliPath: INHERITED }); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openInherited).toBe(""); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); +}); + +describe.skipIf(process.platform === "win32")("chatgpt launch in PAC-fallback mode", () => { + for (const mode of ["launch", "watch"] as const) { + for (const pacFile of ["missing", "directory", "empty"] as const) { + test(`${mode} leaves the running app untouched when PAC preflight finds a ${pacFile} file`, () => { + const r = run(mode, { app: "plain", pac: true, pacFile }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toContain(`400|ChatGPT|${APP_BINARY}\n`); + expect(r.openArgs).toEqual([]); + expect(existsSync(join(dir, "tmp", "opencodex-chatgpt-launch.lock"))).toBe(false); + }); + } + test.skipIf(process.getuid?.() === 0)(`${mode} leaves the running app untouched when PAC preflight cannot read the file`, () => { + const r = run(mode, { app: "plain", pac: true, pacFile: "unreadable" }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toContain(`400|ChatGPT|${APP_BINARY}\n`); + }); + test(`${mode} leaves the running app untouched when PAC preflight encoding fails after partial output`, () => { + const r = run(mode, { app: "plain", pac: true, pacEncodingFails: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toContain(`400|ChatGPT|${APP_BINARY}\n`); + }); + } + + test("PAC preflight leaves an old watch-mode app alone when the PAC is missing", () => { + const r = run("watch", { app: "plain", pac: true, pacFile: "missing", appEtime: "01:00:00" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.processes).toContain(`400|ChatGPT|${APP_BINARY}\n`); + }); + + test("PAC preflight refuses a new launch without opening an app when the PAC is missing", () => { + const r = run("launch", { app: "none", pac: true, pacFile: "missing" }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toBe(""); + }); + + test("the PAC snapshot is built before quit and is not re-read during open", () => { + const r = run("launch", { app: "plain", pac: true, removePacOnQuit: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([PAC_SWITCH]); + expect(existsSync(join(dir, "chatgpt-unblock.pac"))).toBe(false); + }); + + test("PAC preflight leaves no-op watch and native restore independent of a missing PAC file", () => { + const idle = run("watch", { app: "none", pac: true, pacFile: "missing" }); + expect(idle.status).toBe(0); + expect(idle.calls).toEqual([]); + const native = run("native", { app: "pac", pac: true, pacFile: "missing" }); + expect(native.status).toBe(0); + expect(native.calls).toEqual(["quit", "open"]); + expect(native.openArgs).toEqual([]); + }); + + test("the app gets the PAC switch alone, whatever the system proxy is", () => { + for (const scutil of [SCUTIL_NO_PROXY, SCUTIL_SYSTEM_PROXY, SCUTIL_SOCKS_ONLY, SCUTIL_PAC]) { + const r = run("launch", { app: "none", scutil, pac: true, configDir: dir }); + expect(r.openArgs).toEqual([PAC_SWITCH]); + } + }); + + test("watch corrects an app launched under the resolver-rule mode once the PAC is live", () => { + // A mode change means the app's old switches no longer match the marker: it is corrected. + const r = run("watch", { app: "flagged", pac: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openArgs).toEqual([PAC_SWITCH]); + }); + + test("an app already carrying the PAC is left alone", () => { + const r = run("launch", { app: "pac", pac: true }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + expect(r.stdout).toContain("already running with the launch switches"); + }); + + test("launch refuses with guidance while the entry listener is down", () => { + const r = run("launch", { app: "none", pac: true, entry: "down" }); + expect(r.status).toBe(1); + expect(r.stderr).toContain("entry listener is not answering"); + expect(r.calls).toEqual([]); + }); + + test("watch leaves everything alone when the entry is down (never force a dead route)", () => { + const r = run("watch", { app: "plain", pac: true, entry: "down" }); + expect(r.status).toBe(0); + expect(r.calls).toEqual([]); + }); + + test("the entry probe rejects curl exit 56 (connected, empty answer)", () => { + const r = run("launch", { app: "plain", pac: true, entry: "up56" }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + }); + + test("the shim composes with PAC-fallback mode", () => { + const r = run("launch", { app: "none", pac: true, shim: true, configDir: dir }); + expect(r.openArgs).toEqual([PAC_SWITCH]); + expect(r.openEnv).toEqual([`CODEX_CLI_PATH=${SHIM_PATH()}`]); + }); + + test("the PAC switch on the command line counts as flagged via chatgptCommandLineHasPac", () => { + writeFileSync(join(dir, "chatgpt-unblock.pac"), PAC_TEXT); + expect(chatgptCommandLineHasPac(`${APP_BINARY} ${PAC_SWITCH}`, dir)).toBe(true); + // A PAC regenerated since the app launched is no longer the one it carries. + writeFileSync(join(dir, "chatgpt-unblock.pac"), `${PAC_TEXT}// changed\n`); + expect(chatgptCommandLineHasPac(`${APP_BINARY} ${PAC_SWITCH}`, dir)).toBe(false); + // The pre-inline file:// switch still counts as ours, so it can be corrected. + expect(chatgptCommandLineHasPac(`${APP_BINARY} --proxy-pac-url=file://${dir}/chatgpt-unblock.pac`, dir)).toBe(true); + expect(chatgptCommandLineHasPac(APP_BINARY, dir)).toBe(false); + expect(chatgptCommandLineHasPac(`${APP_BINARY} --proxy-pac-url=file://other/chatgpt-unblock.pac`, dir)).toBe(false); + }); +}); + +describe.skipIf(process.platform === "win32")("generated watcher script syntax", () => { + // The script is a template literal; a stray quote or backtick in it would still build and + // install, then fail on every launch. + test("every mode combination parses as bash", () => { + const configDirs = [dir, join(dir, "it's \"quoted\" $dir")]; + for (const configDir of configDirs) { + mkdirSync(configDir, { recursive: true }); + for (const pac of [false, true]) { + for (const shim of [false, true]) { + const script = join(dir, `syntax-${pac}-${shim}.sh`); + writeFileSync(script, buildChatgptUnblockWatcherScript(PORT, configDir, shim, pac ? { entryPort: 10301 } : undefined)); + const check = checkChatgptWatcherScriptSyntax(script); + expect({ pac, shim, configDir, ok: check.ok, output: check.output }).toEqual({ pac, shim, configDir, ok: true, output: "" }); + } + } + } + }); +}); + + +describe.skipIf(process.platform === "win32")("verified watcher bundle", () => { + for (const mode of ["watch", "launch", "native"] as const) { + test(`${mode} leaves a running untrusted bundle untouched`, () => { + const r = run(mode, { app: mode === "native" ? "flagged" : "plain", bundleTrust: "rejected" }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toContain("400|ChatGPT|"); + }); + test(`${mode} quits and opens the same verified absolute bundle path`, () => { + const appPath = "/Applications/Other installs/it's \"Codex\".app"; + const r = run(mode, { app: mode === "native" ? "flagged" : "plain", appPath }); + expect(r.status).toBe(0); + expect(r.calls).toEqual(["quit", "open"]); + expect(r.openAllArgs.slice(0, 2)).toEqual(["-a", appPath]); + expect(r.quitAllArgs.at(-1)).toBe(appPath); + expect(r.preflightArgs).toEqual([appPath]); + }); + } +}); + + +describe.skipIf(process.platform === "win32")("readiness after bundle preflight", () => { + for (const pac of [false, true]) { + test(`listener stopping during preflight leaves the running app untouched (PAC=${pac})`, () => { + const r = run("launch", { app: "plain", pac, listenerStopsDuringPreflight: true }); + expect(r.status).toBe(1); + expect(r.calls).toEqual([]); + expect(r.processes).toContain("400|ChatGPT|"); + expect(r.log).toContain("readiness changed during preflight"); + }); + } +}); diff --git a/tests/clients/desktop-unblock-lifecycle.test.ts b/tests/clients/desktop-unblock-lifecycle.test.ts new file mode 100644 index 00000000000..4e7d229a993 --- /dev/null +++ b/tests/clients/desktop-unblock-lifecycle.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { spawnTestChild } from "../helpers/spawn-test-child"; + +// Isolate module mocks and the macOS gate from other test files. Runtime startup is +// deferred explicitly; handles own real loopback sockets, without a CA or app launch. +async function runLifecycle(body: string): Promise { + const result = await spawnTestChild([process.execPath, "-e", ` + import { strict as assert } from "node:assert"; + import { mock } from "bun:test"; + Object.defineProperty(process, "platform", { value: "darwin" }); + const starts = [], warnings = [], logs = [], sockets = []; + const started = Promise.withResolvers(); + let stopCalls = 0, stopGate, app = "ChatGPT", matchesPac = false, matchesRule = false; + const rulePorts = []; + console.warn = text => warnings.push(text); + console.log = text => logs.push(text); + const tick = () => new Promise(resolve => setImmediate(resolve)); + const options = (desktop = {}, role) => ({ + config: { chatgptDesktop: { unblockSend: true, ...desktop }, runtimeRole: role }, + publicPort: 10100, + }); + function bind(port = 0) { + const socket = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + sockets.push(socket); + return socket; + } + function fulfillStart(index = 0, extra = {}) { + const listener = bind(); + const handle = { + port: listener.port, caCertPath: "/test-config/ca.pem", listener, + async stop() { stopCalls++; await stopGate?.promise; listener.stop(true); }, + ...extra, + }; + starts[index].resolve(handle); + return handle; + } + function assertPortReleased(port) { bind(port).stop(true); } + mock.module(${JSON.stringify(repoPath("src/config/paths.ts"))}, () => ({ getConfigDir: () => "/test-config" })); + mock.module(${JSON.stringify(repoPath("src/chatgpt/desktop-unblock/runtime.ts"))}, () => ({ + startChatgptUnblock(options) { + const deferred = Promise.withResolvers(); + starts.push({ options, ...deferred }); + started.resolve(); + return deferred.promise; + }, + })); + mock.module(${JSON.stringify(repoPath("src/chatgpt/desktop-unblock/launch-watcher.ts"))}, () => ({ + chatgptAppCommandLine: () => app, + chatgptCommandLineHasPac: () => matchesPac, + chatgptCommandLineHasRule: (_app, port) => { rulePorts.push(port); return matchesRule; }, + })); + const { createChatgptUnblockLifecycle } = await import(${JSON.stringify(repoPath("src/server/index/chatgpt-unblock-lifecycle.ts"))}); + const lifecycle = createChatgptUnblockLifecycle(); + try { ${body} } finally { for (const socket of sockets) socket.stop(true); } + `], { cwd: repoRoot(), env: { ...process.env }, deadlineMs: 10_000 }); + expect(result.timedOut, result.stderr).toBe(false); + expect(result.exitCode, result.stderr).toBe(0); +} + +describe("ChatGPT unblock single-shot listener ownership", () => { + test("concurrent enabled starts retain one listener and the first options", async () => { + await runLifecycle(` + const first = options({ port: 10300 }); + lifecycle.start(first); + lifecycle.start(options({ port: 10400 })); + await started.promise; + await tick(); + assert.equal(starts.length, 1); + assert.equal(starts[0].options, first); + const handle = fulfillStart(); + await lifecycle.stop(); + assert.equal(stopCalls, 1); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("a disabled second start cannot discard an in-flight listener", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + lifecycle.start(options({ unblockSend: false })); + const handle = fulfillStart(); + await lifecycle.stop(); + assert.equal(starts.length, 1); + assert.equal(stopCalls, 1); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("later enabled and disabled starts leave an already-active listener owned", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + const handle = fulfillStart(); + await tick(); + assert.ok(logs.some(message => message.includes("send-unblock active"))); + lifecycle.start(options({ port: 10400 })); + lifecycle.start(options({ unblockSend: false })); + await lifecycle.stop(); + assert.equal(starts.length, 1); + assert.equal(stopCalls, 1); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("stop while startup is pending waits for and closes the eventual listener", async () => { + await runLifecycle(` + lifecycle.start(options()); + let stopped = false; + const stopping = lifecycle.stop().then(() => { stopped = true; }); + await started.promise; + await tick(); + assert.equal(stopped, false); + assert.equal(stopCalls, 0); + const handle = fulfillStart(); + await stopping; + assert.equal(stopped, true); + assert.equal(stopCalls, 1); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("repeated stops join the same shutdown and report the route warning once", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + const handle = fulfillStart(); + matchesRule = true; + stopGate = Promise.withResolvers(); + const first = lifecycle.stop(), second = lifecycle.stop(); + assert.equal(first, second); + let stopped = false; + void second.then(() => { stopped = true; }); + await tick(); + assert.equal(stopCalls, 1); + assert.equal(stopped, false); + stopGate.resolve(); + await Promise.all([first, second]); + assert.equal(lifecycle.stop(), first); + await lifecycle.stop(); + assert.equal(stopCalls, 1); + assert.equal(warnings.length, 2); + assert.ok(warnings[0].includes("still routed to port " + handle.port)); + assert.ok(warnings[1].includes("ocx chatgpt restore")); + assert.deepEqual(rulePorts, [handle.port]); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("starts during shutdown and after stop cannot create another listener", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + const stopping = lifecycle.stop(); + lifecycle.start(options({ port: 10400 })); + const handle = fulfillStart(); + await stopping; + lifecycle.start(options({ port: 10500 })); + await tick(); + assert.equal(starts.length, 1); + assert.equal(stopCalls, 1); + assertPortReleased(handle.port); + `); + }, 20_000); + + test("stop before start permanently closes an unused lifecycle", async () => { + await runLifecycle(` + const stopping = lifecycle.stop(); + lifecycle.start(options()); + await stopping; + lifecycle.start(options()); + await tick(); + assert.equal(starts.length, 0); + assert.equal(stopCalls, 0); + `); + }, 20_000); + + test.each(["disabled", "client", "platform"])("a %s initial start stays inactive", async gate => { + await runLifecycle(` + ${gate === "platform" ? 'Object.defineProperty(process, "platform", { value: "linux" });' : ""} + lifecycle.start(options(${gate === "disabled" ? "{ unblockSend: false }" : "{}"}, ${gate === "client" ? '"client"' : "undefined"})); + Object.defineProperty(process, "platform", { value: "darwin" }); + lifecycle.start(options()); + await tick(); + assert.equal(starts.length, 0); + await lifecycle.stop(); + assert.deepEqual(warnings, []); + assert.deepEqual(logs, []); + `); + }, 20_000); + + test.each([false, true])("ignored starts preserve the original PAC mode (%s)", async pacMode => { + await runLifecycle(` + lifecycle.start(options({ pacFallback: ${pacMode} })); + await started.promise; + lifecycle.start(options({ pacFallback: ${!pacMode} })); + await tick(); + assert.equal(starts.length, 1); + const handle = fulfillStart(); + matchesPac = matchesRule = true; + await lifecycle.stop(); + assert.equal(warnings.length, 2); + assert.ok(warnings[0].includes(${JSON.stringify(pacMode ? "still launched with opencodex's PAC" : "still routed to port ")})); + assert.deepEqual(rulePorts, ${pacMode ? "[]" : "[handle.port]"}); + assert.equal(starts.length, 1); + `); + }, 20_000); + + test("startup rejection stays a single warning and shutdown still completes", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + starts[0].reject(new Error("test bind failure")); + await lifecycle.stop(); + await lifecycle.stop(); + assert.equal(stopCalls, 0); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes("could not start: test bind failure")); + `); + }, 20_000); + + test("a null runtime result needs no shutdown and does not allow another start", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + starts[0].resolve(null); + await tick(); + lifecycle.start(options()); + await lifecycle.stop(); + assert.equal(starts.length, 1); + assert.equal(stopCalls, 0); + assert.deepEqual(warnings, []); + assert.deepEqual(logs, []); + `); + }, 20_000); + + test("shutdown failures propagate to every caller without stopping twice", async () => { + await runLifecycle(` + lifecycle.start(options()); + await started.promise; + const failure = new Error("test stop failure"); + fulfillStart(0, { async stop() { stopCalls++; throw failure; } }); + const first = lifecycle.stop(), second = lifecycle.stop(); + assert.equal(first, second); + await assert.rejects(first, error => error === failure); + await assert.rejects(second, error => error === failure); + await assert.rejects(lifecycle.stop(), error => error === failure); + lifecycle.start(options()); + await tick(); + assert.equal(starts.length, 1); + assert.equal(stopCalls, 1); + `); + }, 20_000); +}); diff --git a/tests/clients/desktop-unblock-listener.test.ts b/tests/clients/desktop-unblock-listener.test.ts new file mode 100644 index 00000000000..6abfcee1f5e --- /dev/null +++ b/tests/clients/desktop-unblock-listener.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { + CHATGPT_UNBLOCK_IDENTITY_PATH, + MAX_REWRITE_BODY_BYTES, + CHATGPT_UNBLOCK_SERVICE_ID, + ChatgptUnblockDiagnostics, + relayWithSendUnblock, + sseRewriteStream, +} from "../../src/chatgpt/desktop-unblock/listener"; + +const UPSTREAM = "https://chatgpt.example"; + +/** A fetch stand-in that records the target and answers with `response`. */ +function upstreamReturning(response: () => Response): { fetchImpl: typeof fetch; targets: string[] } { + const targets: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + targets.push(String(input)); + return response(); + }) as typeof fetch; + return { fetchImpl, targets }; +} + +function json(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { ...init, headers: { "content-type": "application/json", ...init.headers } }); +} + +async function collect(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + +describe("chatgpt unblock relay scope", () => { + test("an unrelated endpoint carrying a closed rate_limit passes through byte-identical", async () => { + // Reproduction from review: the whole hostname is mapped, so unrelated JSON must not change. + const body = '{"result":{"rate_limit":{"allowed":false,"limit_reached":true}}}'; + const { fetchImpl } = upstreamReturning(() => new Response(body, { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/review-fixture/not-a-composer-endpoint"), UPSTREAM, fetchImpl); + expect(await res.text()).toBe(body); + }); + + test("the usage endpoint's closed gate is opened", async () => { + const { fetchImpl, targets } = upstreamReturning(() => json({ rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage?x=1"), UPSTREAM, fetchImpl); + expect(targets).toEqual([`${UPSTREAM}/backend-api/wham/usage?x=1`]); + expect(await res.json()).toEqual({ rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } }); + }); + + test("conversation init loses quota send blocks and reports preserved eligibility blocks", async () => { + const diagnostics = new ChatgptUnblockDiagnostics(); + const { fetchImpl } = upstreamReturning(() => json({ + blocked_features: [ + { name: "send", block_reason: "usage_limit" }, + { name: "tpp_send", block_reason: "work_subscription_required" }, + ], + })); + const res = await relayWithSendUnblock( + new Request("https://chatgpt.com/backend-api/conversation/init", { method: "POST", body: "{}" }), + UPSTREAM, fetchImpl, diagnostics, + ); + expect(await res.json()).toEqual({ blocked_features: [{ name: "tpp_send", block_reason: "work_subscription_required" }] }); + const identity = await relayWithSendUnblock(new Request(`https://chatgpt.com${CHATGPT_UNBLOCK_IDENTITY_PATH}`), UPSTREAM, fetchImpl, diagnostics); + const snapshot = await identity.json() as { service: string; preservedSendBlocks: { name: string; reason: string }[] }; + expect(snapshot.service).toBe(CHATGPT_UNBLOCK_SERVICE_ID); + expect(snapshot.preservedSendBlocks.map(({ name, reason }) => ({ name, reason }))).toEqual([ + { name: "tpp_send", reason: "work_subscription_required" }, + ]); + }); + + test("the identity path is answered locally and never relayed", async () => { + const { fetchImpl, targets } = upstreamReturning(() => json({})); + const res = await relayWithSendUnblock(new Request(`https://chatgpt.com${CHATGPT_UNBLOCK_IDENTITY_PATH}`), UPSTREAM, fetchImpl); + expect(targets).toEqual([]); + expect(((await res.json()) as { service: string }).service).toBe(CHATGPT_UNBLOCK_SERVICE_ID); + }); +}); + +describe("chatgpt unblock relay responses", () => { + for (const status of [204, 205, 304]) { + test(`a ${status} with a JSON content type is relayed without a body`, async () => { + const { fetchImpl } = upstreamReturning(() => new Response(null, { status, headers: { "content-type": "application/json", etag: "\"v1\"" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect(res.status).toBe(status); + expect(res.body).toBeNull(); + expect(res.headers.get("etag")).toBe("\"v1\""); + }); + } + + test("a HEAD request is answered without a body", async () => { + const { fetchImpl } = upstreamReturning(() => json({ rate_limit: { allowed: false } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage", { method: "HEAD" }), UPSTREAM, fetchImpl); + expect(res.status).toBe(200); + expect(await res.text()).toBe(""); + }); + + test("an upstream failure becomes a 502 error envelope", async () => { + const fetchImpl = (async () => { throw new Error("connect ECONNREFUSED"); }) as unknown as typeof fetch; + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: { message: "chatgpt unblock relay failed: connect ECONNREFUSED" } }); + }); + + test("response headers keep cookies and drop encoding, length and HTTP/3 advertisements", async () => { + const { fetchImpl } = upstreamReturning(() => { + const headers = new Headers({ + "content-type": "text/plain", + "content-encoding": "br", + "content-length": "999", + "alt-svc": "h3=\":443\"; ma=86400", + }); + headers.append("set-cookie", "a=1; Path=/"); + headers.append("set-cookie", "b=2; Path=/"); + return new Response("ok", { headers }); + }); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/"), UPSTREAM, fetchImpl); + expect(res.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("content-length")).not.toBe("999"); + expect(res.headers.get("alt-svc")).toBeNull(); + }); +}); + +describe("chatgpt unblock SSE rewriting", () => { + // A window at 100% is the plain-quota evidence the gate rewrite requires before it opens the flags. + const locked = JSON.stringify({ usage: { rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } } }); + const opened = JSON.stringify({ usage: { rate_limit: { allowed: true, limit_reached: false, primary_window: { used_percent: 100 } } } }); + + test("a data line split across chunks is rewritten once and every other byte survives", async () => { + const frame = `event: snapshot\ndata: ${locked}\n\n: keep-alive\n\n`; + const cut = frame.indexOf("limit_reached"); + const encoder = new TextEncoder(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(frame.slice(0, cut))); + controller.enqueue(encoder.encode(frame.slice(cut))); + controller.close(); + }, + }); + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "usage" }))); + expect(text.split("\n\n")).toEqual(["event: snapshot\ndata: " + opened, ": keep-alive", ""]); + }); + + test("CRLF-framed events are rewritten and keep their line endings", async () => { + const frame = `event: snapshot\r\ndata: ${locked}\r\n\r\n`; + const source = new Response(frame).body!; + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "usage" }))); + expect(text).toBe(`event: snapshot\r\ndata: ${opened}\r\n\r\n`); + }); + + test("a stream on the conversation surface leaves usage gates alone", async () => { + const source = new Response(`data: ${locked}\n\n`).body!; + const text = await collect(source.pipeThrough(sseRewriteStream({ surface: "conversation" }))); + expect(text).toBe(`data: ${locked}\n\n`); + }); +}); + +describe("chatgpt unblock relay body cap", () => { + const closedUsage = (padding: string) => JSON.stringify({ padding, rate_limit: { allowed: false, limit_reached: true, primary_window: { used_percent: 100 } } }); + + test("a JSON body over the cap streams through unchanged, whatever content-length says", async () => { + const body = closedUsage("x".repeat(MAX_REWRITE_BODY_BYTES + 1024)); + // Chunked: no content-length, so the cap has to be enforced while reading. + const chunked = upstreamReturning(() => new Response(new ReadableStream({ + start(controller) { + const bytes = new TextEncoder().encode(body); + for (let i = 0; i < bytes.length; i += 1 << 20) controller.enqueue(bytes.slice(i, i + (1 << 20))); + controller.close(); + }, + }), { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, chunked.fetchImpl); + expect(await res.text()).toBe(body); + + const declared = upstreamReturning(() => new Response(body, { headers: { "content-type": "application/json", "content-length": String(body.length) } })); + const res2 = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, declared.fetchImpl); + expect(await res2.text()).toBe(body); + }); + + test("a JSON body under the cap is still rewritten", async () => { + const { fetchImpl } = upstreamReturning(() => new Response(closedUsage("y".repeat(1024)), { headers: { "content-type": "application/json" } })); + const res = await relayWithSendUnblock(new Request("https://chatgpt.com/backend-api/wham/usage"), UPSTREAM, fetchImpl); + expect((await res.json() as { rate_limit: { allowed: boolean } }).rate_limit.allowed).toBe(true); + }); +}); diff --git a/tests/clients/desktop-unblock-pac-runtime.test.ts b/tests/clients/desktop-unblock-pac-runtime.test.ts new file mode 100644 index 00000000000..44893e8eea7 --- /dev/null +++ b/tests/clients/desktop-unblock-pac-runtime.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + CHATGPT_UNBLOCK_ENTRY_PORT_OFFSET, + chatgptPacFallbackEnabled, + chatgptUnblockEntryPort, + chatgptUnblockPacArg, + chatgptUnblockPacPath, + chatgptUnblockPort, + CHATGPT_UNBLOCK_PAC_SWITCH_MAX_BYTES, + chatgptUnblockPacArgFor, + chooseChatgptUnblockPac, + startChatgptUnblock, +} from "../../src/chatgpt/desktop-unblock/runtime"; +import { CHATGPT_INTERCEPT_HOST } from "../../src/chatgpt/desktop-unblock/listener"; +import { chatgptUnblockReadyPath } from "../../src/chatgpt/desktop-unblock/runtime"; +import type { OcxConfig } from "../../src/types"; +import { repoPath } from "../helpers/repo-root"; + +function config(overrides: Partial> = {}): OcxConfig { + return { chatgptDesktop: { unblockSend: true, ...overrides } } as OcxConfig; +} + +describe("chatgpt unblock runtime ports and mode", () => { + test("the entry port is one after the origin port", () => { + expect(chatgptUnblockPort(config(), 10100)).toBe(10300); + expect(chatgptUnblockEntryPort(config(), 10100)).toBe(10300 + CHATGPT_UNBLOCK_ENTRY_PORT_OFFSET); + }); + + test("an origin port at 65535 wraps the entry port to 65534 instead of leaving the range", () => { + expect(chatgptUnblockPort(config({ port: 65535 }), 10100)).toBe(65535); + expect(chatgptUnblockEntryPort(config({ port: 65535 }), 10100)).toBe(65534); + }); + + test("pacFallback requires unblockSend and is off by default", () => { + expect(chatgptPacFallbackEnabled(config())).toBe(false); + // The intercept itself is macOS only, and PAC mode requires it. + expect(chatgptPacFallbackEnabled(config({ pacFallback: true }))).toBe(process.platform === "darwin"); + expect(chatgptPacFallbackEnabled({ chatgptDesktop: { pacFallback: true } } as OcxConfig)).toBe(false); + expect(chatgptPacFallbackEnabled({ chatgptDesktop: { unblockSend: true, pacFallback: true }, runtimeRole: "client" } as OcxConfig)).toBe(false); + }); + + test("the PAC argument carries the script inline as a data: URL", () => { + // A file:// PAC is ignored by the ChatGPT app (it dials directly, bypassing intercept and + // VPN), and an http:// one would need opencodex alive to be fetched. + const dir = mkdtempSync(join(tmpdir(), "ocx-pac-arg-")); + try { + const text = "function FindProxyForURL(u, h) { return \"DIRECT\"; }\n"; + writeFileSync(chatgptUnblockPacPath(dir), text); + const arg = chatgptUnblockPacArg(dir); + expect(arg).toBe(`--proxy-pac-url=data:application/x-ns-proxy-autoconfig;base64,${Buffer.from(text).toString("base64")}`); + expect(Buffer.from(arg.split("base64,")[1]!, "base64").toString("utf8")).toBe(text); + // No file yet: the bare prefix, which matches no real command line. + rmSync(chatgptUnblockPacPath(dir)); + expect(chatgptUnblockPacArg(dir)).toBe("--proxy-pac-url=data:application/x-ns-proxy-autoconfig;base64,"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + expect(chatgptUnblockPacPath("/cfg")).toBe(join("/cfg", "chatgpt-unblock.pac")); + }); + +}); + +/** + * A free origin/entry port pair away from the defaults (10300/10301 belong to a local + * opencodex on the default public port). + */ +function freePortPair(): number { + for (;;) { + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const origin = probe.port; + probe.stop(true); + if (origin >= 65535) continue; + try { + Bun.listen({ hostname: "127.0.0.1", port: origin + 1, socket: { data() {} } }).stop(true); + return origin; + } catch { + // the entry half is taken; try another pair + } + } +} + +/** Whether something accepts TCP connections on a loopback port right now. */ +function accepts(port: number): Promise { + return new Promise(resolve => { + Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { open(socket) { socket.end(); resolve(true); }, data() {}, error() { resolve(false); }, connectError() { resolve(false); } }, + }).catch(() => resolve(false)); + }); +} + +describe("chatgpt unblock PAC choice", () => { + const chain = { entries: ["PROXY 127.0.0.1:7892"], autoConfig: true, autoConfigUrl: "http://127.0.0.1:7892/proxy.pac" }; + const systemPac = 'function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:7892"; }'; + + test("a system PAC that fits the launch switch is embedded", () => { + const pac = chooseChatgptUnblockPac(10301, chain, systemPac); + expect(pac.route).toBe("system-pac"); + expect(pac.text).toContain(systemPac); + }); + + test("a system PAC too large for the launch switch fails instead of replacing its route", () => { + const bigger = `${systemPac}\n// ${"x".repeat(3_000)}`; + expect(() => chooseChatgptUnblockPac(10301, chain, bigger, 2_000)).toThrow("The system PAC is too large"); + }); + + test("the default limit refuses a whitelist-sized PAC rather than adding DIRECT", () => { + const large = `function FindProxyForURL(url, host) { ${"if (dnsDomainIs(host, '.example.test')) return 'DIRECT';\n".repeat(12_000)} return "PROXY 127.0.0.1:7892"; }`; + expect(large.length).toBeGreaterThan(600 * 1024); + expect(() => chooseChatgptUnblockPac(10301, chain, large)).toThrow("The system PAC is too large"); + expect(CHATGPT_UNBLOCK_PAC_SWITCH_MAX_BYTES).toBeLessThan(1024 * 1024); + }); + + test("an unreadable configured PAC fails even when static proxies are available", () => { + expect(() => chooseChatgptUnblockPac(10301, chain, null)).toThrow("The configured system PAC could not be read"); + expect(() => chooseChatgptUnblockPac(10301, { ...chain, entries: [], autoConfigUrl: null }, null)).toThrow(); + }); + + test("no system PAC retains only the configured static proxy route", () => { + const pac = chooseChatgptUnblockPac(10301, { ...chain, autoConfig: false, autoConfigUrl: null }, null); + expect(pac.route).toBe("system-proxy"); + expect(pac.text).not.toContain("DIRECT"); + }); + +}); + +describe.skipIf(process.platform === "win32")("chatgpt unblock PAC startup route failures", () => { + for (const route of ["scutil-failed", "pac-unreadable", "pac-too-large"]) { + test(`${route} releases both listeners and publishes no PAC or readiness marker`, () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-pac-route-failure-")); + try { + const pacPath = join(dir, "system.pac"); + if (route === "pac-too-large") writeFileSync(pacPath, `function FindProxyForURL(u, h) { return "DIRECT"; }\n//${"x".repeat(500_000)}`); + const scutil = route === "scutil-failed" ? "exit 1" : `printf '%s\\n' ' {' 'ProxyAutoConfigEnable : 1' 'ProxyAutoConfigURLString : ${pathToFileURL(pacPath).href}' '}'`; + writeFileSync(join(dir, "scutil"), `#!/bin/sh\n${scutil}\n`, { mode: 0o755 }); + // Platform dispatch is emulated only inside this isolated process. Every path and + // command is a temporary fixture; no macOS proxy or trust setting is accessed. + const source = ` + import { startChatgptUnblock } from ${JSON.stringify(repoPath("src", "chatgpt", "desktop-unblock", "runtime.ts"))}; + Object.defineProperty(process, "platform", { value: "darwin" }); + let port; + for (;;) { + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + port = probe.port; probe.stop(true); + if (port >= 65535) continue; + try { Bun.listen({ hostname: "127.0.0.1", port: port + 1, socket: { data() {} } }).stop(true); break; } catch {} + } + let error = ""; + try { + const handle = await startChatgptUnblock({ config: { chatgptDesktop: { unblockSend: true, pacFallback: true, port } }, publicPort: 0, configDir: ${JSON.stringify(dir)} }); + await handle?.stop(); + } catch (value) { error = String(value); } + for (const target of [port, port + 1]) { + // Successful rebind proves cleanup without relying on TCP timing. + Bun.listen({ hostname: "127.0.0.1", port: target, socket: { data() {} } }).stop(true); + } + console.log(JSON.stringify({ error })); + `; + const result = spawnSync(process.execPath, ["-e", source], { env: { ...process.env, PATH: dir, OPENCODEX_HOME: dir }, encoding: "utf8", timeout: 10_000 }); + expect(result.status).toBe(0); + const expected = route === "scutil-failed" ? "Cannot read the system proxy configuration" : route === "pac-unreadable" ? "The configured system PAC could not be read" : "The system PAC is too large"; + expect(result.stdout).toContain(expected); + expect(existsSync(chatgptUnblockPacPath(dir))).toBe(false); + expect(existsSync(chatgptUnblockReadyPath(dir))).toBe(false); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + } +}); + +describe.skipIf(process.platform !== "darwin")("chatgpt unblock PAC-mode startup", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-pac-runtime-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + test("resolver-rule mode starts no entry proxy and writes no PAC", async () => { + const handle = await startChatgptUnblock({ config: config({ port: freePortPair() }), publicPort: 0, configDir: dir }); + expect(handle).not.toBeNull(); + expect(handle!.entryProxy).toBeUndefined(); + expect(handle!.pacRoute).toBeUndefined(); + expect(existsSync(chatgptUnblockPacPath(dir))).toBe(false); + await handle!.stop(); + }); + + test("PAC mode binds the entry listener and writes a PAC pointing chatgpt.com at it", async () => { + const origin = freePortPair(); + const handle = await startChatgptUnblock({ config: config({ pacFallback: true, port: origin }), publicPort: 0, configDir: dir }); + expect(handle).not.toBeNull(); + expect(handle!.entryProxy!.port).toBe(origin + 1); + expect(handle!.pacRoute).toBeDefined(); + const pac = readFileSync(chatgptUnblockPacPath(dir), "utf8"); + expect(pac).toContain(`if (host == "${CHATGPT_INTERCEPT_HOST}")`); + expect(pac).toContain(`PROXY 127.0.0.1:${origin + 1}`); + expect(await accepts(origin + 1)).toBe(true); + // The readiness marker the watcher wakes on is written after the PAC it relaunches with. + expect(existsSync(chatgptUnblockReadyPath(dir))).toBe(true); + await handle!.stop(); + expect(await accepts(origin + 1)).toBe(false); + }); + + test("an entry bind failure fails the start instead of serving an unroutable PAC", async () => { + const origin = freePortPair(); + const blocker = Bun.listen({ hostname: "127.0.0.1", port: origin + 1, socket: { data() {} } }); + try { + await expect(startChatgptUnblock({ config: config({ pacFallback: true, port: origin }), publicPort: 0, configDir: dir })).rejects.toThrow(); + expect(await accepts(origin)).toBe(false); + } finally { + blocker.stop(true); + } + }); + + test("a PAC write failure releases both listeners", async () => { + const origin = freePortPair(); + // A directory where the PAC file goes makes the write fail after both listeners bound. + mkdirSync(chatgptUnblockPacPath(dir)); + await expect(startChatgptUnblock({ config: config({ pacFallback: true, port: origin }), publicPort: 0, configDir: dir })).rejects.toThrow(); + expect(await accepts(origin)).toBe(false); + expect(await accepts(origin + 1)).toBe(false); + }); +}); diff --git a/tests/clients/desktop-unblock-pac.test.ts b/tests/clients/desktop-unblock-pac.test.ts new file mode 100644 index 00000000000..278297aa15d --- /dev/null +++ b/tests/clients/desktop-unblock-pac.test.ts @@ -0,0 +1,244 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { runInNewContext } from "node:vm"; +import { buildChatgptUnblockPac, loadSystemPac, parseScutilOutput, systemProxyChain } from "../../src/chatgpt/desktop-unblock/pac"; +import { repoPath } from "../helpers/repo-root"; + +/** Evaluate a generated PAC the way Chromium does: run the script, then call the global. */ +function evaluatePac(pac: string, host: string): string { + const sandbox: Record = { + shExpMatch: (text: string, pattern: string) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(text), + dnsDomainIs: (h: string, domain: string) => h.endsWith(domain), + }; + runInNewContext(pac, sandbox); + return (sandbox.FindProxyForURL as (url: string, host: string) => string)(`https://${host}/`, host); +} + +/** A scutil stand-in built from the same `Key : value` lines the real command prints. */ +function scutilOf(lines: Record): ReturnType extends never ? never : Parameters[0] { + const map = new Map(Object.entries(lines)); + return { get: key => map.get(key) ?? null }; +} + +describe("chatgpt unblock scutil parsing", () => { + test.skipIf(process.platform === "win32")("command failure, empty output and malformed output cannot become a direct route", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-scutil-failure-")); + try { + const source = `import { systemProxyChain } from ${JSON.stringify(repoPath("src", "chatgpt", "desktop-unblock", "pac.ts"))}; systemProxyChain();`; + for (const body of ['printf " {\\n}\\n"; exit 1', "exit 0", 'printf "unparseable output\\n"']) { + writeFileSync(join(dir, "scutil"), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + const result = spawnSync(process.execPath, ["-e", source], { env: { ...process.env, PATH: dir }, encoding: "utf8" }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Cannot read the system proxy configuration"); + } + writeFileSync(join(dir, "scutil"), '#!/bin/sh\nprintf " {\\n}\\n"\n', { mode: 0o755 }); + expect(spawnSync(process.execPath, ["-e", source], { env: { ...process.env, PATH: dir } }).status).toBe(0); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + test("the parser reads the colon-separated lines scutil actually prints", () => { + const raw = " {\n HTTPSEnable : 1\n HTTPSProxy : 127.0.0.1\n HTTPSPort : 7892\n}"; + const chain = systemProxyChain(parseScutilOutput(raw)); + expect(chain.entries).toEqual(["PROXY 127.0.0.1:7892"]); + }); + + test("an equals-style line also parses, and non-key lines are ignored", () => { + const raw = " {\n HTTPEnable = 1\n HTTPProxy = 10.0.0.9\n HTTPPort = 7890\n ExceptionsList : {\n}"; + const chain = systemProxyChain(parseScutilOutput(raw)); + expect(chain.entries).toEqual(["PROXY 10.0.0.9:7890"]); + }); +}); + +describe("chatgpt unblock PAC generation", () => { + test("an HTTP(S) system proxy has no synthetic DIRECT fallback", () => { + const chain = systemProxyChain(scutilOf({ + HTTPSEnable: "1", HTTPSProxy: "127.0.0.1", HTTPSPort: "7892", + HTTPEnable: "1", HTTPProxy: "127.0.0.1", HTTPPort: "7890", + SOCKSEnable: "0", SOCKSProxy: "", SOCKSPort: "0", + })); + expect(chain.entries).toEqual(["PROXY 127.0.0.1:7892", "PROXY 127.0.0.1:7890"]); + const pac = buildChatgptUnblockPac(10301, chain); + expect(pac).toContain(`if (host == "chatgpt.com") return "PROXY 127.0.0.1:10301; PROXY 127.0.0.1:7892; PROXY 127.0.0.1:7890"`); + expect(pac).toContain(`return "PROXY 127.0.0.1:7892; PROXY 127.0.0.1:7890"`); + expect(pac).not.toContain("DIRECT"); + }); + + test("SOCKS-only VPNs map to a SOCKS5 entry", () => { + const chain = systemProxyChain(scutilOf({ + HTTPSEnable: "0", HTTPEnable: "0", SOCKSEnable: "1", SOCKSProxy: "10.0.0.1", SOCKSPort: "1080", + })); + expect(chain.entries).toEqual(["SOCKS5 10.0.0.1:1080"]); + expect(evaluatePac(buildChatgptUnblockPac(10301, chain), "auth.openai.com")).toBe("SOCKS5 10.0.0.1:1080"); + }); + + test("TUN mode (no system proxy) degrades to DIRECT only", () => { + const chain = systemProxyChain(scutilOf({ + HTTPSEnable: "0", HTTPEnable: "0", SOCKSEnable: "0", + })); + expect(chain.entries).toEqual([]); + const pac = buildChatgptUnblockPac(10301, chain); + expect(pac).toContain(`"PROXY 127.0.0.1:10301; DIRECT"`); + expect(pac).toContain(`return "DIRECT"`); + }); + + test("an unreadable scutil answer fails closed instead of claiming no proxy", () => { + expect(() => systemProxyChain(null)).toThrow("Cannot read the system proxy configuration"); + }); + + test("a system-level PAC file is detected and its URL captured", () => { + const chain = systemProxyChain(scutilOf({ ProxyAutoConfigEnable: "1", ProxyAutoConfigURLString: "http://127.0.0.1:1089/proxy.pac" })); + expect(chain.autoConfig).toBe(true); + expect(chain.autoConfigUrl).toBe("http://127.0.0.1:1089/proxy.pac"); + expect(chain.entries).toEqual([]); + expect(systemProxyChain(scutilOf({ ProxyAutoConfigEnable: "0", ProxyAutoConfigURLString: "http://x/p.pac" })).autoConfigUrl).toBeNull(); + }); + + test("the same proxy set for HTTPS and HTTP appears once", () => { + const chain = systemProxyChain(scutilOf({ + HTTPSEnable: "1", HTTPSProxy: "127.0.0.1", HTTPSPort: "7892", + HTTPEnable: "1", HTTPProxy: "127.0.0.1", HTTPPort: "7892", + SOCKSEnable: "1", SOCKSProxy: "127.0.0.1", SOCKSPort: "7892", + })); + expect(chain.entries).toEqual(["PROXY 127.0.0.1:7892", "SOCKS5 127.0.0.1:7892"]); + }); + + test("the generated chain PAC routes as it reads when evaluated", () => { + const pac = buildChatgptUnblockPac(10301, { entries: ["PROXY 127.0.0.1:7892"], autoConfig: false, autoConfigUrl: null }); + expect(evaluatePac(pac, "chatgpt.com")).toBe("PROXY 127.0.0.1:10301; PROXY 127.0.0.1:7892"); + expect(evaluatePac(pac, "auth.openai.com")).toBe("PROXY 127.0.0.1:7892"); + }); + + test("an enabled proxy with a missing address fails closed", () => { + expect(() => systemProxyChain(scutilOf({ + HTTPSEnable: "1", HTTPSProxy: "(null)", HTTPSPort: "0", + HTTPEnable: "1", HTTPProxy: "", HTTPPort: "0", + SOCKSEnable: "0", + }))).toThrow("An enabled system proxy has an invalid address"); + }); + + test("malformed enabled proxy routes never produce an empty proxy chain", () => { + for (const [host, port] of [["127.0.0.1", "0"], ["proxy.test", "65536"], ["proxy.test", "abc"], ["proxy.test; DIRECT", "80"], ["proxy.test/path", "80"], ["user@proxy.test", "80"]]) { + expect(() => systemProxyChain(scutilOf({ HTTPEnable: "1", HTTPProxy: host!, HTTPPort: port! }))).toThrow("An enabled system proxy has an invalid address"); + } + expect(() => systemProxyChain(scutilOf({ HTTPEnable: "unknown" }))).toThrow("malformed"); + expect(() => systemProxyChain(scutilOf({ ProxyAutoDiscoveryEnable: "1" }))).toThrow("Automatic proxy discovery"); + }); + + test("raw and percent-encoded delimiters cannot escape the canonical proxy hostname", () => { + for (const host of ['foo"bar', "foo%22bar", "a;b", "a%3bb", "a%3Bb", "foo..bar", "-proxy.test"]) { + expect(() => systemProxyChain(scutilOf({ HTTPEnable: "1", HTTPProxy: host, HTTPPort: "80" }))).toThrow("An enabled system proxy has an invalid address"); + } + expect(systemProxyChain(scutilOf({ HTTPEnable: "1", HTTPProxy: "Proxy.Example.test.", HTTPPort: "80" })).entries).toEqual(["PROXY proxy.example.test.:80"]); + }); + + test("generated chain values are JavaScript string literals even for unexpected quotes", () => { + const value = 'PROXY unexpected"quote:80'; + const pac = buildChatgptUnblockPac(10301, { entries: [value], autoConfig: false, autoConfigUrl: null }); + expect(pac).toContain(JSON.stringify(value)); + expect(evaluatePac(pac, "auth.openai.com")).toBe(value); + }); + + test("IPv6 proxies remain valid bracketed PAC destinations", () => { + const chain = systemProxyChain(scutilOf({ SOCKSEnable: "1", SOCKSProxy: "::1", SOCKSPort: "1080" })); + expect(chain.entries).toEqual(["SOCKS5 [::1]:1080"]); + expect(evaluatePac(buildChatgptUnblockPac(10301, chain), "auth.openai.com")).toBe("SOCKS5 [::1]:1080"); + }); + + test("a configured PAC may not silently use the static chain when its source is missing", () => { + expect(() => buildChatgptUnblockPac(10301, { entries: ["PROXY 127.0.0.1:7892"], autoConfig: true, autoConfigUrl: null })).toThrow("The configured system PAC could not be read"); + }); +}); + +describe("chatgpt unblock PAC with an embedded system PAC", () => { + const chain = { entries: [], autoConfig: true, autoConfigUrl: "http://127.0.0.1:1089/proxy.pac" }; + const systemStyles: Record = { + declaration: `function FindProxyForURL(url, host) { + if (shExpMatch(host, "*.cn")) return "DIRECT"; + return "SOCKS5 127.0.0.1:1086; DIRECT"; +}`, + "var assignment": `var FindProxyForURL = function (url, host) { + if (shExpMatch(host, "*.cn")) return "DIRECT"; + return "SOCKS5 127.0.0.1:1086; DIRECT"; +};`, + "bare global assignment and a trailing line comment": `FindProxyForURL = function (url, host) { + if (shExpMatch(host, "*.cn")) return "DIRECT"; + return "SOCKS5 127.0.0.1:1086; DIRECT"; +} +// end of gfwlist`, + }; + + for (const [style, systemPac] of Object.entries(systemStyles)) { + test(`chatgpt.com goes to the entry then the system PAC's answer (${style})`, () => { + const pac = buildChatgptUnblockPac(10301, chain, systemPac); + expect(evaluatePac(pac, "chatgpt.com")).toBe("PROXY 127.0.0.1:10301; SOCKS5 127.0.0.1:1086; DIRECT"); + expect(evaluatePac(pac, "auth.openai.com")).toBe("SOCKS5 127.0.0.1:1086; DIRECT"); + expect(evaluatePac(pac, "www.example.cn")).toBe("DIRECT"); + }); + } + + test("an embedded PAC that answers nothing degrades to DIRECT", () => { + const pac = buildChatgptUnblockPac(10301, chain, "function FindProxyForURL(url, host) { return undefined; }"); + expect(evaluatePac(pac, "chatgpt.com")).toBe("PROXY 127.0.0.1:10301; DIRECT"); + }); +}); + +describe("chatgpt unblock system PAC loading", () => { + const PAC = 'function FindProxyForURL(url, host) { return "PROXY 10.0.0.1:3128; DIRECT"; }'; + let dir: string; + let server: ReturnType; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-system-pac-")); + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/proxy.pac") return new Response(PAC, { headers: { "content-type": "application/x-ns-proxy-autoconfig" } }); + if (path === "/html") return new Response("captive portal"); + return new Response("missing", { status: 404 }); + }, + }); + }); + afterAll(() => { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + }); + + test("a file:// PAC is read from disk", async () => { + const path = join(dir, "vpn.pac"); + writeFileSync(path, PAC); + expect(await loadSystemPac(pathToFileURL(path).href)).toBe(PAC); + }); + + test("an http:// PAC on loopback is fetched directly, even with a dead HTTP_PROXY in the environment", async () => { + const saved = { HTTP_PROXY: process.env.HTTP_PROXY, http_proxy: process.env.http_proxy }; + process.env.HTTP_PROXY = "http://127.0.0.1:1"; + process.env.http_proxy = "http://127.0.0.1:1"; + try { + expect(await loadSystemPac(`http://127.0.0.1:${server.port}/proxy.pac`)).toBe(PAC); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + test("unreadable, non-PAC, oversized or unsupported sources give null", async () => { + expect(await loadSystemPac(`http://127.0.0.1:${server.port}/missing`)).toBeNull(); + expect(await loadSystemPac(`http://127.0.0.1:${server.port}/html`)).toBeNull(); + expect(await loadSystemPac("http://127.0.0.1:1/proxy.pac")).toBeNull(); + expect(await loadSystemPac(pathToFileURL(join(dir, "absent.pac")).href)).toBeNull(); + const big = join(dir, "big.pac"); + writeFileSync(big, `${PAC}\n//${"x".repeat(5 * 1024 * 1024)}`); + expect(await loadSystemPac(pathToFileURL(big).href)).toBeNull(); + mkdirSync(join(dir, "folder.pac")); + expect(await loadSystemPac(pathToFileURL(join(dir, "folder.pac")).href)).toBeNull(); + expect(await loadSystemPac("ftp://127.0.0.1/proxy.pac")).toBeNull(); + expect(await loadSystemPac("not a url")).toBeNull(); + }); +}); diff --git a/tests/clients/desktop-unblock-readiness-auth.test.ts b/tests/clients/desktop-unblock-readiness-auth.test.ts new file mode 100644 index 00000000000..0c8caf4162e --- /dev/null +++ b/tests/clients/desktop-unblock-readiness-auth.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + claudeInterceptCaCertPath, createLocalInterceptCa, issueLocalInterceptLeaf, mintAuthorityWithExtensionsForTests, + type PemKeyPair, +} from "../../src/claude/intercept/local-ca"; +import { startChatgptUnblockEntryProxy } from "../../src/chatgpt/desktop-unblock/entry-proxy"; +import { buildChatgptUnblockWatcherScript, probeChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/launch-watcher"; +import { CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_SERVICE_ID } from "../../src/chatgpt/desktop-unblock/listener"; + +const trustedCa = createLocalInterceptCa(); +const trustedLeaf = issueLocalInterceptLeaf(trustedCa, ["chatgpt.com"]); +const unrelatedCa = createLocalInterceptCa(); +const blocks = [{ name: "tpp_send", reason: "work_subscription_required", lastSeen: "2026-10-01T00:00:00.000Z" }]; +const identity = { service: CHATGPT_UNBLOCK_SERVICE_ID, preservedSendBlocks: blocks }; +const cleanups: (() => void | Promise)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +function configWithCa(ca: string | null = trustedCa.certPem): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-readiness-auth-")); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + if (ca !== null) { + const path = claudeInterceptCaCertPath(dir); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, ca); + } + return dir; +} + +function identityPeer(leaf: PemKeyPair = trustedLeaf, body: unknown = identity) { + const requests: string[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, tls: { cert: leaf.certPem, key: leaf.keyPem }, + fetch(request) { + requests.push(new URL(request.url).pathname); + return Response.json(body); + }, + }); + cleanups.push(async () => { await server.stop(true); }); + return { port: server.port!, requests }; +} + +const rejected = [ + { label: "another CA's chatgpt.com certificate", leaf: issueLocalInterceptLeaf(unrelatedCa, ["chatgpt.com"]), ca: trustedCa.certPem }, + { label: "a trusted certificate for a different hostname", leaf: issueLocalInterceptLeaf(trustedCa, ["other.example"]), ca: trustedCa.certPem }, + { label: "a self-signed chatgpt.com certificate", leaf: mintAuthorityWithExtensionsForTests("chatgpt.com", []), ca: trustedCa.certPem }, + { label: "a missing published CA", leaf: trustedLeaf, ca: null }, + { label: "a malformed published CA", leaf: trustedLeaf, ca: "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----\n" }, + { label: "an empty published CA", leaf: trustedLeaf, ca: "" }, + { label: "a leaf published instead of a CA", leaf: trustedLeaf, ca: trustedLeaf.certPem }, +]; + +describe("authenticated ChatGPT listener readiness", () => { + test("a real loopback peer signed by this config directory's CA reports preserved blocks", async () => { + const dir = configWithCa(); + const peer = identityPeer(); + expect(await probeChatgptUnblockListener(peer.port, undefined, undefined, dir)).toEqual({ state: "ours", preservedSendBlocks: blocks }); + expect(peer.requests).toEqual([CHATGPT_UNBLOCK_IDENTITY_PATH]); + }); + + for (const scenario of rejected) { + test(`rejects a copied service identity with ${scenario.label}`, async () => { + const dir = configWithCa(scenario.ca); + const peer = identityPeer(scenario.leaf); + expect(await probeChatgptUnblockListener(peer.port, undefined, undefined, dir)).toEqual({ state: "foreign" }); + expect(peer.requests).toEqual([]); + }); + } + + test("a verified TLS peer must still return the expected service identity", async () => { + const dir = configWithCa(); + const peer = identityPeer(trustedLeaf, { service: "another-local-service" }); + expect(await probeChatgptUnblockListener(peer.port, undefined, undefined, dir)).toEqual({ state: "foreign" }); + expect(peer.requests).toEqual([CHATGPT_UNBLOCK_IDENTITY_PATH]); + }); + + test("reads the current published CA on each probe without repairing missing trust", async () => { + const dir = configWithCa(); + const peer = identityPeer(); + expect((await probeChatgptUnblockListener(peer.port, undefined, undefined, dir)).state).toBe("ours"); + writeFileSync(claudeInterceptCaCertPath(dir), unrelatedCa.certPem); + expect((await probeChatgptUnblockListener(peer.port, undefined, undefined, dir)).state).toBe("foreign"); + expect(readFileSync(claudeInterceptCaCertPath(dir), "utf8")).toBe(unrelatedCa.certPem); + expect(peer.requests).toEqual([CHATGPT_UNBLOCK_IDENTITY_PATH]); + }); +}); + +/** Execute only the generated probe definitions, never app discovery, quit, or launch. */ +async function shellProbe(name: "listener_ours" | "entry_ours", port: number, dir: string, entryPort?: number): Promise { + const script = buildChatgptUnblockWatcherScript(port, dir, false, entryPort === undefined ? undefined : { entryPort }); + const boundary = script.indexOf('\nif [ "$MODE" != native ]'); + expect(boundary).toBeGreaterThan(0); + const child = Bun.spawn(["/bin/bash", "-c", `${script.slice(0, boundary)}\n${name}`], { + env: { + ...process.env, + CURL_HOME: dir, + // Direct probes must bypass these; proxy probes must override NO_PROXY='*'. + HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", ALL_PROXY: "http://127.0.0.1:9", + http_proxy: "http://127.0.0.1:9", https_proxy: "http://127.0.0.1:9", all_proxy: "http://127.0.0.1:9", + NO_PROXY: "*", no_proxy: "*", + }, + stdout: "ignore", stderr: "ignore", + }); + return child.exited; +} + +describe.skipIf(process.platform === "win32")("authenticated generated readiness probes", () => { + test("direct and CONNECT probes authenticate the local CA and reach only the identity endpoint", async () => { + const dir = configWithCa(); + const peer = identityPeer(); + const entry = await startChatgptUnblockEntryProxy({ originPort: peer.port }); + cleanups.push(() => entry.stop()); + expect(await shellProbe("listener_ours", peer.port, dir)).toBe(0); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).toBe(0); + expect(peer.requests).toEqual([CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_IDENTITY_PATH]); + }); + + for (const scenario of rejected.filter(scenario => !scenario.label.includes("leaf published"))) { + test(`direct and CONNECT probes reject ${scenario.label}`, async () => { + const dir = configWithCa(scenario.ca); + const peer = identityPeer(scenario.leaf); + const entry = await startChatgptUnblockEntryProxy({ originPort: peer.port }); + cleanups.push(() => entry.stop()); + expect(await shellProbe("listener_ours", peer.port, dir)).not.toBe(0); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).not.toBe(0); + expect(peer.requests).toEqual([]); + }); + } + + test("a plaintext entry that copies the identity body cannot satisfy a CONNECT probe", async () => { + const dir = configWithCa(); + const peer = identityPeer(); + const entry = Bun.listen({ + hostname: "127.0.0.1", port: 0, + socket: { data(socket) { + const body = JSON.stringify(identity); + socket.end(`HTTP/1.1 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`); + } }, + }); + cleanups.push(() => entry.stop(true)); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).not.toBe(0); + expect(peer.requests).toEqual([]); + }); + + test("curlrc cannot disable authentication for direct or CONNECT probes", async () => { + const dir = configWithCa(); + writeFileSync(join(dir, ".curlrc"), "insecure\n"); + const peer = identityPeer(issueLocalInterceptLeaf(unrelatedCa, ["chatgpt.com"])); + const entry = await startChatgptUnblockEntryProxy({ originPort: peer.port }); + cleanups.push(() => entry.stop()); + expect(await shellProbe("listener_ours", peer.port, dir)).not.toBe(0); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).not.toBe(0); + expect(peer.requests).toEqual([]); + }); + + test("direct and CONNECT probes also require the expected service identity over verified TLS", async () => { + const dir = configWithCa(); + const peer = identityPeer(trustedLeaf, { service: "another-local-service" }); + const entry = await startChatgptUnblockEntryProxy({ originPort: peer.port }); + cleanups.push(() => entry.stop()); + expect(await shellProbe("listener_ours", peer.port, dir)).not.toBe(0); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).not.toBe(0); + expect(peer.requests).toEqual([CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_IDENTITY_PATH]); + }); + + test("a listening entry that closes connections without a TLS peer is not ready", async () => { + const dir = configWithCa(); + const peer = identityPeer(); + const entry = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { socket.end(); } } }); + cleanups.push(() => entry.stop(true)); + expect(await shellProbe("entry_ours", peer.port, dir, entry.port)).not.toBe(0); + }); +}); diff --git a/tests/clients/desktop-unblock-runtime.test.ts b/tests/clients/desktop-unblock-runtime.test.ts new file mode 100644 index 00000000000..200d1d13188 --- /dev/null +++ b/tests/clients/desktop-unblock-runtime.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + chatgptUnblockEnabled, chatgptUnblockPort, chatgptUnblockResolverArg, startChatgptUnblock, +} from "../../src/chatgpt/desktop-unblock/runtime"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +function config(chatgptDesktop: OcxConfig["chatgptDesktop"] = { unblockSend: true }): OcxConfig { + return { chatgptDesktop } as OcxConfig; +} + +describe("ChatGPT intercept in resolver-rule mode, without a duplicate shim", () => { + test("stable offset, explicit port and resolver switch", () => { + expect(chatgptUnblockPort(config(), 10100)).toBe(10300); + expect(chatgptUnblockPort(config({ port: 65535 }), 10100)).toBe(65535); + expect(() => chatgptUnblockPort(config(), 65336)).toThrow("out of range"); + expect(chatgptUnblockResolverArg(10300)).toBe("--host-resolver-rules=MAP chatgpt.com 127.0.0.1:10300"); + }); + test("shim alone never starts the intercept; client role disables it", async () => { + expect(chatgptUnblockEnabled(config({ appServerShim: true }))).toBe(false); + expect(chatgptUnblockEnabled({ ...config(), runtimeRole: "client" })).toBe(false); + expect(chatgptUnblockEnabled(config())).toBe(process.platform === "darwin"); + expect(await startChatgptUnblock({ config: config({ appServerShim: true }), publicPort: 10100 })).toBeNull(); + }); + test.skipIf(process.platform !== "darwin")("resolver mode binds only its TLS listener and stops it", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-runtime-")); + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = probe.port; + probe.stop(true); + let handle: Awaited> = null; + try { + handle = await startChatgptUnblock({ config: config({ unblockSend: true, port }), publicPort: 10100, configDir: dir }); + expect(handle).not.toBeNull(); + expect(handle!.listener.port).toBe(port); + expect(existsSync(handle!.caCertPath)).toBe(true); + expect(existsSync(join(dir, "chatgpt-unblock.pac"))).toBe(false); + expect(existsSync(join(dir, "chatgpt-codex-shim.sh"))).toBe(false); + await handle!.stop(); + handle = null; + const rebound = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + rebound.stop(true); + } finally { + await handle?.stop(); + removeTreeWithRetry(dir); + } + }); +}); diff --git a/tests/clients/desktop-unblock-watcher-install.test.ts b/tests/clients/desktop-unblock-watcher-install.test.ts new file mode 100644 index 00000000000..62f8cdda531 --- /dev/null +++ b/tests/clients/desktop-unblock-watcher-install.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { repoPath } from "../helpers/repo-root"; +import { + CHATGPT_UNBLOCK_WATCHER_LABEL, + chatgptUnblockWatcherLoaded, + chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, + probeChatgptUnblockListener, + uninstallChatgptUnblockWatcher, + type LaunchctlRunner, +} from "../../src/chatgpt/desktop-unblock/launch-watcher"; +import { CHATGPT_UNBLOCK_SERVICE_ID, startChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/listener"; +import { ensureLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +let dir: string; +let plistPath: string; +let scriptPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ocx-chatgpt-watcher-")); + plistPath = join(dir, "agent.plist"); + scriptPath = join(dir, "chatgpt-unblock-watcher.sh"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +/** launchctl stand-in answering each verb with a fixed status, recording every call. */ +function launchctl(statuses: { bootout?: number; bootstrap?: number }): { run: LaunchctlRunner; calls: string[] } { + const calls: string[] = []; + const run: LaunchctlRunner = args => { + calls.push(args[0]!); + const status = (args[0] === "bootout" ? statuses.bootout : statuses.bootstrap) ?? 0; + return { ok: status === 0, status, output: status === 0 ? "" : `${args[0]} failed: ${status}: Input/output error` }; + }; + return { run, calls }; +} + +const install = (run: LaunchctlRunner) => + installChatgptUnblockWatcher({ port: 10300, configDir: dir, plistPath, assumeSupported: true, launchctl: run }); + +describe("chatgpt launch watcher loaded state", () => { + for (const loaded of [false, true]) { + test(`reads only the launchd print result (loaded=${loaded})`, () => { + const calls: string[][] = []; + const result = chatgptUnblockWatcherLoaded(args => { + calls.push(args); + return { ok: loaded, status: loaded ? 0 : 113, output: "" }; + }); + expect(result).toBe(loaded); + expect(calls).toEqual([["print", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]]); + }); + } +}); + +describe("chatgpt launch watcher install", () => { + test("a fresh install unloads nothing, writes both files and loads the agent", () => { + // 3 = "No such process": nothing of ours was loaded, the normal fresh-install answer. + const fake = launchctl({ bootout: 3 }); + install(fake.run); + expect(fake.calls).toEqual(["bootout", "bootstrap"]); + expect(existsSync(plistPath)).toBe(true); + expect(existsSync(scriptPath)).toBe(true); + }); + + test.skipIf(process.platform === "win32")("fresh watcher plists match both watched paths and old lock-only plists are stale", () => { + // Isolate HOME so the real status reader never touches an installed LaunchAgent. + const child = spawnSync(process.execPath, ["--eval", ` + import { mkdirSync, writeFileSync } from "node:fs"; + import { dirname } from "node:path"; + import { + buildChatgptUnblockWatcherPlist, chatgptUnblockWatcherPaths, + chatgptUnblockWatcherStatus, installChatgptUnblockWatcher, + } from ${JSON.stringify(repoPath("src/chatgpt/desktop-unblock/launch-watcher.ts"))}; + const dir = process.env.HOME; + const paths = chatgptUnblockWatcherPaths(dir); + if (!paths.plistPath.startsWith(dir + "/")) throw new Error("test home was not isolated"); + mkdirSync(dirname(paths.plistPath), { recursive: true }); + const results = []; + for (const entryPort of [undefined, 10301]) { + installChatgptUnblockWatcher({ + port: 10300, configDir: dir, plistPath: paths.plistPath, entryPort, + assumeSupported: true, launchctl: () => ({ ok: true, status: 0, output: "" }), + }); + const pac = entryPort === undefined ? undefined : { entryPort }; + const fresh = chatgptUnblockWatcherStatus(10300, dir, false, pac); + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath)); + const stale = chatgptUnblockWatcherStatus(10300, dir, false, pac); + results.push({ fresh: fresh.plistUpToDate, script: fresh.scriptUpToDate, stale: stale.plistUpToDate }); + } + console.log(JSON.stringify(results)); + `], { encoding: "utf8", timeout: 10000, env: { ...process.env, HOME: dir, USERPROFILE: dir } }); + expect({ status: child.status, stderr: child.stderr }).toEqual({ status: 0, stderr: "" }); + expect(JSON.parse(child.stdout)).toEqual([ + { fresh: true, script: true, stale: false }, + { fresh: true, script: true, stale: false }, + ]); + }); + + test("a failed bootstrap is reported with its diagnostic and leaves nothing behind", () => { + const fake = launchctl({ bootout: 3, bootstrap: 5 }); + expect(() => install(fake.run)).toThrow("launchctl bootstrap exited 5: bootstrap failed: 5: Input/output error"); + expect(existsSync(plistPath)).toBe(false); + expect(existsSync(scriptPath)).toBe(false); + }); + + test("a previous agent that cannot be unloaded stops the install before anything is written", () => { + const fake = launchctl({ bootout: 5 }); + expect(() => install(fake.run)).toThrow("could not unload the previous watcher"); + expect(fake.calls).toEqual(["bootout"]); + expect(existsSync(plistPath)).toBe(false); + }); +}); + +describe("chatgpt launch watcher install (PAC mode)", () => { + test("installing with an entryPort writes the PAC-mode script that probes the entry", () => { + const fake = launchctl({ bootout: 3 }); + installChatgptUnblockWatcher({ port: 10300, configDir: dir, plistPath, assumeSupported: true, launchctl: fake.run, entryPort: 10301 }); + expect(existsSync(scriptPath)).toBe(true); + const script = readFileSync(scriptPath, "utf8"); + // The PAC is rebuilt from the file at run time and passed inline (a file:// PAC is ignored). + expect(script).toContain("--proxy-pac-url=data:application/x-ns-proxy-autoconfig;base64,"); + expect(script).toContain("chatgpt-unblock.pac"); + expect(script).toContain("PAC_MODE=1"); + expect(script).toContain("127.0.0.1:10301"); + expect(script).toContain("entry_ours"); + }); + + test("the PAC-mode script's up-to-date check matches only the same mode", () => { + const fake = launchctl({ bootout: 3 }); + installChatgptUnblockWatcher({ port: 10300, configDir: dir, plistPath, assumeSupported: true, launchctl: fake.run, entryPort: 10301 }); + expect(chatgptUnblockWatcherStatus(10300, dir, false, { entryPort: 10301 }).scriptUpToDate).toBe(true); + expect(chatgptUnblockWatcherStatus(10300, dir, false).scriptUpToDate).toBe(false); + }); +}); + +describe("chatgpt launch watcher uninstall", () => { + const uninstall = (run: LaunchctlRunner) => uninstallChatgptUnblockWatcher({ configDir: dir, plistPath, launchctl: run }); + + test("unloading removes both files", () => { + install(launchctl({ bootout: 3 }).run); + uninstall(launchctl({ bootout: 0 }).run); + expect(existsSync(plistPath)).toBe(false); + expect(existsSync(scriptPath)).toBe(false); + }); + + test("an agent that was not loaded still has its files removed", () => { + install(launchctl({ bootout: 3 }).run); + uninstall(launchctl({ bootout: 113 }).run); + expect(existsSync(plistPath)).toBe(false); + }); + + test("a failed unload keeps the files so disk and launchd never disagree", () => { + install(launchctl({ bootout: 3 }).run); + expect(() => uninstall(launchctl({ bootout: 5 }).run)).toThrow("watcher files kept"); + expect(existsSync(plistPath)).toBe(true); + expect(existsSync(scriptPath)).toBe(true); + }); +}); + +describe("chatgpt listener probe", () => { + const answering = (body: unknown) => async () => JSON.stringify(body); + const open = async () => true; + + test("opencodex's listener is recognised and its preserved send blocks reported", async () => { + const blocks = [{ name: "tpp_send", reason: "work_subscription_required", lastSeen: "2026-09-26T00:00:00.000Z" }]; + const probe = await probeChatgptUnblockListener(10300, answering({ service: CHATGPT_UNBLOCK_SERVICE_ID, preservedSendBlocks: blocks }), open); + expect(probe).toEqual({ state: "ours", preservedSendBlocks: blocks }); + }); + + test("any other answer on the port is a foreign process", async () => { + expect((await probeChatgptUnblockListener(10300, answering({ service: "something-else" }), open)).state).toBe("foreign"); + expect((await probeChatgptUnblockListener(10300, async () => "", open)).state).toBe("foreign"); + // Open port, but no usable HTTP answer: still not ours. + expect((await probeChatgptUnblockListener(10300, async () => null, open)).state).toBe("foreign"); + }); + + test("a closed port means nothing is listening, without asking for identity", async () => { + let asked = false; + const request = async () => { asked = true; return "{}"; }; + expect((await probeChatgptUnblockListener(10300, request, async () => false)).state).toBe("down"); + expect(asked).toBe(false); + }); + + test("the default TCP check reports a really closed loopback port as down", async () => { + const socket = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = socket.port; + socket.stop(true); + expect((await probeChatgptUnblockListener(port)).state).toBe("down"); + }); + + test("a real listener is recognised even with a proxy in the environment", async () => { + // Bun's fetch would send this loopback request to HTTPS_PROXY; the probe must not. + const listener = startChatgptUnblockListener({ leaf: issueLocalInterceptLeaf(ensureLocalInterceptCa(dir), ["chatgpt.com"]) }); + const saved = { HTTPS_PROXY: process.env.HTTPS_PROXY, HTTP_PROXY: process.env.HTTP_PROXY }; + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.HTTP_PROXY = "http://127.0.0.1:9"; + try { + const probe = await probeChatgptUnblockListener(listener.port!, undefined, undefined, dir); + expect(probe).toEqual({ state: "ours", preservedSendBlocks: [] }); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await listener.stop(true); + } + }); +}); + diff --git a/tests/clients/desktop-unblock-ws-frame.test.ts b/tests/clients/desktop-unblock-ws-frame.test.ts new file mode 100644 index 00000000000..43dec2d678e --- /dev/null +++ b/tests/clients/desktop-unblock-ws-frame.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_MAX_FRAME_BYTES, WsOpcode } from "../../src/chatgpt/desktop-unblock/ws-frame"; + +describe("ws-frame", () => { + test("encode+parse roundtrip preserves text frame opcode, FIN and payload", () => { + const encoded = encodeWsFrame(WsOpcode.TEXT, Buffer.from("hello")); + // masked client frame: FIN+text, mask bit set, length 5 + expect(encoded[0]).toBe(0x81); + expect(encoded[1] & 0x80).toBe(0x80); + const { frames, violation, rest } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(rest.length).toBe(0); + expect(frames).toHaveLength(1); + expect(frames[0]!.opcode).toBe(WsOpcode.TEXT); + expect(frames[0]!.fin).toBe(true); + expect(frames[0]!.payload.toString()).toBe("hello"); + }); + + test("binary frame with an unmasked server shape parses identically", () => { + const payload = Buffer.from([1, 2, 3, 254]); + const encoded = encodeWsFrame(WsOpcode.BINARY, payload, false); + expect(encoded[1] & 0x80).toBe(0); + const { frames, violation } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(frames).toHaveLength(1); + expect(frames[0]!.opcode).toBe(WsOpcode.BINARY); + expect(Buffer.from(frames[0]!.payload)).toEqual(payload); + }); + + test("split and coalesced chunks parse through the pending buffer", () => { + const a = encodeWsFrame(WsOpcode.TEXT, Buffer.from("part-a")); + const b = encodeWsFrame(WsOpcode.BINARY, Buffer.from([9, 8, 7]), false); + const joined = Buffer.concat([a, b]); + // feed it in two awkward cuts + const cut1 = joined.subarray(0, 3); + const first = parseWsFrames(cut1, Buffer.alloc(0)); + expect(first.frames).toHaveLength(0); + expect(first.violation).toBeNull(); + const cut2 = joined.subarray(3, 9); + // 3+6=9 bytes < 12-byte frame: still incomplete, nothing parsed yet + const second = parseWsFrames(cut2, first.rest); + expect(second.frames).toHaveLength(0); + expect(second.violation).toBeNull(); + const third = parseWsFrames(joined.subarray(9), second.rest); + expect(third.frames).toHaveLength(2); + expect(third.violation).toBeNull(); + expect(third.rest.length).toBe(0); + expect(third.frames[0]!.payload.toString()).toBe("part-a"); + expect(Array.from(third.frames[1]!.payload)).toEqual([9, 8, 7]); + }); + + test("extended length forms parse", () => { + const medium = Buffer.alloc(300); + medium.fill(0xab); + const encoded = encodeWsFrame(WsOpcode.BINARY, medium, false); + expect(encoded[1] & 0x7f).toBe(126); + const { frames, violation } = parseWsFrames(encoded, Buffer.alloc(0)); + expect(violation).toBeNull(); + expect(frames[0]!.payload.length).toBe(300); + + const huge = Buffer.alloc(70_000); + const encodedHuge = encodeWsFrame(WsOpcode.BINARY, huge, false); + expect(encodedHuge[1] & 0x7f).toBe(127); + const parsed = parseWsFrames(encodedHuge, Buffer.alloc(0)); + expect(parsed.violation).toBeNull(); + expect(parsed.frames[0]!.payload.length).toBe(70_000); + }); + + test("control frames parse and are capped at 125 bytes", () => { + const ping = encodeWsFrame(WsOpcode.PING, Buffer.from("hb"), false); + const { frames } = parseWsFrames(ping, Buffer.alloc(0)); + expect(frames[0]!.opcode).toBe(WsOpcode.PING); + + const oversizedControl = Buffer.from([0x89, 0x7e, 0x00, 0x80]); + const { violation } = parseWsFrames(oversizedControl, Buffer.alloc(0)); + expect(violation).toBe("control frame payload over 125 bytes"); + }); + + test("protocol violations surface as reasons", () => { + expect(parseWsFrames(Buffer.from([0x81, 0x05]), Buffer.alloc(0)).frames).toHaveLength(0); + // RSV bit set + expect(parseWsFrames(Buffer.from([0xc1, 0x00]), Buffer.alloc(0)).violation).toBe( + "RSV bits set without a negotiated extension", + ); + // unknown opcode 0x3 + expect(parseWsFrames(Buffer.from([0x83, 0x00]), Buffer.alloc(0)).violation).toBe("unknown opcode"); + // close frame without FIN + expect(parseWsFrames(Buffer.from([0x08, 0x00]), Buffer.alloc(0)).violation).toBe("control frame without FIN"); + // 64-bit length above the ceiling + // 64-bit length 0x1_0000_0000 (4 GiB) — beyond the 16 MiB ceiling + const oversize64 = Buffer.from([0x82, 0xff, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + expect(parseWsFrames(oversize64, Buffer.alloc(0)).violation).toBe("frame exceeds the relay's size ceiling"); + }); + + test("the size ceiling is a constant tests can reference", () => { + expect(WEBSOCKET_MAX_FRAME_BYTES).toBe(16 * 1024 * 1024); + }); +}); diff --git a/tests/clients/desktop-unblock-ws-relay.test.ts b/tests/clients/desktop-unblock-ws-relay.test.ts new file mode 100644 index 00000000000..5da6037c89f --- /dev/null +++ b/tests/clients/desktop-unblock-ws-relay.test.ts @@ -0,0 +1,501 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { createServer as createNetServer, connect as connectNet } from "node:net"; +import type { AddressInfo, Server as NetServer } from "node:net"; +import { connect as connectTls, createServer as createTlsServer } from "node:tls"; +import type { Server as TlsServer, TLSSocket } from "node:tls"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; +import { startChatgptUnblockListener } from "../../src/chatgpt/desktop-unblock/listener"; +import { isRelayableUpgrade, readResponseHead, sendableCloseCode } from "../../src/chatgpt/desktop-unblock/ws-relay"; +import { encodeWsFrame, parseWsFrames, WEBSOCKET_GUID, WsOpcode } from "../../src/chatgpt/desktop-unblock/ws-frame"; +import type { DialUpstreamOptions } from "../../src/chatgpt/desktop-unblock/ws-upstream"; + +const ca = createLocalInterceptCa(); +const leaf = issueLocalInterceptLeaf(ca, ["chatgpt.com"]); + +/** One unmasked server frame; `b0` carries FIN and the opcode. */ +function serverFrame(b0: number, payload: Buffer | string): Buffer { + const body = typeof payload === "string" ? Buffer.from(payload) : payload; + return Buffer.concat([Buffer.from([b0, body.length]), body]); +} + +interface UpstreamLog { + heads: string[]; + pongs: string[]; + closes: { code: number; reason: string }[]; +} + +/** + * Scripted chatgpt.com stand-in speaking raw RFC 6455, so the test can send what a real + * server sends but Bun's server API cannot: fragments, interleaved pings, early frames. + */ +function startFakeUpstream(): { server: TlsServer; log: UpstreamLog; port: () => number } { + const log: UpstreamLog = { heads: [], pongs: [], closes: [] }; + const server = createTlsServer({ cert: leaf.certPem, key: leaf.keyPem }, socket => { + let buffer = Buffer.alloc(0); + let upgraded = false; + socket.on("error", () => {}); + socket.on("data", (chunk: Buffer) => { + if (!upgraded) { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + const head = buffer.subarray(0, end).toString("latin1"); + buffer = Buffer.alloc(0); + log.heads.push(head); + if (head.startsWith("GET /refuse")) { + socket.end("HTTP/1.1 403 Forbidden\r\ncf-ray: test-ray\r\ncontent-encoding: gzip\r\ncontent-length: 0\r\n\r\n"); + return; + } + upgraded = true; + const key = /^sec-websocket-key: *([^\r\n]+)/im.exec(head)![1]!.trim(); + const accept = createHash("sha1").update(key + WEBSOCKET_GUID).digest("base64"); + const offered = /^sec-websocket-protocol: *([^\r\n]+)/im.exec(head)?.[1]?.split(",").map(p => p.trim()) ?? []; + const chosen = offered.at(-1); + // The 101 and the first frame share one write: the relay must replay the early bytes. + socket.write(Buffer.concat([ + Buffer.from( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n${chosen ? `Sec-WebSocket-Protocol: ${chosen}\r\n` : ""}\r\n`, + ), + serverFrame(0x81, "hello-early"), + ])); + return; + } + const parsed = parseWsFrames(chunk, buffer); + buffer = parsed.rest; + for (const frame of parsed.frames) { + if (frame.opcode === WsOpcode.PONG) log.pongs.push(frame.payload.toString()); + else if (frame.opcode === WsOpcode.CLOSE) { + log.closes.push({ code: frame.payload.readUInt16BE(0), reason: frame.payload.subarray(2).toString() }); + socket.end(serverFrame(0x88, frame.payload)); + } else if (frame.opcode === WsOpcode.BINARY) socket.write(serverFrame(0x82, frame.payload)); + else if (frame.opcode === WsOpcode.TEXT) { + const text = frame.payload.toString(); + if (text === "frag") { + // Binary message in three fragments with a ping between them (legal per RFC 6455 §5.4). + socket.write(Buffer.concat([ + serverFrame(0x02, "ab"), + serverFrame(0x89, "hb"), + serverFrame(0x00, "cd"), + serverFrame(0x80, "ef"), + ])); + } else if (text === "close") { + const payload = Buffer.concat([Buffer.from([0x0f, 0xa1]), Buffer.from("bye")]); + socket.end(serverFrame(0x88, payload)); + } else if (text === "bigfrag") { + // A message whose fragment payloads pass the 16 MiB byte ceiling (chunk count stays small). + // FIN is cleared on both fragments, so the relay must buffer rather than flush. + const piece = Buffer.alloc(12 * 1024 * 1024, 0x61); + const unfin = (frame: Buffer): Buffer => { const copy = Buffer.from(frame); copy[0]! &= 0x7f; return copy; }; + socket.write(Buffer.concat([ + unfin(encodeWsFrame(WsOpcode.BINARY, piece, false)), + unfin(encodeWsFrame(WsOpcode.CONTINUATION, piece, false)), + ])); + } else socket.write(serverFrame(0x81, `up:${text}`)); + } + } + }); + }); + server.listen(0, "127.0.0.1"); + return { server, log, port: () => (server.address() as AddressInfo).port }; +} + +/** HTTP CONNECT proxy that sends every tunnel to the fake upstream, recording the request line. */ +function startConnectProxy(upstreamPort: () => number): { server: NetServer; requests: string[]; heads: string[] } { + const requests: string[] = []; + const heads: string[] = []; + const server = createNetServer(client => { + let buffer = Buffer.alloc(0); + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + client.removeListener("data", onData); + heads.push(buffer.subarray(0, end).toString()); + requests.push(buffer.subarray(0, buffer.indexOf("\r\n")).toString()); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, requests, heads }; +} + +/** TLS-speaking CONNECT proxy: the client must wrap the proxy port before its CONNECT. */ +function startTlsConnectProxy(upstreamPort: () => number): { server: TlsServer; requests: string[] } { + const requests: string[] = []; + // A leaf for 127.0.0.1 (IP SAN), so the client's TLS wrap of the proxy port validates. + const proxyLeaf = issueLocalInterceptLeaf(ca, ["127.0.0.1"]); + const server = createTlsServer({ ca: [ca.certPem], cert: proxyLeaf.certPem, key: proxyLeaf.keyPem }, client => { + let buffer = Buffer.alloc(0); + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + client.removeListener("data", onData); + requests.push(buffer.subarray(0, buffer.indexOf("\r\n")).toString()); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, requests }; +} + +/** SOCKS5 proxy that sends every CONNECT to the fake upstream, recording the target. Optional RFC 1929 credentials. */ +function startSocks5Proxy(upstreamPort: () => number, credentials?: { username: string; password: string }): { server: NetServer; targets: string[]; authAttempts: string[] } { + const targets: string[] = []; + const authAttempts: string[] = []; + const server = createNetServer(client => { + let buffer = Buffer.alloc(0); + let stage: "greeting" | "auth" | "connect" = "greeting"; + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]!) return; + const methods = buffer.subarray(2, 2 + buffer[1]!); + buffer = buffer.subarray(2 + methods.length); + const needsAuth = credentials !== undefined; + if (needsAuth && !methods.includes(0x02)) { + client.end(Buffer.from([0x05, 0xff])); + return; + } + client.write(Buffer.from([0x05, needsAuth ? 0x02 : 0x00])); + stage = needsAuth ? "auth" : "connect"; + } + if (stage === "auth") { + if (buffer.length < 2) return; + const usernameLength = buffer[1]!; + if (buffer.length < 2 + usernameLength + 1) return; + const passwordLength = buffer[2 + usernameLength]!; + if (buffer.length < 2 + usernameLength + 1 + passwordLength) return; + const username = buffer.subarray(2, 2 + usernameLength).toString(); + const password = buffer.subarray(3 + usernameLength, 3 + usernameLength + passwordLength).toString(); + buffer = buffer.subarray(2 + usernameLength + 1 + passwordLength); + const valid = username === credentials?.username && password === credentials?.password; + authAttempts.push(`${username}:${password}`); + client.write(Buffer.from([0x01, valid ? 0x00 : 0xff])); + if (!valid) return; + stage = "connect"; + } + if (stage === "connect") { + if (buffer.length < 5) return; + const hostLength = buffer[4]!; + if (buffer.length < 5 + hostLength + 2) return; + client.removeListener("data", onData); + const host = buffer.subarray(5, 5 + hostLength).toString(); + targets.push(`${host}:${buffer.readUInt16BE(5 + hostLength)}`); + const upstream = connectNet(upstreamPort(), "127.0.0.1", () => { + client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + } + }; + client.on("data", onData); + }); + server.listen(0, "127.0.0.1"); + return { server, targets, authAttempts }; +} + +/** A WebSocket client standing in for the desktop app, with an awaitable message queue. */ +async function openApp(port: number, path: string): Promise<{ + ws: WebSocket; + next: () => Promise; + closed: Promise<{ code: number; reason: string }>; +}> { + const ws = new WebSocket(`wss://127.0.0.1:${port}${path}`, { + protocols: ["realtime-v1", "realtime-v2"], + headers: { Cookie: "session=abc123", Origin: "https://chatgpt.com" }, + tls: { rejectUnauthorized: false }, + } as unknown as string[]); + ws.binaryType = "arraybuffer"; + const queue: (string | Uint8Array)[] = []; + const waiters: ((message: string | Uint8Array) => void)[] = []; + ws.onmessage = event => { + const message = typeof event.data === "string" ? event.data : new Uint8Array(event.data as ArrayBuffer); + const waiter = waiters.shift(); + if (waiter) waiter(message); + else queue.push(message); + }; + const closed = new Promise<{ code: number; reason: string }>(resolve => { + ws.onclose = event => resolve({ code: event.code, reason: event.reason }); + }); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("app websocket failed to open")); + }); + const next = () => { + const queued = queue.shift(); + if (queued !== undefined) return Promise.resolve(queued); + return new Promise(resolve => waiters.push(resolve)); + }; + return { ws, next, closed }; +} + +/** Send a bare upgrade over raw TLS and return the listener's response head. */ +function rawUpgrade(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const socket: TLSSocket = connectTls({ host: "127.0.0.1", port, rejectUnauthorized: false }, () => { + socket.write( + `GET ${path} HTTP/1.1\r\nHost: chatgpt.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + }); + let buffer = ""; + socket.on("data", (chunk: Buffer) => { + buffer += chunk.toString("latin1"); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + socket.destroy(); + resolve(buffer.slice(0, end)); + }); + socket.on("error", reject); + }); +} + +async function waitFor(check: () => boolean, label: string): Promise { + const deadline = Date.now() + 3000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`); + await Bun.sleep(10); + } +} + +function listenerFor(wsUpstream: DialUpstreamOptions) { + return startChatgptUnblockListener({ leaf, wsUpstream: { ca: ca.certPem, connectTimeoutMs: 2000, ...wsUpstream } }); +} + +describe("chatgpt unblock websocket relay", () => { + const upstream = startFakeUpstream(); + const direct = () => ({ proxy: null, target: { host: "127.0.0.1", port: upstream.port() } }); + const cleanups: (() => void)[] = []; + + beforeAll(async () => { + await waitFor(() => upstream.server.listening, "fake upstream"); + }); + + afterAll(() => { + for (const cleanup of cleanups) cleanup(); + upstream.server.close(); + }); + + test("relays an upgrade end to end: handshake, early frames, text, binary, fragments, pings, upstream close", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream?x=1"); + + // Upstream picked the last offered subprotocol; the app must see that choice. + expect(app.ws.protocol).toBe("realtime-v2"); + const head = upstream.log.heads.at(-1)!; + expect(head.split("\r\n")[0]).toBe("GET /dictation/stream?x=1 HTTP/1.1"); + expect(head).toMatch(/^host: chatgpt\.com$/im); + expect(head).toMatch(/^cookie: session=abc123$/im); + expect(head).toMatch(/^origin: https:\/\/chatgpt\.com$/im); + expect(head).toMatch(/^sec-websocket-protocol: realtime-v1, realtime-v2$/im); + expect(head).toMatch(/^sec-websocket-version: 13$/im); + // The relay cannot inflate, so the app's permessage-deflate offer must not reach upstream. + expect(head).not.toMatch(/sec-websocket-extensions/i); + + expect(await app.next()).toBe("hello-early"); + + app.ws.send("hi"); + expect(await app.next()).toBe("up:hi"); + + // Binary stays binary and arrives byte-exact. + app.ws.send(new Uint8Array([1, 2, 3, 250])); + expect(Array.from(await app.next() as Uint8Array)).toEqual([1, 2, 3, 250]); + + app.ws.send("frag"); + expect(Buffer.from(await app.next() as Uint8Array).toString()).toBe("abcdef"); + await waitFor(() => upstream.log.pongs.includes("hb"), "pong for the interleaved ping"); + + app.ws.send("close"); + expect(await app.closed).toEqual({ code: 4001, reason: "bye" }); + }); + + test("forwards the app's close to upstream with its code and reason", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + const before = upstream.log.closes.length; + app.ws.close(4000, "cya"); + await waitFor(() => upstream.log.closes.length > before, "upstream close frame"); + expect(upstream.log.closes.at(-1)).toEqual({ code: 4000, reason: "cya" }); + }); + + test("a refused upstream upgrade reaches the app as the upstream's status", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/refuse"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 403/); + expect(head).toMatch(/^cf-ray: test-ray$/im); + // The relay replaces the body, so the upstream's encoding must not describe it. + expect(head).not.toMatch(/content-encoding/i); + }); + + test("an unreachable upstream fails the upgrade with 502", async () => { + const closed = createNetServer(); + await new Promise(resolve => closed.listen(0, "127.0.0.1", resolve)); + const deadPort = (closed.address() as AddressInfo).port; + await new Promise(resolve => closed.close(() => resolve())); + const listener = listenerFor({ proxy: null, target: { host: "127.0.0.1", port: deadPort } }); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/dictation/stream"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 502/); + }); + + test("dials chatgpt.com through an HTTP CONNECT proxy", async () => { + const proxy = startConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "connect proxy"); + const listener = listenerFor({ proxy: `http://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-connect"); + expect(await app.next()).toBe("up:via-connect"); + expect(proxy.requests).toEqual(["CONNECT chatgpt.com:443 HTTP/1.1"]); + app.ws.close(); + }); + + test("sends Proxy-Authorization when the CONNECT proxy URL carries credentials", async () => { + const proxy = startConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authed connect proxy"); + const listener = listenerFor({ proxy: `http://user:p%40ss@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-auth"); + expect(await app.next()).toBe("up:via-auth"); + expect(proxy.heads[0]).toContain("Proxy-Authorization: Basic " + Buffer.from("user:p@ss").toString("base64")); + app.ws.close(); + }); + + test("TLS-wraps an https:// proxy before the CONNECT handshake", async () => { + const proxy = startTlsConnectProxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "tls connect proxy"); + const listener = listenerFor({ proxy: `https://127.0.0.1:${(proxy.server.address() as AddressInfo).port}`, ca: `${ca.certPem}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-tls"); + expect(await app.next()).toBe("up:via-tls"); + expect(proxy.requests).toEqual(["CONNECT chatgpt.com:443 HTTP/1.1"]); + app.ws.close(); + }); + + test("dials chatgpt.com through a SOCKS5 proxy", async () => { + const proxy = startSocks5Proxy(upstream.port); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-socks"); + expect(await app.next()).toBe("up:via-socks"); + expect(proxy.targets).toEqual(["chatgpt.com:443"]); + app.ws.close(); + }); + + test("a credentialed SOCKS5 proxy authenticates over RFC 1929 before CONNECT", async () => { + const proxy = startSocks5Proxy(upstream.port, { username: "user", password: "p@ss w0rd" }); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authenticated socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://user:p%40ss%20w0rd@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("via-auth-socks"); + expect(await app.next()).toBe("up:via-auth-socks"); + // Percent-encoded credentials decoded, subnegotiated exactly once, CONNECT after. + expect(proxy.authAttempts).toEqual(["user:p@ss w0rd"]); + expect(proxy.targets).toEqual(["chatgpt.com:443"]); + app.ws.close(); + }); + + test("rejected RFC 1929 credentials fail the upgrade with 502", async () => { + const proxy = startSocks5Proxy(upstream.port, { username: "user", password: "real-secret" }); + cleanups.push(() => proxy.server.close()); + await waitFor(() => proxy.server.listening, "authenticated socks5 proxy"); + const listener = listenerFor({ proxy: `socks5://user:wrong@127.0.0.1:${(proxy.server.address() as AddressInfo).port}` }); + cleanups.push(() => listener.stop(true)); + const head = await rawUpgrade(listener.port!, "/dictation/stream"); + expect(head.split("\r\n")[0]).toMatch(/^HTTP\/1\.1 502/); + // The proxy saw the failed attempt and no CONNECT followed it. + expect(proxy.authAttempts).toEqual(["user:wrong"]); + expect(proxy.targets).toEqual([]); + }); + + test("a fragmented message whose payload passes the byte ceiling fails with 1009", async () => { + const listener = listenerFor(direct()); + cleanups.push(() => listener.stop(true)); + const app = await openApp(listener.port!, "/dictation/stream"); + expect(await app.next()).toBe("hello-early"); + app.ws.send("bigfrag"); + const closed = await app.closed; + expect(closed.code).toBe(1009); + }); +}); + +describe("chatgpt unblock websocket relay helpers", () => { + const upgrade = (headers: Record) => new Request("https://chatgpt.com/x", { headers }); + + test("only version-13 websocket upgrades are taken by the relay", () => { + expect(isRelayableUpgrade(upgrade({ upgrade: "websocket", "sec-websocket-version": "13" }))).toBe(true); + expect(isRelayableUpgrade(upgrade({ upgrade: "WebSocket", "sec-websocket-version": "13" }))).toBe(true); + expect(isRelayableUpgrade(upgrade({ upgrade: "websocket", "sec-websocket-version": "8" }))).toBe(false); + expect(isRelayableUpgrade(upgrade({ upgrade: "h2c" }))).toBe(false); + expect(isRelayableUpgrade(upgrade({}))).toBe(false); + }); + + test("close codes that may not appear on the wire are replaced", () => { + expect(sendableCloseCode(1000)).toBe(1000); + expect(sendableCloseCode(1011)).toBe(1011); + expect(sendableCloseCode(4000)).toBe(4000); + for (const reserved of [1004, 1005, 1006, 1015, 999, 2000, 5000]) expect(sendableCloseCode(reserved)).toBe(1000); + }); +}); + +describe("chatgpt unblock upstream handshake reader", () => { + /** An emitter that stands in for the tunnel socket; an unhandled 'error' on it throws. */ + function fakeTunnel(): TLSSocket { + const emitter = new EventEmitter() as EventEmitter & { pause(): void; setTimeout(ms: number, cb?: () => void): void }; + emitter.pause = () => {}; + emitter.setTimeout = () => {}; + return emitter as unknown as TLSSocket; + } + + test("a tunnel error after the 101 head, before attach(), does not throw", async () => { + const socket = fakeTunnel(); + const read = readResponseHead(socket, 1000); + socket.emit("data", Buffer.from("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n")); + expect((await read)?.head).toStartWith("HTTP/1.1 101"); + expect(() => socket.emit("error", new Error("ECONNRESET"))).not.toThrow(); + }); + + test("a tunnel error after a failed read does not throw either", async () => { + const socket = fakeTunnel(); + const read = readResponseHead(socket, 1000); + socket.emit("close"); + expect(await read).toBeNull(); + expect(() => socket.emit("error", new Error("ECONNRESET"))).not.toThrow(); + }); +}); diff --git a/tests/clients/desktop-unblock-ws-upstream.test.ts b/tests/clients/desktop-unblock-ws-upstream.test.ts new file mode 100644 index 00000000000..a784cb6a383 --- /dev/null +++ b/tests/clients/desktop-unblock-ws-upstream.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import type { AddressInfo, Server, Socket } from "node:net"; +import { dialUpstreamTunnel, proxyDialPort } from "../../src/chatgpt/desktop-unblock/ws-upstream"; + +describe("ChatGPT intercept upstream proxy dial", () => { + test("an omitted or scheme-default proxy port dials the scheme default", () => { + expect(proxyDialPort(new URL("http://proxy.example"), "http-connect")).toBe(80); + expect(proxyDialPort(new URL("http://proxy.example:80"), "http-connect")).toBe(80); + expect(proxyDialPort(new URL("http://proxy.example:3128"), "http-connect")).toBe(3128); + expect(proxyDialPort(new URL("https://proxy.example"), "http-connect")).toBe(443); + expect(proxyDialPort(new URL("socks5://proxy.example"), "socks5")).toBe(1080); + expect(proxyDialPort(new URL("socks5h://proxy.example:9050"), "socks5")).toBe(9050); + }); + + /** A proxy that reads the first handshake bytes and then closes cleanly without replying. */ + async function closingProxy(): Promise<{ server: Server; port: number }> { + const server = createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.end()); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { server, port: (server.address() as AddressInfo).port }; + } + + for (const scheme of ["http", "socks5"] as const) { + test(`a ${scheme} proxy closing mid-handshake fails the dial before the timeout`, async () => { + const { server, port } = await closingProxy(); + try { + const started = Date.now(); + const tunnel = await dialUpstreamTunnel({ proxy: `${scheme}://127.0.0.1:${port}`, connectTimeoutMs: 8_000 }); + expect(tunnel).toBeNull(); + expect(Date.now() - started).toBeLessThan(4_000); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + }); + } + + /** Capture CONNECT bytes, refuse the tunnel, and require the client to close its socket. */ + async function observingProxy() { + const sockets = new Set(); + const chunks: Buffer[] = []; + let closed!: () => void; + const peerClosed = new Promise(resolve => { closed = resolve; }); + const server = createServer(socket => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => { sockets.delete(socket); closed(); }); + socket.on("data", (chunk: Buffer) => { + chunks.push(chunk); + if (Buffer.concat(chunks).includes("\r\n\r\n")) { + socket.write("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + } + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { + port: (server.address() as AddressInfo).port, + written: () => Buffer.concat(chunks).toString("latin1"), + async expectClosed() { + let timer: ReturnType | undefined; + try { + await Promise.race([ + peerClosed, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("proxy socket was not closed")), 1_000); + }), + ]); + } finally { clearTimeout(timer); } + }, + async stop() { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + }, + }; + } + + for (const [label, userinfo, decoded] of [ + ["no credentials", "", null], + ["username only", "user@", "user:"], + ["password only", ":pass@", ":pass"], + ["percent-encoded credentials", "u%40ser:p%3Ass@", "u@ser:p:ss"], + ] as const) { + test(`HTTP CONNECT writes the expected authorization with ${label}`, async () => { + const proxy = await observingProxy(); + try { + expect(await dialUpstreamTunnel({ proxy: `http://${userinfo}127.0.0.1:${proxy.port}` })).toBeNull(); + await proxy.expectClosed(); + const authorization = decoded === null ? "" : `Proxy-Authorization: Basic ${Buffer.from(decoded).toString("base64")}\r\n`; + expect(proxy.written()).toBe("CONNECT chatgpt.com:443 HTTP/1.1\r\nHost: chatgpt.com:443\r\n" + + `Proxy-Connection: Keep-Alive\r\n${authorization}\r\n`); + } finally { await proxy.stop(); } + }); + } + + for (const userinfo of ["%zz:pass", "user:%zz", ":%zz"]) { + test(`HTTP CONNECT closes without sending malformed credentials (${userinfo})`, async () => { + const proxy = await observingProxy(); + try { + expect(await dialUpstreamTunnel({ proxy: `http://${userinfo}@127.0.0.1:${proxy.port}` })).toBeNull(); + await proxy.expectClosed(); + expect(proxy.written()).toBe(""); + } finally { await proxy.stop(); } + }); + } +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..ee8f21bdac6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -95,6 +95,23 @@ "codex-shim-overlay-readiness.test.ts": "codex-integration", "codex-shim-overlay-candidates.test.ts": "codex-integration", "droid-normalized-reasoning-defaults.test.ts": "clients", + "desktop-unblock-entry-proxy.test.ts": "clients", + "desktop-unblock-pac-runtime.test.ts": "clients", + "desktop-unblock-pac.test.ts": "clients", + "desktop-rewrite.test.ts": "clients", + "desktop-unblock-ca-trust.test.ts": "clients", + "desktop-unblock-config-boundary.test.ts": "clients", + "desktop-unblock-launch-script.test.ts": "clients", + "desktop-unblock-listener.test.ts": "clients", + "desktop-unblock-runtime.test.ts": "clients", + "desktop-unblock-watcher-install.test.ts": "clients", + "desktop-unblock-lifecycle.test.ts": "clients", + "desktop-unblock-readiness-auth.test.ts": "clients", + "desktop-unblock-bundle-preflight.test.ts": "clients", + "desktop-unblock-ws-frame.test.ts": "clients", + "desktop-unblock-ws-relay.test.ts": "clients", + "desktop-unblock-ws-upstream.test.ts": "clients", + "socks5-handshake.test.ts": "lib", "responses-combo-exhausted-error.test.ts": "responses", "responses-combo-run-turn-classified-failure.test.ts": "responses", "cli-config-default-show.test.ts": "cli", diff --git a/tests/helpers/desktop-app-server-shim-command-child.ts b/tests/helpers/desktop-app-server-shim-command-child.ts index 72362b026b3..462ae5a52a1 100644 --- a/tests/helpers/desktop-app-server-shim-command-child.ts +++ b/tests/helpers/desktop-app-server-shim-command-child.ts @@ -62,7 +62,7 @@ mock.module("node:child_process", () => ({ ...childProcess, spawnSync(command: s if (command === "/usr/bin/open" && scenario.openFails) return { status: 1, stdout: "", stderr: "fixture open failed" }; return { status: 0, stdout: "", stderr: "" }; } })); -mock.module("../../src/config", () => ({ loadConfig: () => ({ chatgptDesktop: { appServerShim: scenario.flag === true } }) })); +mock.module("../../src/config", () => ({ loadConfig: () => ({ chatgptDesktop: { appServerShim: scenario.flag === true, unblockSend: scenario.intercept === true } }) })); // A refused launch explains a dropped chatgptDesktop block from the config file; never read this // machine's config here. mock.module("../../src/config/diagnostics", () => ({ readConfigFileSnapshot: () => ({ raw: scenario.configRaw }) })); @@ -78,6 +78,36 @@ mock.module("../../src/chatgpt/app-server-shim/launcher", () => ({ resolveChatgptCodexBinary: () => scenario.missingBinary ? null : binary, writeChatgptShimLauncher: () => { fs.writeFileSync(launcher, "new launcher"); }, })); +// The send-unblock intercept is out of scope here, and its live probes (listening port, launchd agent, +// keychain trust, a running opencodex) must never reach this machine. Its watcher is absent unless +// the scenario loads one. +mock.module("../../src/chatgpt/desktop-unblock/launch-watcher", () => ({ + chatgptAppCommandLine: () => null, + chatgptCommandLineHasPac: () => false, + chatgptCommandLineHasRule: () => false, + chatgptUnblockWatcherLoaded: () => scenario.watcherLoaded === true, + chatgptUnblockWatcherStatus: () => { + if (scenario.watcherFilesUnreadable) throw new Error("fixture: installed watcher files cannot be read"); + return { + scriptInstalled: false, plistInstalled: false, agentLoaded: scenario.watcherLoaded === true, + scriptUpToDate: false, plistUpToDate: false, + }; + }, + installChatgptUnblockWatcher: (options: { appPath: string }) => { + calls.push({ command: "watcher-install", args: [options.appPath] }); + }, + launchChatgptWithRule: (...args: unknown[]) => { + calls.push({ command: "intercept-launch", args: [args[4] as string] }); + return { ok: true, output: "" }; + }, + probeChatgptUnblockListener: async () => ({ state: scenario.intercept ? "ours" : "down" }), + uninstallChatgptUnblockWatcher: () => undefined, +})); +mock.module("../../src/server/proxy-liveness", () => ({ findLiveProxy: async () => null })); +mock.module("../../src/chatgpt/desktop-unblock/ca-trust", () => ({ + inspectChatgptCaTrust: async () => "missing", + chatgptCaTrustCommand: () => "", +})); const { handleChatgptCommand } = await import("../../src/cli/chatgpt-command"); -const code = await handleChatgptCommand([scenario.sub ?? "restore"], "darwin"); +const code = await handleChatgptCommand([scenario.sub ?? "restore", ...(scenario.sub === "install-watcher" ? ["--yes"] : [])], "darwin"); console.log(JSON.stringify({ code, calls, launcherExists: fs.existsSync(launcher), launcherContent: fs.existsSync(launcher) ? fs.readFileSync(launcher, "utf8") : null, root, shell, binary })); diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 7cbbc315b5c..f8a84a5ff79 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1045,7 +1045,7 @@ describe("activation window stays synchronous", () => { "(...).then()": "Promise.then on the fire-and-forget `import('../codex/plan-from-token')` chain. then() registers a callback and returns immediately; the callback is a nested function this scan skips. Awaiting the import would already fail Guard 3.", "(...).catch()": "Promise.catch on that same dynamic-import chain. Same fire-and-forget: it cannot suspend startServer.", "backgroundLifecycle.scheduleStartupRun()": "src/server/background-lifecycle.ts owns this object method. The call site cannot resolve the declaration statically; scheduleStartupRun is declared `(): void` and is documented as never blocking listen.", - "optionalListeners.start()": "Instance method on OptionalListenerSet. Declared `(ctx): void`; it binds the optional link listener synchronously and starts the existing Claude intercept fire-and-forget lifecycle. Its stop() runs inside the async stop wrapper, which this scan skips.", + "optionalListeners.start()": "Instance method on OptionalListenerSet. Declared `(ctx): void`; it binds the optional link listener synchronously and starts the existing Claude intercept and opt-in ChatGPT send-unblock lifecycles fire-and-forget; both retain their start promise and degrade failures to warnings. Its stop() runs inside the async stop wrapper, which this scan skips.", "spendLedgerLifecycle.track()": "Instance method on the lifecycle from acquireSpendLedgerServerLifecycle in src/server/index/spend-ledger-lifecycle.ts, called on each listener as it is created. It binds the listener's stop, records a rollback closure and returns the same server; it is declared `(server: T): T` and contains no await. An `await spendLedgerLifecycle.track(...)` would already fail Guard 3. The lifecycle's release() is not here because it is called inside the async stop wrapper, which this scan skips as a nested function.", }; diff --git a/tests/lib/socks5-handshake.test.ts b/tests/lib/socks5-handshake.test.ts new file mode 100644 index 00000000000..ffb392f6e44 --- /dev/null +++ b/tests/lib/socks5-handshake.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from "bun:test"; +import { socks5Credentials, socks5Handshake, Socks5HandshakeError } from "../../src/lib/socks5-handshake"; + +/** + * Byte-level contract of the SOCKS5 handshake the raw transports share: method + * negotiation, RFC 1929 subnegotiation, CONNECT framing, and reply parsing. + * `tests/lib/socks5-fetch.test.ts` and `tests/clients/desktop-unblock-ws-relay.test.ts` + * pin each transport end to end; these cases drive the handshake alone so every reply + * shape is reachable without a socket. + */ + +/** A reader fed pre-scripted proxy replies; records what the handshake writes. */ +class ScriptedReader { + private sent: Buffer = Buffer.alloc(0); + private replies: Buffer[] = []; + private failure: Error | undefined; + private waiter: { bytes: number; resolve: (value: Buffer) => void; reject: (error: Error) => void } | null = null; + + takeWritten(): Buffer { + const value = this.sent; + this.sent = Buffer.alloc(0); + return value; + } + + reply(...chunks: number[][]): void { + for (const chunk of chunks) this.replies.push(Buffer.from(chunk)); + this.flush(); + } + + fail(error: Error): void { + this.failure = error; + this.flush(); + } + + private flush(): void { + if (!this.waiter) return; + if (this.failure) { + const waiter = this.waiter; + this.waiter = null; + waiter.reject(this.failure); + return; + } + const available = this.replies.reduce((total, reply) => total + reply.byteLength, 0); + if (available >= this.waiter.bytes) { + const waiter = this.waiter; + this.waiter = null; + waiter.resolve(this.consume(waiter.bytes)); + } + } + + private consume(bytes: number): Buffer { + const out: Buffer[] = []; + let remaining = bytes; + while (remaining > 0) { + const next = this.replies[0]!; + if (next.byteLength <= remaining) { + this.replies.shift(); + out.push(next); + remaining -= next.byteLength; + } else { + out.push(next.subarray(0, remaining)); + this.replies[0] = next.subarray(remaining); + remaining = 0; + } + } + return Buffer.concat(out); + } + + write(bytes: Uint8Array): void { + this.sent = Buffer.concat([this.sent, Buffer.from(bytes)]); + } + + readExact(bytes: number): Promise { + if (this.failure) return Promise.reject(this.failure); + const available = this.replies.reduce((total, reply) => total + reply.byteLength, 0); + if (available >= bytes) return Promise.resolve(this.consume(bytes)); + return new Promise((resolve, reject) => { + this.waiter = { bytes, resolve, reject }; + }); + } +} + +const AUTH = socks5Credentials(new URL("socks5://user:pass@proxy.example.test:1080")); +const NO_AUTH = socks5Credentials(new URL("socks5://proxy.example.test:1080")); +const TARGET = { host: "chatgpt.com", port: 443 }; +const CONNECT_REPLY = [0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]; + +function connectRequest(host: string, port: number): Buffer { + const hostBytes = Buffer.from(host, "utf8"); + return Buffer.from([0x05, 0x01, 0x00, 0x03, hostBytes.byteLength, ...hostBytes, port >> 8, port & 0xff]); +} + +describe("shared socks5 handshake", () => { + test("offers no-auth only without credentials, user-pass alongside them with credentials", async () => { + const plain = new ScriptedReader(); + plain.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(plain, TARGET, NO_AUTH); + expect([...plain.takeWritten()]).toEqual([0x05, 0x01, 0x00, ...connectRequest(TARGET.host, TARGET.port)]); + + const credentialed = new ScriptedReader(); + credentialed.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(credentialed, TARGET, AUTH); + // Two methods offered: NO-AUTH keeps a proxy that ignores credentials usable. + // NO-AUTH picked: no credential bytes are ever sent unasked. + expect([...credentialed.takeWritten()]).toEqual([ + 0x05, 0x02, 0x00, 0x02, ...connectRequest(TARGET.host, TARGET.port), + ]); + }); + + test("performs the RFC 1929 subnegotiation when the proxy picks user-pass", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0x00], CONNECT_REPLY); + await socks5Handshake(reader, TARGET, AUTH); + const written = reader.takeWritten(); + // After the 4-byte greeting: version-1 subnegotiation, ulen uname plen passwd. + const subnegotiation = written.subarray(4, 4 + 1 + 1 + "user".length + 1 + "pass".length); + expect([...subnegotiation]).toEqual([ + 0x01, 4, ...Buffer.from("user"), 4, ...Buffer.from("pass"), + ]); + }); + + test("an omitted password has the same wire bytes as a username-only proxy URL", async () => { + const expected = Buffer.from([ + 0x05, 0x02, 0x00, 0x02, + 0x01, 4, ...Buffer.from("user"), 0, + ...connectRequest(TARGET.host, TARGET.port), + ]); + for (const credentials of [ + socks5Credentials(new URL("socks5://user@proxy.example.test:1080")), + { username: Buffer.from("user") }, + ]) { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0x00], CONNECT_REPLY); + await socks5Handshake(reader, TARGET, credentials); + expect(reader.takeWritten()).toEqual(expected); + } + }); + + test("a rejected empty password stops before CONNECT", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0x01]); + await expect(socks5Handshake(reader, TARGET, { username: Buffer.from("user") })) + .rejects.toThrow("SOCKS5 proxy authentication failed"); + expect(reader.takeWritten()).toEqual(Buffer.from([ + 0x05, 0x02, 0x00, 0x02, 0x01, 4, ...Buffer.from("user"), 0, + ])); + }); + + test("a failure reply to the subnegotiation is an authentication failure", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x02], [0x01, 0xff]); + await expect(socks5Handshake(reader, TARGET, AUTH)) + .rejects.toThrow("SOCKS5 proxy authentication failed"); + }); + + test("a proxy picking a method we did not offer is refused before CONNECT", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x80]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)) + .rejects.toThrow("does not accept an offered authentication method"); + // Refusal must not be followed by a CONNECT request into a dead conversation. + expect(reader.takeWritten()).toEqual(Buffer.from([0x05, 0x01, 0x00])); + }); + + test("connect failures carry the proxy's reply code", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x00], [0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)) + .rejects.toThrow("refused the connection (code 1)"); + }); + + test("malformed replies are named errors: bad version, reserved byte, unknown address type", async () => { + const badVersion = new ScriptedReader(); + badVersion.reply([0x04, 0x00]); + await expect(socks5Handshake(badVersion, TARGET, NO_AUTH)) + .rejects.toThrow("invalid greeting"); + + const badReserved = new ScriptedReader(); + badReserved.reply([0x05, 0x00], [0x05, 0x00, 0x01, 0x01, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(badReserved, TARGET, NO_AUTH)) + .rejects.toThrow("invalid address type or reserved byte"); + + const badAddress = new ScriptedReader(); + badAddress.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x07, 0, 0, 0, 0, 0, 0]); + await expect(socks5Handshake(badAddress, TARGET, NO_AUTH)) + .rejects.toThrow("invalid address type or reserved byte"); + }); + + test("consumes the bound-address tail for every address type", async () => { + // IPv4: 4 address bytes + 2 port bytes. A shorter reply left unread would corrupt + // the stream the TLS layer inherits. + const ipv4 = new ScriptedReader(); + ipv4.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x01, 10, 1, 2, 3, 0x01, 0xbb]); + await socks5Handshake(ipv4, TARGET, NO_AUTH); + + const domain = new ScriptedReader(); + domain.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x03, 9, ...Buffer.from("proxy.example"), 0x01, 0xbb]); + await socks5Handshake(domain, TARGET, NO_AUTH); + + const ipv6 = new ScriptedReader(); + ipv6.reply([0x05, 0x00], [0x05, 0x00, 0x00, 0x04, ...Array.from({ length: 16 }, (_, i) => i), 0x01, 0xbb]); + await socks5Handshake(ipv6, TARGET, NO_AUTH); + }); + + test("a reader that stops answering rejects the pending step", async () => { + const reader = new ScriptedReader(); + const pending = socks5Handshake(reader, TARGET, NO_AUTH); + reader.fail(new Error("handshake timeout")); + await expect(pending).rejects.toThrow("handshake timeout"); + }); + + test("a domain target longer than one length byte is refused before anything is written", async () => { + const reader = new ScriptedReader(); + await expect(socks5Handshake(reader, { host: "a".repeat(256), port: 443 }, NO_AUTH)) + .rejects.toThrow("target hostname is too long"); + expect(reader.takeWritten().byteLength).toBe(0); + }); + + test("credentials with invalid percent encoding or oversized parts are refused", () => { + expect(() => socks5Credentials(new URL("socks5://%zz@proxy.example.test:1080"))) + .toThrow("invalid percent encoding"); + expect(() => socks5Credentials(new URL(`socks5://${"u".repeat(256)}@proxy.example.test:1080`))) + .toThrow("must each fit in 255 UTF-8 bytes"); + }); + + test("errors are the named handshake type", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x80]); + await expect(socks5Handshake(reader, TARGET, NO_AUTH)).rejects.toBeInstanceOf(Socks5HandshakeError); + }); + + test("writes the CONNECT request in the expected domain framing", async () => { + const reader = new ScriptedReader(); + reader.reply([0x05, 0x00], CONNECT_REPLY); + await socks5Handshake(reader, TARGET, NO_AUTH); + const written = reader.takeWritten(); + // 3-byte greeting (05, nmethods, one method) precedes the CONNECT request. + expect([...written.subarray(3)]).toEqual([...connectRequest("chatgpt.com", 443)]); + }); +});