From 4e0e8f4b13f1411651fa5502ecec69f4a2c16358 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 16:25:51 +0900 Subject: [PATCH 1/4] feat(chatgpt): add experimental macOS app-server quota-gate shim Split the app-server shim out of #5947. ocx chatgpt launch|restore|status relaunches ChatGPT with CODEX_CLI_PATH pointing at a generated launcher that execs the bundled codex app-server unchanged and pipes only its stdout through the hidden 'ocx internal chatgpt-app-server-filter'. The filter clears the plain-quota gate in account/rateLimits/read and account/rateLimits/updated and passes every other line through byte for byte. The launcher falls back to the untouched binary when the platform check, runtime or filter self-test fails. Default off behind chatgptDesktop.appServerShim; macOS only; experimental. Refs #6196 Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- docs-site/astro.config.mjs | 1 + .../content/docs/guides/chatgpt-desktop.md | 87 ++++++++ scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 14 +- .../app-server-shim/app-server-rewrite.ts | 45 ++++ src/chatgpt/app-server-shim/filter.ts | 101 +++++++++ src/chatgpt/app-server-shim/gate-rewrite.ts | 98 +++++++++ src/chatgpt/app-server-shim/launcher.ts | 42 ++++ src/cli/capabilities.ts | 7 + src/cli/chatgpt-command.ts | 99 +++++++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/internal-command.ts | 17 +- src/cli/registry.ts | 6 + src/config/diagnostics.ts | 8 + src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 3 + src/types/config.ts | 2 + structure/INDEX.md | 2 + structure/clients/chatgpt-desktop.md | 47 +++++ structure/config.md | 2 +- structure/manifest.json | 9 + structure/runtime.md | 2 +- .../desktop-app-server-shim-launcher.test.ts | 92 +++++++++ tests/clients/desktop-app-server-shim.test.ts | 192 ++++++++++++++++++ tests/clients/desktop-chatgpt-config.test.ts | 40 ++++ tests/fixtures/test-layout-expected.json | 3 + 27 files changed, 920 insertions(+), 9 deletions(-) create mode 100644 docs-site/src/content/docs/guides/chatgpt-desktop.md create mode 100644 src/chatgpt/app-server-shim/app-server-rewrite.ts create mode 100644 src/chatgpt/app-server-shim/filter.ts create mode 100644 src/chatgpt/app-server-shim/gate-rewrite.ts create mode 100644 src/chatgpt/app-server-shim/launcher.ts create mode 100644 src/cli/chatgpt-command.ts create mode 100644 structure/clients/chatgpt-desktop.md create mode 100644 tests/clients/desktop-app-server-shim-launcher.test.ts create mode 100644 tests/clients/desktop-app-server-shim.test.ts create mode 100644 tests/clients/desktop-chatgpt-config.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 0c5f250128f..61b3274da9d 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -108,6 +108,7 @@ export default defineConfig({ { label: "Grok Build", translations: { fr: "Grok Build", ko: "Grok Build", "zh-CN": "Grok Build", "zh-TW": "Grok Build", ru: "Grok Build", ja: "Grok Build", tr: "Grok Build" }, slug: "guides/grok-build" }, { label: "opencode", translations: { fr: "opencode", ko: "opencode", "zh-CN": "opencode", "zh-TW": "opencode", ru: "opencode", ja: "opencode", tr: "opencode" }, slug: "guides/opencode" }, { label: "Pi", translations: { fr: "Pi", ko: "Pi", "zh-CN": "Pi", "zh-TW": "Pi", ru: "Pi", ja: "Pi", tr: "Pi" }, slug: "guides/pi" }, + { label: "ChatGPT Desktop (experimental)", slug: "guides/chatgpt-desktop" }, { label: "Integrations", translations: { fr: "Intégrations", ko: "연동", "zh-CN": "集成", "zh-TW": "整合", ru: "Интеграции", ja: "連携", tr: "Entegrasyonlar" }, slug: "guides/integrations" }, { label: "MiniMax clients", translations: { fr: "Clients MiniMax", ko: "MiniMax 클라이언트", "zh-CN": "MiniMax 客户端", "zh-TW": "MiniMax 客戶端", ru: "Клиенты MiniMax", ja: "MiniMax クライアント", tr: "MiniMax İstemcileri" }, slug: "guides/minimax" }, { label: "Sidecars: Web Search & Vision", translations: { fr: "Services auxiliaires : recherche web et vision", ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", "zh-TW": "邊車:網路搜尋與視覺", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン", tr: "Sidecar'lar: Web Arama ve Görme" }, slug: "guides/sidecars" }, diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..a8fdc61c87b --- /dev/null +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -0,0 +1,87 @@ +--- +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. +--- + +This experiment is **macOS only and off by default**. It filters the bundled ChatGPT +app-server's JSON-RPC stdout to open known plain-quota gates. It does not increase +an account's quota or make an upstream service accept a request it refuses. + +Enable it in your OpenCodex `config.json`: + +```json +{ + "chatgptDesktop": { "appServerShim": true } +} +``` + +Then run: + +```bash +ocx chatgpt launch +ocx chatgpt status +``` + +`launch` creates an executable launcher under the OpenCodex config directory, +quits ChatGPT if it is running, and relaunches it with +`open -a ChatGPT --env CODEX_CLI_PATH=`. Save ongoing work first: this +restarts the app. It does not require a running OpenCodex proxy. + +To remove the launcher and relaunch without the override: + +```bash +ocx chatgpt restore +``` + +Restore leaves the config flag as configured. Set `chatgptDesktop.appServerShim` +to `false` or remove it to disable future explicit shim launches. Normal launches +from Dock or Spotlight do not apply the shim automatically. + +## Rewrite boundary + +Only `account/rateLimits/updated` notifications and responses whose top-level +result contains `rateLimits`, `rateLimitsByLimitId`, or `ordinaryUsageAllowed` +are eligible. Plain `rate_limit_reached` markers are cleared; known quota gate +flags (`allowed`, `limit_reached` / `limitReached`, `ordinaryUsageAllowed`) are +opened where the payload permits it. `ordinaryUsageAllowed` requires plain-quota +exhaustion evidence. Workspace, credit, unknown reached-type and spend-control +restrictions keep the usage gate closed. + +Displayed usage stays honest: percentages, reset times, window durations, plan +information and other display fields stay as received. Unrelated JSON-RPC +messages, nested tool output, conversation send-block metadata and malformed +lines pass through. Only changed lines are serialized again; other bytes retain +their original encoding and line endings. Stdin, stderr and the real binary's +exit status retain their direct connection to the app. + +## Executable and environment security + +The generated launcher has mode `0755` and embeds the current OpenCodex executable +and, for source installs, the CLI entry path. `CODEX_CLI_PATH` tells ChatGPT to +execute this launcher instead of its bundled binary directly. Keep the launcher, +its config directory, and the OpenCodex installation under your control: changing +these executable paths changes code the app runs. The launcher still `exec`s the +bundled binary at its standard `/Applications/ChatGPT.app` location. + +This integration 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. + +## Failure behavior and known limits + +A failed precondition or a failed self-test runs the original binary with untouched stdout. +A filter that passes the self-test and then dies mid-session closes the pipe. +Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a write error and Desktop respawns it through the same launcher. +The filter's passthrough mode limits this to an exit/crash case: a rewrite exception +passes its line through, and an unexpected rewrite-machinery failure switches the +remaining stream to raw bytes. + +The experiment depends on the bundled binary path, the app honoring +`CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved +or removed OpenCodex installation fails the launcher preflight and runs the +original binary. Run `ocx chatgpt launch` again after relocating the installation. + +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. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b4d27b9425e..08e48f415a7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,9 @@ "version": 1, "root": "tests", "explicit": { + "desktop-app-server-shim.test.ts": "clients", + "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-chatgpt-config.test.ts": "clients", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 15c52cd6c71..3b54d20e6b0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -513,6 +513,16 @@ JSON mode: `payload`. Each of these writes. Check the flags column before running one unattended. +### `ocx chatgpt` + +Experimental ChatGPT app-server shim: launch, restore or status (macOS only). + +Drives no management route. + +JSON mode: `none`. + +- Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher. + ### `ocx link issue` Issue one link credential and record its tunnel metadata. @@ -1207,6 +1217,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 67 -- of those, state-changing: 37 +- declared capabilities: 68 +- of those, state-changing: 38 - head-resolved invocations: 2 diff --git a/src/chatgpt/app-server-shim/app-server-rewrite.ts b/src/chatgpt/app-server-shim/app-server-rewrite.ts new file mode 100644 index 00000000000..ae0d97a82b5 --- /dev/null +++ b/src/chatgpt/app-server-shim/app-server-rewrite.ts @@ -0,0 +1,45 @@ +import { unlockRateLimitGate } from "./gate-rewrite"; + +/** + * Line-level rewrite for the app-server's JSON-RPC output. + * + * Only the two messages that carry the composer's gate are rewritten: the + * `account/rateLimits/updated` notification and a result whose top level holds the rate-limit + * snapshot (the `account/rateLimits/read` response). Every other message is left alone, including + * thread items and tool results that happen to nest a `rate_limit` object. Lines that do not + * mention the snapshot fields are not even parsed, so the hot path costs two substring checks and + * the bytes stay identical. + */ +const RATE_LIMITS_UPDATED = "account/rateLimits/updated"; +const RATE_LIMIT_RESULT_FIELDS = ["rateLimits", "rateLimitsByLimitId", "ordinaryUsageAllowed"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The part of a message the gate rewrite may touch, or null when the message carries no gate. */ +function gatePayload(message: unknown): Record | null { + if (!isRecord(message)) return null; + if (message.method === RATE_LIMITS_UPDATED) return isRecord(message.params) ? message.params : null; + if ("method" in message) return null; + const result = message.result; + return isRecord(result) && RATE_LIMIT_RESULT_FIELDS.some(field => field in result) ? result : null; +} + +/** + * Returns the rewritten line, or null when the line is not a gate-bearing JSON-RPC message or the + * rewrite has nothing to change. Parse failures also return null: the rewrite owns removal of + * known-shaped locks, not validation. + */ +export function rewriteAppServerLine(line: string): string | null { + if (!line.includes("rateLimit") && !line.includes("ordinaryUsageAllowed")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + const payload = gatePayload(parsed); + if (payload === null) return null; + return unlockRateLimitGate(payload) ? JSON.stringify(parsed) : null; +} diff --git a/src/chatgpt/app-server-shim/filter.ts b/src/chatgpt/app-server-shim/filter.ts new file mode 100644 index 00000000000..6a9312da816 --- /dev/null +++ b/src/chatgpt/app-server-shim/filter.ts @@ -0,0 +1,101 @@ +import { rewriteAppServerLine } from "./app-server-rewrite"; + +/** Byte-preserving stdout filter; unexpected rewrite machinery failures disable filtering. */ +const NEWLINE = 0x0a; + +/** + * Splits a byte stream into lines and rewrites the ones that need it. Returns the bytes to write + * for each chunk; a partial trailing line is held back until its newline arrives (or `flush`). + */ +export function createRpcLineFilter( + rewrite: (line: string) => string | null = rewriteAppServerLine, +): { push(chunk: Uint8Array): Uint8Array[]; flush(): Uint8Array[] } { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending: Uint8Array = new Uint8Array(0); + let passthrough = false; + + // `line` excludes the newline, `whole` includes it when there is one. An untouched line is + // returned as the very bytes that arrived; only a rewritten one is re-encoded. + const emit = (line: Uint8Array, whole: Uint8Array, terminated: boolean): Uint8Array => { + let rewritten: string | null; + try { + rewritten = rewrite(decoder.decode(line)); + } catch { + return whole; + } + if (rewritten === null) return whole; + const body = encoder.encode(rewritten); + if (!terminated) return body; + const out = new Uint8Array(body.length + 1); + out.set(body, 0); + out[body.length] = NEWLINE; + return out; + }; + + return { + push(chunk) { + if (passthrough) return [chunk]; + const previous = pending; + try { + const joined = new Uint8Array(previous.length + chunk.length); + joined.set(previous, 0); + joined.set(chunk, previous.length); + const out: Uint8Array[] = []; + let start = 0; + for (let i = 0; i < joined.length; i++) { + if (joined[i] !== NEWLINE) continue; + out.push(emit(joined.subarray(start, i), joined.subarray(start, i + 1), true)); + start = i + 1; + } + pending = joined.slice(start); + return out; + } catch { + pending = new Uint8Array(0); + passthrough = true; + return previous.length ? [previous, chunk] : [chunk]; + } + }, + flush() { + if (pending.length === 0) return []; + const last = pending; + pending = new Uint8Array(0); + if (passthrough) return [last]; + try { return [emit(last, last, false)]; } catch { return [last]; } + }, + }; +} + +/** Copy `input` to `write`, rewriting gate lines on the way. Resolves when `input` ends. */ +export async function runStdoutFilter( + input: AsyncIterable, + write: (bytes: Uint8Array) => Promise | unknown, + rewrite?: (line: string) => string | null, +): Promise { + let filter: ReturnType | undefined; + try { filter = createRpcLineFilter(rewrite); } catch { /* Raw passthrough if setup fails. */ } + for await (const chunk of input) { + for (const out of filter ? filter.push(chunk) : [chunk]) await write(out); + } + for (const out of filter?.flush() ?? []) await write(out); +} + +/** Hidden CLI entry; the self-test emits no stdout so the launcher can probe it safely. */ +export async function runChatgptAppServerFilter({ selfTest = false }: { selfTest?: boolean } = {}): Promise { + if (selfTest) { + try { + const fixture = JSON.stringify({ id: 1, result: { + ordinaryUsageAllowed: false, + rateLimits: { rateLimitReachedType: "rate_limit_reached", primary: { usedPercent: 100 } }, + } }); + const rewritten = rewriteAppServerLine(fixture); + if (!rewritten) return 1; + const result = JSON.parse(rewritten).result; + return result.ordinaryUsageAllowed === true + && result.rateLimits.rateLimitReachedType === null + && result.rateLimits.primary.usedPercent === 100 ? 0 : 1; + } catch { return 1; } + } + await runStdoutFilter(Bun.stdin.stream(), bytes => Bun.write(Bun.stdout, bytes)); + return 0; +} diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts new file mode 100644 index 00000000000..b1d393cc3fc --- /dev/null +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -0,0 +1,98 @@ +/** 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasNonQuotaBlock(node: unknown): boolean { + if (Array.isArray(node)) return node.some(hasNonQuotaBlock); + if (!isRecord(node)) return false; + if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) return true; + if (isRecord(node.spend_control) && node.spend_control.reached === true) return true; + const reached = node.rate_limit_reached_type; + if (isRecord(reached) && typeof reached.type === "string" && reached.type !== PLAIN_QUOTA_REACHED_TYPE) return true; + const rpcReached = node.rateLimitReachedType; + const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined; + if (typeof rpcType === "string" && rpcType !== PLAIN_QUOTA_REACHED_TYPE) return true; + return Object.values(node).some(hasNonQuotaBlock); +} + +export function unlockRateLimitGate(value: unknown): boolean { + let changed = false; + const payloadBlocked = hasNonQuotaBlock(value); + // A subtree shows the plain quota as the reason when a plain-quota reached type was removed in + // it or a usage window reads 100%; it stays "blocked" when a non-quota reason (workspace or + // credit reached type, spend control) is still standing in it. + const visit = (node: unknown): { cleared: boolean; exhausted: boolean; blocked: boolean } => { + let cleared = false; + let exhausted = false; + let blocked = false; + if (Array.isArray(node)) { + for (const item of node) { + const r = visit(item); + cleared ||= r.cleared; + exhausted ||= r.exhausted; + blocked ||= r.blocked; + } + return { cleared, exhausted, blocked }; + } + if (!isRecord(node)) return { cleared, exhausted, blocked }; + if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true; + if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true; + if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true; + const reachedType = node.rate_limit_reached_type; + if (isRecord(reachedType) && reachedType.type === PLAIN_QUOTA_REACHED_TYPE) { + delete node.rate_limit_reached_type; + changed = true; + cleared = true; + } else if (isRecord(reachedType) && typeof reachedType.type === "string") { + blocked = true; + } + // The app-server's JSON-RPC spelling of the same field: a nullable string. + const rpcReached = node.rateLimitReachedType; + const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined; + if (rpcType === PLAIN_QUOTA_REACHED_TYPE) { + node.rateLimitReachedType = null; + changed = true; + cleared = true; + } else if (typeof rpcType === "string") { + blocked = true; + } + for (const child of Object.values(node)) { + const r = visit(child); + cleared ||= r.cleared; + exhausted ||= r.exhausted; + blocked ||= r.blocked; + } + // The rate-limit flags are opened only when no workspace, credit or spend-control reason + // stands anywhere in this payload; with one present the flags stay as the server sent them. + if (!blocked && !payloadBlocked) { + for (const key of ["rate_limit", "rateLimit"] as const) { + const rateLimit = node[key]; + if (!isRecord(rateLimit)) continue; + if (rateLimit.allowed === false) { + rateLimit.allowed = true; + changed = true; + } + for (const limitKey of ["limit_reached", "limitReached"] as const) { + if (rateLimit[limitKey] === true) { + rateLimit[limitKey] = false; + changed = true; + } + } + } + } + // `ordinaryUsageAllowed: false` is the same quota gate seen from the RPC side, and the only + // field the app reads for it. Open it only when the plain quota is the visible reason and + // nothing else still explains the block. + if (node.ordinaryUsageAllowed === false && (cleared || exhausted) && !blocked && !payloadBlocked) { + node.ordinaryUsageAllowed = true; + changed = true; + } + return { cleared, exhausted, blocked }; + }; + visit(value); + return changed; +} + diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts new file mode 100644 index 00000000000..a8710174920 --- /dev/null +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -0,0 +1,42 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { selfLaunchArgv } from "../../lib/self-launch-argv"; + +export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; + +export function chatgptShimLauncherPath(configDir = getConfigDir()): string { + return join(configDir, "chatgpt-codex-shim.sh"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * A failed precondition or a failed self-test runs the original binary with untouched stdout. + * A filter that passes the self-test and then dies mid-session closes the pipe. + * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a + * write error and Desktop respawns it through the same launcher. + */ +export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string { + return `#!/bin/bash +# opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. +REAL=${shellQuote(real)} +FILTER=(${argv.map(shellQuote).join(" ")}) +if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ + && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then + exec "$REAL" "$@" > >(exec "\${FILTER[@]}") +fi +exec "$REAL" "$@" +`; +} + +export function writeChatgptShimLauncher(configDir = getConfigDir()): string { + mkdirSync(configDir, { recursive: true }); + const path = chatgptShimLauncherPath(configDir); + const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; + writeFileSync(path, buildChatgptShimLauncher(argv), { mode: 0o755 }); + chmodSync(path, 0o755); + return path; +} diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 484454a802f..7ff66234ac8 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -95,6 +95,13 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [ * A capability must not name a route the command does not actually fetch. */ export const CAPABILITIES: readonly Capability[] = [ + { + command: ["chatgpt"], + summary: "Experimental ChatGPT app-server shim: launch, restore or status (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."], + }, { command: ["link", "port"], summary: "Allocate a free loopback port for a remote home link.", diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts new file mode 100644 index 00000000000..06f1c5914ed --- /dev/null +++ b/src/cli/chatgpt-command.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { loadConfig } from "../config"; +import { chatgptShimLauncherPath, writeChatgptShimLauncher } from "../chatgpt/app-server-shim/launcher"; + +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`; + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); + return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; +} + +/** + * Only inspect the named bundle process; never print the environment being inspected. + * `-a` keeps ancestors in the match: when ocx runs inside a ChatGPT/Codex session the app is + * one of this process's ancestors, and plain `pgrep -x` would report it as not running. + */ +function appState(launcher: string): { running: boolean; shim: boolean } { + const pids = run("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return { running: false, shim: false }; + for (const pid of pids.output.split(/\s+/).filter(value => /^\d+$/.test(value))) { + const command = run("ps", ["eww", "-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) { + const marker = `CODEX_CLI_PATH=${launcher}`; + const start = command.output.indexOf(marker); + return { running: true, shim: start >= 0 && (start === 0 || command.output[start - 1] === " ") + && (start + marker.length === command.output.length || command.output[start + marker.length] === " ") }; + } + } + return { running: false, shim: false }; +} + +/** Adapted from #5947: open ignores new launch settings until the previous app exits. */ +async function quitApp(launcher: string): Promise { + if (!appState(launcher).running) return true; + for (let attempt = 0; attempt < 3; attempt++) { + run("osascript", ["-e", 'quit app "ChatGPT"']); + for (let poll = 0; poll < 20; poll++) { + if (!appState(launcher).running) return true; + await Bun.sleep(250); + } + } + return !appState(launcher).running; +} + +export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise { + const sub = args[0]; + if (!sub || ["help", "--help", "-h"].includes(sub)) { + console.log(USAGE); + return sub ? 0 : 64; + } + if (!["launch", "restore", "status"].includes(sub) || args.length !== 1) { + console.error(USAGE); + return 64; + } + if (platform !== "darwin") { + console.error("ChatGPT app-server shim (experimental): macOS only."); + return 1; + } + try { + const config = loadConfig(); + const launcher = chatgptShimLauncherPath(); + if (sub === "status") { + const app = appState(launcher); + console.log(`app-server shim (experimental): ${config.chatgptDesktop?.appServerShim === true ? "on" : "off"} +launcher: ${existsSync(launcher) ? "present" : "absent"} +app: ${app.running ? "running" : "not running"} +CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); + return 0; + } + if (sub === "launch") { + if (config.chatgptDesktop?.appServerShim !== true) { + console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); + return 1; + } + writeChatgptShimLauncher(); + } + if (!(await quitApp(launcher))) { + console.error("ChatGPT did not quit; quit it manually and retry."); + return 1; + } + // Remove an inherited override too: restore must launch without CODEX_CLI_PATH. + const env = { ...process.env }; + delete env.CODEX_CLI_PATH; + const result = spawnSync("open", ["-a", "ChatGPT", ...(sub === "launch" ? ["--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.`); + return 0; + } catch (error) { + console.error(`ChatGPT shim (experimental): ${error instanceof Error ? error.message : String(error)}`); + return 1; + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 450829c20d1..0e9ef882b6d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -947,6 +947,10 @@ const commandRunners: Record = { const { handleLabCommand } = await import("./lab"); return await handleLabCommand(deps.args.slice(1)); }, + chatgpt: async deps => { + const { handleChatgptCommand } = await import("./chatgpt-command"); + return await handleChatgptCommand(deps.args.slice(1)); + }, claude: async deps => { const { cmdClaude } = await import("./claude"); // "ocx claude desktop" → write Desktop 3P config diff --git a/src/cli/help.ts b/src/cli/help.ts index 488df7bc4ff..b5043bb8329 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -90,6 +90,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection + ocx chatgpt Experimental app-server shim: launch|restore|status (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 ef2e5961555..7799ceb52b2 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -3,21 +3,20 @@ * * Deliberately NOT in `src/cli/registry.ts` and NOT in `src/cli/capabilities.ts`, so it * is absent from help, from the generated skill surface, and from the registry-parity - * gate. It is not a user-facing capability and must not become one: it exists so the - * detached restart helper is the same audited binary running the same audited ladder, - * rather than a second implementation in a shell script. + * gate. These are implementation entrypoints for the detached restart helper and + * experimental ChatGPT stdout filter; compiled builds re-enter the same CLI binary. * * It is routed before the dispatch table for the same reason `help` is - adding it as a * runner key would make it a command the registry-parity test expects to find * documented. * - * It is intentionally unauthenticated, and that is not an oversight. Any process running + * The restart handoff is intentionally unauthenticated, and that is not an oversight. Any process running * as this user can invoke it with a hand-written plan file, and it gains nothing by * doing so: the helper only does what the public `--restart-codex` flag already does for * that same user, and a same-uid process could call `kill` directly. A token here would * protect nothing and would imply a boundary that does not exist. */ -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan "; +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test]"; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); @@ -26,6 +25,14 @@ function optionValue(args: readonly string[], name: string): string | undefined export async function handleInternalCommand(args: readonly string[]): Promise { const sub = args[0]; + if (sub === "chatgpt-app-server-filter") { + if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { + console.error(USAGE); + return 2; + } + const { runChatgptAppServerFilter } = await import("../chatgpt/app-server-shim/filter"); + return await runChatgptAppServerFilter({ selfTest: args[1] === "--self-test" }); + } if (sub !== "desktop-restart-handoff") { console.error(`Unknown internal command: ${sub ?? "(none)"}. ${USAGE}`); return 2; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3dcc639c7e2..25b7f8258ec 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -8,6 +8,12 @@ export interface CliCommandEntry { } 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."], + }, { name: "init", aliases: ["setup"], diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index dd559ed6097..1e079eb5649 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -64,6 +64,7 @@ import { remoteGuiConfigSchema, runtimeRoleSchema, spendSchema, + chatgptDesktopSchema, compactionRoutingSchema, skillsConfigSchema, memoryModelsSchema, @@ -102,6 +103,9 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf const rawEffort = rawClaudeSubagentEffort(rawParsed); const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); const warnings = configPlaceholderWarnings(normalized); + if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") { + warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform"); + } warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); @@ -619,6 +623,10 @@ 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" }; + } const compactionRouting = rawConfigRecord(value)?.compactionRouting; if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 6febc06293f..3302e836d4b 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -17,6 +17,7 @@ import { remoteGuiConfigSchema, runtimeRoleSchema, spendSchema, + chatgptDesktopSchema, skillsConfigSchema, configuredCodexPoolAccountIds, apiKeyEntrySchema, @@ -69,6 +70,7 @@ import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/inte import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; export const configSchema = z.object({ + chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined), codexNativeSteering: z.boolean().optional().catch(false), codexNativeInjection: z.boolean().optional().catch(false), port: z.number().int().min(0).max(65535).default(10100), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index bce878456d4..8ccd389929c 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -43,6 +43,9 @@ import { import { getConfigDir } from "../paths"; import { COMPACTION_TRIGGERS } from "./compaction-triggers"; +/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ +export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); + /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value === value.trim(); diff --git a/src/types/config.ts b/src/types/config.ts index 1c0f7c5c83c..cd76ad5e0ad 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -495,6 +495,8 @@ export interface OcxClientConnectionConfig { } export interface OcxConfig { + /** Experimental macOS app-server stdout shim; absent or false disables it. */ + chatgptDesktop?: { appServerShim?: boolean }; port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ runtimeRole?: OcxRuntimeRole; diff --git a/structure/INDEX.md b/structure/INDEX.md index 53f9e34cebd..398edd1e2c2 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -79,6 +79,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | +| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -115,6 +116,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md new file mode 100644 index 00000000000..3fd7a7ee34a --- /dev/null +++ b/structure/clients/chatgpt-desktop.md @@ -0,0 +1,47 @@ +# ChatGPT Desktop app-server shim + +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 +writes reject malformed values and unknown fields. + +The launcher under the config directory re-enters the current CLI using +`process.execPath` and `selfLaunchArgv()`. Its hidden internal filter command stays +out of the public command registry and generated skill surface. Source installs +include the CLI entry argument; compiled builds use only the executable and +internal command arguments. + +The launcher checks macOS, executable presence and a successful filter self-test, +then replaces itself with the bundled app-server using shell exec. Only stdout +passes through the filter. Stdin, stderr, process identity and the real server's +exit status retain the direct app/server relationship. + +A failed precondition or a failed self-test runs the original binary with untouched stdout. +A filter that passes the self-test and then dies mid-session closes the pipe. +Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a write error and Desktop respawns it through the same launcher. +The filter's passthrough mode limits this to an exit/crash case. + +The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC +rate-limit notifications and top-level rate-limit results. Workspace, credit, +unknown reached-type and spend-control restrictions preserve closed gate flags. +Usage percentages, resets and window durations remain accurate. Unrelated messages +and malformed lines remain byte-identical; changed lines are reserialized. +A per-line rewrite exception preserves that line. A failure in the framing/rewrite +machinery preserves buffered bytes and switches the rest of the stream to raw +passthrough. Output-write failures propagate; they are not rewrite failures. + +Launch writes a mode-0755 executable, waits for the old ChatGPT instance to quit, +then invokes open with the launcher in CODEX_CLI_PATH. Restore relaunches without +that override and removes the launcher only after open succeeds. Status reports +the experimental flag, launcher presence, and the named bundle process's override +without printing its environment. Other platforms reject all three 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. + +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. diff --git a/structure/config.md b/structure/config.md index 2b92b202384..e8ff6b998ae 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). +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. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). diff --git a/structure/manifest.json b/structure/manifest.json index 533183c97bd..d378c6df6e1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -439,6 +439,15 @@ "src/lib/" ] }, + { + "path": "clients/chatgpt-desktop.md", + "tier": 5, + "title": "ChatGPT Desktop", + "scope": "Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries.", + "documents": [ + "src/chatgpt/" + ] + }, { "path": "clients/claude-desktop.md", "tier": 5, diff --git a/structure/runtime.md b/structure/runtime.md index 85e0a73cb16..5c904cd7e60 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -597,4 +597,4 @@ registration succeeds. 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). +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 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. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts new file mode 100644 index 00000000000..a7379016f0a --- /dev/null +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildChatgptShimLauncher, writeChatgptShimLauncher, chatgptShimLauncherPath } from "../../src/chatgpt/app-server-shim/launcher"; +import { selfLaunchArgv } from "../../src/lib/self-launch-argv"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const bashAvailable = process.platform !== "win32" + && spawnSync("/bin/bash", ["-c", "exit 0"]).status === 0; +const substitutionAvailable = bashAvailable + && spawnSync("/bin/bash", ["-c", "printf ok > >(cat)"], { encoding: "utf8" }).stdout === "ok"; +const executionTest = substitutionAvailable ? test : test.skip; +const executable = (dir: string, name: string, body: string) => { + const path = join(dir, name); + writeFileSync(path, `#!/bin/bash\n${body}\n`); + chmodSync(path, 0o755); + return path; +}; +function withDir(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); + try { run(dir); } finally { removeTreeWithRetry(dir); } +} +const internalArgs = ["internal", "chatgpt-app-server-filter"]; + +describe("experimental app-server launcher", () => { + test("source and compiled argv re-enter the CLI with shell-safe quoting", () => { + for (const standalone of [false, true]) { + const argv = ["/runtime with 'quote", ...selfLaunchArgv(internalArgs, { + isStandaloneExecutable: standalone, sourceEntrypoint: "/source with space/index.ts", + })]; + const launcher = buildChatgptShimLauncher(argv, "/real with space"); + expect(launcher).toContain("'internal' 'chatgpt-app-server-filter'"); + expect(launcher.includes("'/source with space/index.ts'")).toBe(!standalone); + expect(launcher).toContain("'\\''"); + expect(launcher).toContain('--self-test >/dev/null 2>&1'); + expect(launcher).toContain('"$(uname -s)" = "Darwin"'); + expect(launcher).toContain('exec "$REAL" "$@" > >(exec "${FILTER[@]}")'); + } + }); + + test("writer creates the launcher under the selected config dir with mode 0755", () => withDir(dir => { + const path = writeChatgptShimLauncher(dir); + expect(path).toBe(chatgptShimLauncherPath(dir)); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o755); + })); + + // Stub uname activates the macOS precondition on Linux without requiring the actual app. + const environment = (dir: string) => { + executable(dir, "uname", 'echo Darwin'); + return { ...process.env, PATH: `${dir}:${process.env.PATH ?? "/usr/bin:/bin"}` }; + }; + for (const scenario of ["missing runtime", "failing self-test", "passing self-test"] as const) { + const scenarioTest = (scenario === "passing self-test" ? substitutionAvailable : bashAvailable) ? test : test.skip; + scenarioTest(scenario, () => withDir(dir => { + const real = executable(dir, "real with 'quote.sh", 'echo "PID:$$ ARGS:$*"\nread -r line\necho "STDIN:$line"\necho "to-stderr" >&2\nexit 7'); + const filter = scenario === "missing runtime" ? join(dir, "missing") : executable(dir, "filter.sh", + `if [ "$1" = --self-test ]; then exit ${scenario === "failing self-test" ? 1 : 0}; fi\nsed 's/STDIN/FILTERED/'`); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const out = spawnSync("/bin/bash", [launcher, "app-server", "--flag"], { + encoding: "utf8", input: "hello\n", env: environment(dir), timeout: 5000, + }); + expect(out.status).toBe(7); + expect(out.stdout).toBe(`PID:${out.pid} ARGS:app-server --flag\n${scenario === "passing self-test" ? "FILTERED" : "STDIN"}:hello\n`); + expect(out.stderr).toBe("to-stderr\n"); + })); + } + + executionTest("the real hidden CLI filter passes preflight and rewrites the gate", () => withDir(dir => { + const real = executable(dir, "real.sh", `printf '%s\\n' '{"id":1,"result":{"ordinaryUsageAllowed":false,"rateLimits":{"primary":{"usedPercent":100}}}}'`); + const argv = [process.execPath, ...selfLaunchArgv(internalArgs, { + isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), + })]; + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real)); + const out = spawnSync("/bin/bash", [launcher, "app-server"], { encoding: "utf8", env: environment(dir), timeout: 10000 }); + expect(out.status).toBe(0); + const result = JSON.parse(out.stdout).result; + expect(result.ordinaryUsageAllowed).toBe(true); + expect(result.rateLimits.primary.usedPercent).toBe(100); + })); + + executionTest("a filter exit after successful preflight closes the pipe and terminates a mock server", () => withDir(dir => { + const real = executable(dir, "real.sh", 'trap "exit 23" PIPE\nwhile :; do printf "%s\\n" "mock server stdout" || exit 23; done'); + const filter = executable(dir, "filter.sh", 'if [ "$1" = --self-test ]; then exit 0; fi\nexit 1'); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: environment(dir), timeout: 5000 }); + expect(out.error).toBeUndefined(); + expect(out.status).toBe(23); + })); +}); diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts new file mode 100644 index 00000000000..c2b8b850340 --- /dev/null +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { rewriteAppServerLine } from "../../src/chatgpt/app-server-shim/app-server-rewrite"; +import { createRpcLineFilter, runStdoutFilter, runChatgptAppServerFilter } from "../../src/chatgpt/app-server-shim/filter"; + +/** + * The desktop app reads the composer's send gate from the bundled app-server over JSON-RPC, not + * from Chromium (#6196). The shim sits on that one stdio pipe and opens the plain-quota gate. + */ + +const rpcResult = (result: unknown) => JSON.stringify({ id: 2, result }); + +const EXHAUSTED_RATE_LIMITS = { + ordinaryUsageAllowed: false, + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: 1790000000 }, + planType: "pro", + rateLimitReachedType: "rate_limit_reached", + }, + rateLimitsByLimitId: { codex: { limitId: "codex", rateLimitReachedType: "rate_limit_reached" } }, +}; + +describe("app-server line rewrite", () => { + test("a plain-quota reached type is cleared and ordinary usage is allowed again", () => { + const out = JSON.parse(rewriteAppServerLine(rpcResult(EXHAUSTED_RATE_LIMITS))!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimitsByLimitId.codex.rateLimitReachedType).toBeNull(); + // Displayed usage is never changed. + expect(out.result.rateLimits.primary).toEqual(EXHAUSTED_RATE_LIMITS.rateLimits.primary); + expect(out.result.rateLimits.planType).toBe("pro"); + }); + + test("a workspace or credit reached type is left as the server sent it", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted"]) { + const line = rpcResult({ ...EXHAUSTED_RATE_LIMITS, rateLimits: { ...EXHAUSTED_RATE_LIMITS.rateLimits, rateLimitReachedType: type }, rateLimitsByLimitId: {} }); + expect(rewriteAppServerLine(line)).toBeNull(); + } + }); + + test("a plain quota next to a workspace block keeps ordinary usage closed", () => { + const line = rpcResult({ + ordinaryUsageAllowed: false, + rateLimits: { rateLimitReachedType: "rate_limit_reached" }, + rateLimitsByLimitId: { other: { rateLimitReachedType: "workspace_owner_credits_depleted" } }, + }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimitsByLimitId.other.rateLimitReachedType).toBe("workspace_owner_credits_depleted"); + expect(out.result.ordinaryUsageAllowed).toBe(false); + }); + + test("a Plus account with only the 5-hour window exhausted is opened and both windows are kept (#6196)", () => { + // The reported state: 5-hour window at 100 %, weekly window at 32 %, Send disabled. + const line = rpcResult({ + ordinaryUsageAllowed: false, + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: 1790000000 }, + secondary: { usedPercent: 32, windowDurationMins: 10080, resetsAt: 1790500000 }, + planType: "plus", + rateLimitReachedType: "rate_limit_reached", + }, + }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimits.primary).toEqual({ usedPercent: 100, windowDurationMins: 300, resetsAt: 1790000000 }); + expect(out.result.rateLimits.secondary).toEqual({ usedPercent: 32, windowDurationMins: 10080, resetsAt: 1790500000 }); + }); + + test("a quota window at 100% opens ordinary usage even when no reached type is sent", () => { + const line = rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 100, windowDurationMins: 10080 }, rateLimitReachedType: null } }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.primary.usedPercent).toBe(100); + }); + + test("ordinary usage stays closed without quota evidence, or when spend control also blocks", () => { + // Closed for a reason the payload does not show: not ours to argue with. + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 12 } } }))).toBeNull(); + // Quota is exhausted but a spend control also stands. + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 100 }, spendControlReached: { used: 5, limit: 5 } } }))).toBeNull(); + }); + + test("notifications carrying the same fields are rewritten too", () => { + const line = JSON.stringify({ method: "account/rateLimits/updated", params: { rateLimits: { rateLimitReachedType: "rate_limit_reached" } } }); + expect(JSON.parse(rewriteAppServerLine(line)!).params.rateLimits.rateLimitReachedType).toBeNull(); + }); + + test("only rate-limit messages are rewritten; tool results and other results that nest gate fields are left alone", () => { + const toolResult = JSON.stringify({ + method: "item/completed", + params: { item: { type: "commandExecution", output: { rate_limit: { allowed: false, limit_reached: true }, rateLimitReachedType: "rate_limit_reached" } } }, + }); + expect(rewriteAppServerLine(toolResult)).toBeNull(); + + const otherResult = JSON.stringify({ id: 9, result: { data: { rate_limit: { allowed: false }, rateLimits: { rateLimitReachedType: "rate_limit_reached" } } } }); + expect(rewriteAppServerLine(otherResult)).toBeNull(); + + const blocks = JSON.stringify({ id: 10, result: { blockedFeatures: [{ name: "send", blockReason: "usage_limit" }] } }); + expect(rewriteAppServerLine(blocks)).toBeNull(); + }); + + test("lines without gate fields, unparseable lines and already-open gates are not touched", () => { + expect(rewriteAppServerLine(JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "hello" } }))).toBeNull(); + expect(rewriteAppServerLine('{"rateLimits": broken')).toBeNull(); + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: true, rateLimits: { rateLimitReachedType: null } }))).toBeNull(); + }); + + test("a message that only quotes a field name inside text is not modified", () => { + const line = JSON.stringify({ method: "item/completed", params: { text: "the rateLimitReachedType field is rate_limit_reached" } }); + expect(rewriteAppServerLine(line)).toBeNull(); + }); +}); + +describe("app-server line filter", () => { + const collect = (chunks: Uint8Array[], filter = createRpcLineFilter()) => { + const parts: Uint8Array[] = []; + for (const chunk of chunks) parts.push(...filter.push(chunk)); + parts.push(...filter.flush()); + return new TextDecoder().decode(Buffer.concat(parts)); + }; + const enc = (s: string) => new TextEncoder().encode(s); + + test("untouched lines come back byte for byte, including multibyte text and odd line endings", () => { + const text = `${JSON.stringify({ method: "x", params: { t: "你好 🌏 é" } })}\n\r\n${JSON.stringify({ a: 1 })}\r\n`; + expect(collect([enc(text)])).toBe(text); + }); + + test("a gate line is rewritten wherever the chunk boundaries fall", () => { + const gate = rpcResult(EXHAUSTED_RATE_LIMITS); + const stream = `${JSON.stringify({ method: "a", params: { t: "你好" } })}\n${gate}\n${JSON.stringify({ method: "b" })}\n`; + const bytes = enc(stream); + for (const size of [1, 2, 3, 7, 64, bytes.length]) { + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += size) chunks.push(bytes.slice(i, i + size)); + const out = collect(chunks).split("\n"); + expect(out[0]).toBe(JSON.stringify({ method: "a", params: { t: "你好" } })); + expect(JSON.parse(out[1]!).result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out[2]).toBe(JSON.stringify({ method: "b" })); + expect(out[3]).toBe(""); + } + }); + + test("a final line without a newline is flushed, and rewritten when it needs it", () => { + expect(collect([enc('{"method":"tail"}')])).toBe('{"method":"tail"}'); + const out = collect([enc(rpcResult(EXHAUSTED_RATE_LIMITS))]); + expect(out.endsWith("\n")).toBe(false); + expect(JSON.parse(out).result.ordinaryUsageAllowed).toBe(true); + }); +}); + +describe("app-server stdout filter", () => { + const chunksOf = async function* (parts: string[]) { + for (const part of parts) yield new TextEncoder().encode(part); + }; + const run = async (parts: string[], rewrite?: (line: string) => string | null) => { + const written: Uint8Array[] = []; + await runStdoutFilter(chunksOf(parts), bytes => void written.push(bytes), rewrite); + return new TextDecoder().decode(Buffer.concat(written)); + }; + + test("copies the server's stdout through, rewriting only the gate lines", async () => { + const out = await run([`${JSON.stringify({ method: "a" })}\n${rpcResult(EXHAUSTED_RATE_LIMITS)}\n`, '{"method":"done"}']); + const lines = out.split("\n"); + expect(lines[0]).toBe('{"method":"a"}'); + expect(JSON.parse(lines[1]!).result.rateLimits.rateLimitReachedType).toBeNull(); + expect(lines[2]).toBe('{"method":"done"}'); + }); + + test("a rewrite that throws passes its line through unchanged instead of breaking the stream", async () => { + const text = `${rpcResult(EXHAUSTED_RATE_LIMITS)}\n{"method":"after"}\n`; + const out = await run([text], () => { + throw new Error("boom"); + }); + expect(out).toBe(text); + }); +}); + + +test("hidden filter self-test succeeds without streaming stdin", async () => { + expect(await runChatgptAppServerFilter({ selfTest: true })).toBe(0); +}); + +test("machinery failure preserves buffered bytes and switches the remainder to raw passthrough", () => { + const enc = new TextEncoder(); + const filter = createRpcLineFilter(() => Symbol("encoding failure") as unknown as string); + expect(filter.push(enc.encode("partial"))).toEqual([]); + const out = [...filter.push(enc.encode(" tail\nnext\n")), ...filter.push(enc.encode("raw tail")), ...filter.flush()]; + expect(Buffer.concat(out).toString()).toBe("partial tail\nnext\nraw tail"); +}); diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts new file mode 100644 index 00000000000..96df45f5e6a --- /dev/null +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { validateConfigCandidate, configDiagnosticsFromRaw } from "../../src/config/diagnostics"; +import { handleChatgptCommand } from "../../src/cli/chatgpt-command"; +import { handleInternalCommand } from "../../src/cli/internal-command"; + +const base = { providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" } }, defaultProvider: "xai" }; +describe("experimental ChatGPT desktop config", () => { + test("absent and false are off; true is opt-in", () => { + expect(configSchema.parse(base).chatgptDesktop).toBeUndefined(); + for (const appServerShim of [undefined, false, true]) { + const parsed = configSchema.parse({ ...base, chatgptDesktop: { appServerShim } }); + expect(parsed.chatgptDesktop?.appServerShim === true).toBe(appServerShim === true); + expect(validateConfigCandidate({ ...base, chatgptDesktop: { appServerShim } }).ok).toBe(true); + } + }); + 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 }]) { + const candidate = { ...base, port: 12345, chatgptDesktop }; + const parsed = configSchema.parse(candidate); + expect(parsed.chatgptDesktop).toBeUndefined(); + expect(parsed.port).toBe(12345); + const write = validateConfigCandidate(candidate); + expect(write.ok).toBe(false); + if (!write.ok) expect(write.error).toContain("chatgptDesktop"); + } + }); + test("enabled shim warns on other platforms", () => { + const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop: { appServerShim: true } })); + expect(diagnostics.error).toBeNull(); + if (process.platform !== "darwin") expect(diagnostics.warnings?.join(" ")).toContain("macOS only"); + }); + 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); + }); + test("hidden self-test works and rejects extra options", async () => { + expect(await handleInternalCommand(["chatgpt-app-server-filter", "--self-test"])).toBe(0); + expect(await handleInternalCommand(["chatgpt-app-server-filter", "--invalid"])).toBe(2); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 83170e64b6b..236a542357f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,7 @@ { + "desktop-app-server-shim.test.ts": "clients", + "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-chatgpt-config.test.ts": "clients", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", From 98194c3b16e244b35df4ddffc6edf507b865ccaf Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 16:43:27 +0900 Subject: [PATCH 2/4] feat(chatgpt): carry the local-CA send-unblock intercept (split from #5947) Stacked on the app-server shim. Adds the experimental, default-off chatgptDesktop.unblockSend mode: a loopback TLS listener with a locally issued CA terminates chatgpt.com traffic from the ChatGPT desktop app, relays HTTP and WebSocket traffic upstream through the configured proxy, and clears the plain-quota send gate using the shim's gate-rewrite helpers. The SOCKS5 handshake moves to src/lib/socks5-handshake.ts and is shared with the fetch tunnel. The PAC fallback is left for the next stacked PR. Two fixes on top of #5947: an http:// proxy without an explicit port now dials 80 instead of 8080, and a proxy that closes mid-handshake fails the upstream dial instead of hanging the upgrade. Refs #6196 Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- .../content/docs/guides/chatgpt-desktop.md | 98 +++- scripts/test-layout/layout.json | 11 + .../ocx/references/01_management_surface.md | 4 +- src/chatgpt/app-server-shim/gate-rewrite.ts | 2 +- src/chatgpt/desktop-unblock/ca-trust.ts | 51 ++ src/chatgpt/desktop-unblock/launch-watcher.ts | 471 ++++++++++++++++ src/chatgpt/desktop-unblock/listener.ts | 271 ++++++++++ src/chatgpt/desktop-unblock/rewrite.ts | 180 +++++++ src/chatgpt/desktop-unblock/runtime.ts | 96 ++++ src/chatgpt/desktop-unblock/ws-frame.ts | 156 ++++++ src/chatgpt/desktop-unblock/ws-relay.ts | 347 ++++++++++++ src/chatgpt/desktop-unblock/ws-upstream.ts | 273 ++++++++++ src/cli/capabilities.ts | 6 +- src/cli/chatgpt-command.ts | 116 +++- src/cli/help.ts | 2 +- src/cli/registry.ts | 6 +- src/config/diagnostics.ts | 12 +- src/config/schema/config-schema.ts | 1 + src/config/schema/leaf-validators.ts | 6 +- src/lib/socks5-fetch.ts | 77 +-- src/lib/socks5-handshake.ts | 112 ++++ src/server/index/chatgpt-unblock-lifecycle.ts | 54 ++ src/server/index/optional-listeners.ts | 4 + src/types/config.ts | 4 +- structure/INDEX.md | 5 +- structure/clients/chatgpt-desktop.md | 65 ++- structure/config.md | 2 +- structure/manifest.json | 7 +- structure/runtime.md | 8 +- structure/transports/inventory.md | 5 +- tests/clients/desktop-chatgpt-config.test.ts | 8 +- tests/clients/desktop-rewrite.test.ts | 288 ++++++++++ .../clients/desktop-unblock-ca-trust.test.ts | 68 +++ .../desktop-unblock-config-boundary.test.ts | 112 ++++ .../desktop-unblock-launch-script.test.ts | 342 ++++++++++++ .../clients/desktop-unblock-listener.test.ts | 180 +++++++ tests/clients/desktop-unblock-runtime.test.ts | 50 ++ .../desktop-unblock-watcher-install.test.ts | 141 +++++ .../clients/desktop-unblock-ws-frame.test.ts | 98 ++++ .../clients/desktop-unblock-ws-relay.test.ts | 501 ++++++++++++++++++ .../desktop-unblock-ws-upstream.test.ts | 40 ++ tests/fixtures/test-layout-expected.json | 11 + tests/lab/core-lab-boundary.test.ts | 2 +- tests/lib/socks5-handshake.test.ts | 213 ++++++++ 44 files changed, 4396 insertions(+), 110 deletions(-) create mode 100644 src/chatgpt/desktop-unblock/ca-trust.ts create mode 100644 src/chatgpt/desktop-unblock/launch-watcher.ts create mode 100644 src/chatgpt/desktop-unblock/listener.ts create mode 100644 src/chatgpt/desktop-unblock/rewrite.ts create mode 100644 src/chatgpt/desktop-unblock/runtime.ts create mode 100644 src/chatgpt/desktop-unblock/ws-frame.ts create mode 100644 src/chatgpt/desktop-unblock/ws-relay.ts create mode 100644 src/chatgpt/desktop-unblock/ws-upstream.ts create mode 100644 src/lib/socks5-handshake.ts create mode 100644 src/server/index/chatgpt-unblock-lifecycle.ts create mode 100644 tests/clients/desktop-rewrite.test.ts create mode 100644 tests/clients/desktop-unblock-ca-trust.test.ts create mode 100644 tests/clients/desktop-unblock-config-boundary.test.ts create mode 100644 tests/clients/desktop-unblock-launch-script.test.ts create mode 100644 tests/clients/desktop-unblock-listener.test.ts create mode 100644 tests/clients/desktop-unblock-runtime.test.ts create mode 100644 tests/clients/desktop-unblock-watcher-install.test.ts create mode 100644 tests/clients/desktop-unblock-ws-frame.test.ts create mode 100644 tests/clients/desktop-unblock-ws-relay.test.ts create mode 100644 tests/clients/desktop-unblock-ws-upstream.test.ts create mode 100644 tests/lib/socks5-handshake.test.ts diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index a8fdc61c87b..b8b30c6bbbc 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 @@ -63,7 +63,7 @@ its config directory, and the OpenCodex installation under your control: changin these executable paths changes code the app runs. The launcher still `exec`s the bundled binary at its standard `/Applications/ChatGPT.app` location. -This integration installs no certificate, network listener, PAC, or background +The app-server shim alone installs no certificate, network listener, 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. @@ -85,3 +85,95 @@ 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 with a 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. This candidate creates no PAC file or CONNECT entry proxy. + +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 OpenCodex's identity-confirmed listener. When OpenCodex +stops, an app still carrying the resolver rule cannot reach `chatgpt.com`; run +`ocx chatgpt restore` to relaunch with native networking. + +### 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 restarts an app launched without +intercept switches while the listener answers as OpenCodex. 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. + +### 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 08e48f415a7..c836b1a7c9d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,17 @@ "version": 1, "root": "tests", "explicit": { + "desktop-unblock-ws-frame.test.ts": "clients", + "desktop-unblock-watcher-install.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-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", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 3b54d20e6b0..3d98b1af3bf 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -515,13 +515,13 @@ Each of these writes. Check the flags column before running one unattended. ### `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). 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 link issue` diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index b1d393cc3fc..f4f304ef23c 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/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/launch-watcher.ts b/src/chatgpt/desktop-unblock/launch-watcher.ts new file mode 100644 index 00000000000..81a66e13d91 --- /dev/null +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -0,0 +1,471 @@ +import { execFileSync, spawnSync } from "node:child_process"; +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 { getConfigDir } from "../../config/paths"; +import { CHATGPT_INTERCEPT_HOST, CHATGPT_UNBLOCK_IDENTITY_PATH, CHATGPT_UNBLOCK_SERVICE_ID } from "./listener"; +import type { PreservedSendBlock } from "./rewrite"; +import { chatgptUnblockResolverArg } from "./runtime"; + +/** + * 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. There is no resident polling process: launchd wakes the script on the + * lock event and the script exits after one check. Corrective restarts may interrupt startup. + * + * 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 and explicit intercept launch share the same script, so the launch arguments + * are built in one place. CLI restore independently clears both the intercept and shim: + * + * 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=,direct://` 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). The + * `direct://` fallback keeps the app working if the VPN is switched off after launch; + * - 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. + * + */ + +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"; +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; +} + +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}`), + }; +} + +/** 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. + * + */ +export function buildChatgptUnblockWatcherScript(port: number, configDir?: string, openArgs: readonly string[] = []): string { + return `#!/bin/bash +# opencodex ChatGPT send-unblock launcher. +# watch (launchd, fired by the app's Electron SingletonLock on every launch): if the app is +# running WITHOUT the launch switches (a normal Dock/Spotlight launch), restart it once +# with them. A correctly launched app, 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))} +BYPASS_HOST=${shellQuote(CHATGPT_INTERCEPT_HOST)} +APP_PATTERN='ChatGPT.app/Contents/MacOS/ChatGPT' +IDENTITY_URL=${shellQuote(`https://127.0.0.1:${port}${CHATGPT_UNBLOCK_IDENTITY_PATH}`)} +SERVICE_ID=${shellQuote(`"service":"${CHATGPT_UNBLOCK_SERVICE_ID}"`)} +LOG=${shellQuote(chatgptUnblockWatcherLogPath(configDir))} +LOCK_DIR="\${TMPDIR:-/tmp}/opencodex-chatgpt-launch.lock" + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } +say() { [ "$MODE" != watch ] && echo "$*"; log "$*"; } +# 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. +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_PATTERN"*) echo "$pid"; return 0 ;; + esac + done + return 1 +} +app_running() { app_pid >/dev/null; } +app_flagged() { + ${openArgs.length > 0 ? '[ "$MODE" = launch ] && return 1 # Explicit open arguments require a fresh app process.' : ''} + local pid + pid=$(app_pid) || return 1 + local cmdline + cmdline=$(ps -o command= -p "$pid" 2>/dev/null) + case "$cmdline" in *" $RESOLVER_ARG"*) return 0 ;; esac + return 1 +} +app_switched() { app_flagged; } +# The port must be held by opencodex's listener, not just by any process. The listener answers +# its identity path itself; -k because its certificate names chatgpt.com, --noproxy because a +# proxy in the environment must not be asked to reach loopback. +listener_ours() { + curl -sk --noproxy '*' --max-time 3 "$IDENTITY_URL" 2>/dev/null | 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 'quit app "ChatGPT"' >/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). +proxy_args() { + local out + out=$(scutil --proxy 2>/dev/null) || return 0 + val() { printf '%s\\n' "$out" | awk -v k="$1" '$1 == k { print $3; exit }'; } + [ "$(val ProxyAutoConfigEnable)" = 1 ] && return 0 + 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 0 + printf '%s\\n' "--proxy-server=$scheme://$host:$port,direct://" "--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 +# 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 + 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 ChatGPT || { say "could not relaunch ChatGPT with native networking"; exit 1; } + say "relaunched ChatGPT with native networking" + exit 0 +fi + +if app_running; then + if app_flagged; then + say "ChatGPT is already running with the launch switches" + exit 0 + fi + 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; } +elif [ "$MODE" != launch ]; then + exit 0 +fi + +ARGS=("$RESOLVER_ARG") +while IFS= read -r arg; do + [ -n "$arg" ] && ARGS+=("$arg") +done < <(proxy_args) +open -a ChatGPT${openArgs.map(arg => ` ${shellQuote(arg)}`).join("")} --args "\${ARGS[@]}" || { say "could not launch ChatGPT"; exit 1; } +say "launched ChatGPT with: \${ARGS[*]}" +`; +} + +/** One-shot launchd agent: wake on the app's SingletonLock event, run the script, exit. */ +export function buildChatgptUnblockWatcherPlist(scriptPath: string, watchPath: string, errPath: string): string { + return ` + + + + Label + ${CHATGPT_UNBLOCK_WATCHER_LABEL} + ProgramArguments + + /bin/bash + ${xmlEscape(scriptPath)} + watch + + WatchPaths + + ${xmlEscape(watchPath)} + + 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; + 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; + 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"); + } + if (!options.assumeSupported && !existsSync(CHATGPT_APP_PATH)) { + throw new Error(`${CHATGPT_APP_PATH} not found; install the ChatGPT desktop app first`); + } + 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, + buildChatgptUnblockWatcherScript(options.port, options.configDir), + { mode: 0o700 }, + ); + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, 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; +} + +export function chatgptUnblockWatcherStatus(port: number, configDir?: string): ChatgptUnblockWatcherStatus { + const paths = chatgptUnblockWatcherPaths(configDir); + const scriptInstalled = existsSync(paths.scriptPath); + const plistInstalled = existsSync(paths.plistPath); + const agentLoaded = sh("launchctl", ["print", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; + const scriptUpToDate = scriptInstalled + && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port, configDir); + const plistUpToDate = plistInstalled + && readFileSync(paths.plistPath, "utf8") === buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, 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, + * 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 a command line carries the resolver switch for `port`. */ +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): Promise { + return new Promise(resolve => { + let raw = ""; + let settled = false; + const finish = (body: string | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(body); + }; + // The leaf names chatgpt.com, not 127.0.0.1; identity is established by the answer, not TLS. + const socket = connectTls({ host: "127.0.0.1", port, servername: CHATGPT_INTERCEPT_HOST, rejectUnauthorized: false }, () => { + socket.write(`GET ${CHATGPT_UNBLOCK_IDENTITY_PATH} HTTP/1.1\r\nHost: ${CHATGPT_INTERCEPT_HOST}\r\nConnection: close\r\n\r\n`); + }); + 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. + */ +export async function probeChatgptUnblockListener( + port: number, + request: (port: number) => Promise = requestIdentity, + portOpen: (port: number) => Promise = loopbackPortOpen, +): Promise { + if (!(await portOpen(port))) return { state: "down" }; + const text = await request(port); + 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, openArgs: readonly string[] = []): { 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. + const env = { ...process.env }; + delete env.CODEX_CLI_PATH; + const result = spawnSync("/bin/bash", ["-s", mode], { + env, + input: buildChatgptUnblockWatcherScript(port, configDir, openArgs), + 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, openArgs: readonly string[] = []): { ok: boolean; output: string } { + return runLaunchScript("launch", port, configDir, openArgs); +} + +/** Restart an app that carries the launch switches without them, returning it to native networking. */ +export function restoreChatgptNative(port: number, configDir?: string): { ok: boolean; output: string } { + return runLaunchScript("native", port, configDir); +} 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/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..b39880bfe00 --- /dev/null +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -0,0 +1,96 @@ +import type { Server } from "bun"; +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 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; + +export function chatgptUnblockEnabled(config: Pick): boolean { + if (process.platform !== "darwin" || config.runtimeRole === "client") return false; + return config.chatgptDesktop?.unblockSend === 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)}`; +} + +export interface ChatgptUnblockState { + port: number; + caCertPath: string; +} + +export interface ChatgptUnblockHandle extends ChatgptUnblockState { + listener: Server; + stop(): Promise; +} + +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 }); + return { + port, + caCertPath: claudeInterceptCaCertPath(configDir), + listener, + stop: async () => { + 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..020f60b903c --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-upstream.ts @@ -0,0 +1,273 @@ +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"; + +/** + * 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) { + const credentials = Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString("base64"); + 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.ts b/src/cli/capabilities.ts index 7ff66234ac8..e981a74d657 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -97,10 +97,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", "port"], diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 06f1c5914ed..ac4fcf9152b 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -1,12 +1,35 @@ import { spawnSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; 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 { chatgptUnblockPort, chatgptUnblockResolverArg } from "../chatgpt/desktop-unblock/runtime"; +import { + chatgptAppCommandLine, chatgptCommandLineHasRule, chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, launchChatgptWithRule, probeChatgptUnblockListener, + uninstallChatgptUnblockWatcher, +} from "../chatgpt/desktop-unblock/launch-watcher"; +import { interactiveConfirm } from "./interactive-confirm"; import { chatgptShimLauncherPath, writeChatgptShimLauncher } from "../chatgpt/app-server-shim/launcher"; 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) + 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); +} + +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 }); @@ -52,16 +75,27 @@ 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 launcher = chatgptShimLauncherPath(); if (sub === "status") { const app = appState(launcher); @@ -69,14 +103,53 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat launcher: ${existsSync(launcher) ? "present" : "absent"} app: ${app.running ? "running" : "not running"} CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); + try { + await printInterceptStatus(config, resolveChatgptUnblockPort(config, live?.port)); + } 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; + } + 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! }); + console.log(`Experimental intercept launch watcher installed for port ${port}.`); return 0; } if (sub === "launch") { - if (config.chatgptDesktop?.appServerShim !== true) { - console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); + if (!shim && !intercept) { + console.error("Enable chatgptDesktop.appServerShim or chatgptDesktop.unblockSend before launching."); + 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) writeChatgptShimLauncher(); + } + // A loaded watcher would immediately put the intercept switches back on restore. + if (sub === "restore") { + if (chatgptUnblockWatcherStatus(0).agentLoaded) { + console.error("Uninstall the launch watcher before restoring native networking."); return 1; } - writeChatgptShimLauncher(); + } + 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 ? ["--env", `CODEX_CLI_PATH=${launcher}`] : []); + if (result.output) (result.ok ? console.log : console.error)(result.output); + return result.ok ? 0 : 1; } if (!(await quitApp(launcher))) { console.error("ChatGPT did not quit; quit it manually and retry."); @@ -85,15 +158,36 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); // Remove an inherited override too: restore must launch without CODEX_CLI_PATH. const env = { ...process.env }; delete env.CODEX_CLI_PATH; - const result = spawnSync("open", ["-a", "ChatGPT", ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { + const result = spawnSync("open", ["-a", "ChatGPT", ...(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): Promise { + const listener = await probeChatgptUnblockListener(port); + const watcher = chatgptUnblockWatcherStatus(port); + const app = chatgptAppCommandLine(); + const flagged = app !== null && chatgptCommandLineHasRule(app, port); + const caPath = claudeInterceptCaCertPath(getConfigDir()); + const trust = await inspectChatgptCaTrust(caPath); + console.log(`send-unblock TLS intercept (experimental): ${config.chatgptDesktop?.unblockSend === true ? "on" : "off"} +listener: ${port} (${listener.state === "ours" ? "listening" : listener.state === "foreign" ? "held by another process" : "not listening"}) +resolver switch: ${chatgptUnblockResolverArg(port)} +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("ChatGPT is routed to a closed or foreign port; start opencodex again or run ocx chatgpt restore."); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index b5043bb8329..9838d96de32 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -90,7 +90,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection - 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/registry.ts b/src/cli/registry.ts index 25b7f8258ec..ba87ce7dcba 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -10,9 +10,9 @@ export interface CliCommandEntry { 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. 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 1e079eb5649..264404c162b 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -106,6 +106,9 @@ 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"); + } warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); @@ -624,8 +627,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/config-schema.ts b/src/config/schema/config-schema.ts index 3302e836d4b..c06047ea308 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -70,6 +70,7 @@ import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/inte import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; 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/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 8ccd389929c..4e1f9b051f2 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -44,7 +44,11 @@ import { getConfigDir } from "../paths"; import { COMPACTION_TRIGGERS } from "./compaction-triggers"; /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ -export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); +export const chatgptDesktopSchema = z.object({ + appServerShim: z.boolean().optional(), + unblockSend: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), +}).strict(); /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { diff --git a/src/lib/socks5-fetch.ts b/src/lib/socks5-fetch.ts index 52bdc3d3b50..a5f6ad9be63 100644 --- a/src/lib/socks5-fetch.ts +++ b/src/lib/socks5-fetch.ts @@ -1,6 +1,7 @@ import net, { type Socket } from "node:net"; import tls, { type TLSSocket } from "node:tls"; import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics"; +import { socks5Credentials, socks5Handshake } from "./socks5-handshake"; const DEFAULT_SOCKS5_PORT = 1080; const SOCKS5_CONNECT_TIMEOUT_MS = 30_000; @@ -9,12 +10,6 @@ const MAX_RESPONSE_HEADER_BYTES = 64 * 1024; const MAX_BODY_SLICE_BYTES = 64 * 1024; const MAX_DECODED_BODY_BYTES = 32 * 1024 * 1024; const MAX_STREAM_DECODE_EXPANSION_RATIO = 128; -const SOCKS5_VERSION = 0x05; -const SOCKS5_NO_AUTH = 0x00; -const SOCKS5_USER_PASS = 0x02; -const SOCKS5_CONNECT = 0x01; -const SOCKS5_DOMAIN = 0x03; -const SOCKS5_SUCCESS = 0x00; const CRLF = Buffer.from("\r\n"); const HEADER_END = Buffer.from("\r\n\r\n"); @@ -22,24 +17,6 @@ export class Socks5FetchError extends Error { override readonly name = "Socks5FetchError"; } -function proxyCredentials(proxy: URL): { username?: Uint8Array; password?: Uint8Array } { - if (!proxy.username && !proxy.password) return {}; - let username: string; - let password: string; - try { - username = decodeURIComponent(proxy.username); - password = decodeURIComponent(proxy.password); - } catch { - throw new Socks5FetchError("SOCKS5 proxy credentials contain invalid percent encoding"); - } - const usernameBytes = new TextEncoder().encode(username); - const passwordBytes = new TextEncoder().encode(password); - if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { - throw new Socks5FetchError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); - } - return { username: usernameBytes, password: passwordBytes }; -} - function validateProxy(proxy: string): URL { let parsed: URL; try { @@ -55,7 +32,7 @@ function validateProxy(proxy: string): URL { throw new Socks5FetchError("SOCKS5 proxy port is invalid"); } if (parsed.search || parsed.hash) throw new Socks5FetchError("SOCKS5 proxy URL must not contain a query or fragment"); - proxyCredentials(parsed); + socks5Credentials(parsed); return parsed; } @@ -130,6 +107,14 @@ class SocketReader { socket.resume(); } + write(bytes: Uint8Array): void { + this.socket.write(bytes); + } + + readExact(bytes: number, signal?: AbortSignal): Promise { + return this.read(bytes, signal); + } + private readonly onData = (chunk: Buffer | string): void => { const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; if (this.anyWaiters.length > 0) { @@ -263,7 +248,7 @@ class SocketReader { async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): Promise { const parsedProxy = validateProxy(proxy); - const credentials = proxyCredentials(parsedProxy); + const credentials = socks5Credentials(parsedProxy); const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, ""); const socket = await connectSocket(proxyHost, Number(parsedProxy.port) || DEFAULT_SOCKS5_PORT, signal); socket.setTimeout(SOCKS5_CONNECT_TIMEOUT_MS, () => { @@ -271,45 +256,7 @@ async function socks5Connect(proxy: string, target: URL, signal?: AbortSignal): }); const reader = new SocketReader(socket); try { - const methods = credentials.username ? Buffer.from([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) : Buffer.from([SOCKS5_NO_AUTH]); - socket.write(Buffer.from([SOCKS5_VERSION, methods.byteLength, ...methods])); - const greeting = await reader.read(2, signal); - if (greeting[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid greeting"); - if (greeting[1] === SOCKS5_USER_PASS && credentials.username && credentials.password) { - socket.write(Buffer.from([ - 0x01, - credentials.username.byteLength, - ...credentials.username, - credentials.password.byteLength, - ...credentials.password, - ])); - const auth = await reader.read(2, signal); - if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5FetchError("SOCKS5 proxy authentication failed"); - } else if (greeting[1] !== SOCKS5_NO_AUTH) { - throw new Socks5FetchError("SOCKS5 proxy does not accept an offered authentication method"); - } - - const hostname = new TextEncoder().encode(target.hostname); - if (hostname.byteLength > 255) throw new Socks5FetchError("SOCKS5 target hostname is too long"); - const port = targetPort(target); - socket.write(Buffer.from([ - SOCKS5_VERSION, - SOCKS5_CONNECT, - 0x00, - SOCKS5_DOMAIN, - hostname.byteLength, - ...hostname, - port >> 8, - port & 0xff, - ])); - const reply = await reader.read(4, signal); - if (reply[0] !== SOCKS5_VERSION) throw new Socks5FetchError("SOCKS5 proxy returned an invalid connect response"); - if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5FetchError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); - if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { - throw new Socks5FetchError("SOCKS5 proxy returned an invalid address type or reserved byte"); - } - const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.read(1, signal))[0]! : 16; - await reader.read(addressLength + 2, signal); + await socks5Handshake(reader, { host: target.hostname, port: targetPort(target) }, credentials, signal); socket.setTimeout(0); return socket; } catch (error) { diff --git a/src/lib/socks5-handshake.ts b/src/lib/socks5-handshake.ts new file mode 100644 index 00000000000..3b1e1306bb0 --- /dev/null +++ b/src/lib/socks5-handshake.ts @@ -0,0 +1,112 @@ +/** + * The SOCKS5 handshake both 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/chatgpt/desktop-unblock/ws-upstream.ts` (the ChatGPT desktop relay's raw + * dial) 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 + * greeting offers NO-AUTH alongside USER-PASS, which keeps an unauthenticated + * proxy working for a caller that configured more than it needed. + */ + +export class Socks5HandshakeError extends Error { + override readonly name = "Socks5HandshakeError"; +} + +const SOCKS5_VERSION = 0x05; +const SOCKS5_NO_AUTH = 0x00; +const SOCKS5_USER_PASS = 0x02; +const SOCKS5_CONNECT = 0x01; +const SOCKS5_DOMAIN = 0x03; +const SOCKS5_SUCCESS = 0x00; + +/** RFC 1929 credentials decoded from the proxy URL, each bounded to one length byte. */ +export interface Socks5Credentials { + username?: Uint8Array; + password?: Uint8Array; +} + +export function socks5Credentials(proxy: URL): Socks5Credentials { + if (!proxy.username && !proxy.password) return {}; + let username: string; + let password: string; + try { + username = decodeURIComponent(proxy.username); + password = decodeURIComponent(proxy.password); + } catch { + throw new Socks5HandshakeError("SOCKS5 proxy credentials contain invalid percent encoding"); + } + const usernameBytes = new TextEncoder().encode(username); + const passwordBytes = new TextEncoder().encode(password); + if (usernameBytes.byteLength > 255 || passwordBytes.byteLength > 255) { + throw new Socks5HandshakeError("SOCKS5 proxy credentials must each fit in 255 UTF-8 bytes"); + } + return { username: usernameBytes, password: passwordBytes }; +} + +/** + * The slice of a transport's socket reader the handshake needs: exact-length reads + * that reject on close, on error, or on the transport's own timeout, plus writes. + * Bytes read past a step stay queued in the transport's reader; the handshake never + * touches the socket itself. + */ +export interface Socks5HandshakeReader { + write(bytes: Uint8Array): void; + readExact(bytes: number, signal?: AbortSignal): Promise; +} + +export interface Socks5Target { + host: string; + port: number; +} + +/** Negotiate methods, authenticate when the proxy picks USER-PASS, and CONNECT to `target`. */ +export async function socks5Handshake( + reader: Socks5HandshakeReader, + target: Socks5Target, + credentials: Socks5Credentials, + signal?: AbortSignal, +): Promise { + const hostBytes = Buffer.from(target.host, "utf8"); + if (hostBytes.byteLength > 255) throw new Socks5HandshakeError("SOCKS5 target hostname is too long"); + const methods = credentials.username ? [SOCKS5_NO_AUTH, SOCKS5_USER_PASS] : [SOCKS5_NO_AUTH]; + 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) { + reader.write(Buffer.from([ + 0x01, + credentials.username.byteLength, + ...credentials.username, + credentials.password.byteLength, + ...credentials.password, + ])); + const auth = await reader.readExact(2, signal); + if (auth[0] !== 0x01 || auth[1] !== 0x00) throw new Socks5HandshakeError("SOCKS5 proxy authentication failed"); + } else if (greeting[1] !== SOCKS5_NO_AUTH) { + throw new Socks5HandshakeError("SOCKS5 proxy does not accept an offered authentication method"); + } + + reader.write(Buffer.from([ + SOCKS5_VERSION, + SOCKS5_CONNECT, + 0x00, + SOCKS5_DOMAIN, + hostBytes.byteLength, + ...hostBytes, + target.port >> 8, + target.port & 0xff, + ])); + const reply = await reader.readExact(4, signal); + if (reply[0] !== SOCKS5_VERSION) throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid connect response"); + if (reply[1] !== SOCKS5_SUCCESS) throw new Socks5HandshakeError(`SOCKS5 proxy refused the connection (code ${reply[1]})`); + if (reply[2] !== 0x00 || ![0x01, SOCKS5_DOMAIN, 0x04].includes(reply[3]!)) { + throw new Socks5HandshakeError("SOCKS5 proxy returned an invalid address type or reserved byte"); + } + const addressLength = reply[3] === 0x01 ? 4 : reply[3] === SOCKS5_DOMAIN ? (await reader.readExact(1, signal))[0]! : 16; + await reader.readExact(addressLength + 2, signal); +} diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts new file mode 100644 index 00000000000..11db857290a --- /dev/null +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -0,0 +1,54 @@ +import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; +import { startChatgptUnblock } from "../../chatgpt/desktop-unblock/runtime"; +import { chatgptAppCommandLine, chatgptCommandLineHasRule } from "../../chatgpt/desktop-unblock/launch-watcher"; + +/** + * 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. + * + * 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; +} + +export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { + let pending: Promise | null> = Promise.resolve(null); + return { + start(options) { + pending = startChatgptUnblock(options).then(handle => { + if (handle) { + console.log(`🔓 ChatGPT send-unblock active on https://127.0.0.1:${handle.port} (CA: ${handle.caCertPath})`); + 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; + }); + }, + async stop() { + const handle = await pending; + if (!handle) return; + await handle.stop(); + warnIfAppStillRouted(handle.port); + }, + }; +} + +function warnIfAppStillRouted(port: number): void { + if (process.platform !== "darwin") return; + try { + const app = chatgptAppCommandLine(); + if (app === null) 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 6df3225f35d..fb475a60f18 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"; @@ -45,6 +46,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({ @@ -104,6 +106,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(), @@ -120,6 +123,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 cd76ad5e0ad..737942743b7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -495,8 +495,8 @@ 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. */ + chatgptDesktop?: { appServerShim?: boolean; unblockSend?: 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 398edd1e2c2..bf5b57a2494 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -79,7 +79,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | -| [`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. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -120,6 +120,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/chatgpt-command.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | @@ -137,6 +138,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/integrations/` | [`clients/integrations.md`](clients/integrations.md) | | `src/lab/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `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/socks5-handshake.ts` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/link/` | [`remote-link.md`](remote-link.md) | | `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/plugins/` | [`ops/plugins.md`](ops/plugins.md) | @@ -151,6 +153,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/routing/` | [`catalog.md`](catalog.md) | | `src/server/` | [`runtime.md`](runtime.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/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-and-adapters.md`](providers-and-adapters.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/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/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 3fd7a7ee34a..b4d452d4429 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -1,9 +1,9 @@ -# 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 +`chatgptDesktop.appServerShim === true` for an explicit launch. The strict config leaf accepts optional boolean `appServerShim` and `unblockSend` +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 @@ -35,13 +35,64 @@ Launch writes a mode-0755 executable, waits for the old ChatGPT instance to quit then invokes open with the launcher in CODEX_CLI_PATH. Restore relaunches without that override and removes the launcher only after open succeeds. Status reports the experimental flag, launcher presence, and the named bundle process's override -without printing its environment. Other platforms reject all three operations. +without printing its environment. 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. `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. +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. + +`src/chatgpt/desktop-unblock/launch-watcher.ts` owns resolver/system-proxy launch +arguments and the optional launchd script. The watcher has no shim mode or PAC +fallback. Its SingletonLock event checks listener identity, leaves native +launches alone while the intercept is down, and serializes corrective restarts. +Explicit CLI launch can supply the existing shim's launcher environment along +with intercept arguments. A loaded watcher must be uninstalled before CLI +restore. The CLI preserves shim-only launch without a running proxy and reports +intercept port, trust, listener identity, watcher freshness and app switches. + +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 PAC generator, +entry proxy, or duplicate app-server shim is part of this integration. diff --git a/structure/config.md b/structure/config.md index e8ff6b998ae..fecd11b98d4 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. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). +Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts optional boolean `appServerShim` and `unblockSend` flags, both default off, and an optional integer `port` in 1..65535. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). diff --git a/structure/manifest.json b/structure/manifest.json index d378c6df6e1..37c89b8230c 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -443,9 +443,12 @@ "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/chatgpt/", + "src/cli/chatgpt-command.ts", + "src/server/index/chatgpt-unblock-lifecycle.ts", + "src/lib/socks5-handshake.ts" ] }, { diff --git a/structure/runtime.md b/structure/runtime.md index 5c904cd7e60..291c8901eec 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -235,7 +235,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 @@ -598,3 +598,9 @@ registration succeeds. 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 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. + +The optional macOS ChatGPT send-unblock listener joins this same synchronous startup +composition and awaited shutdown through `src/server/index/chatgpt-unblock-lifecycle.ts`. +It starts only for `chatgptDesktop.unblockSend === true` outside the client role, stores its +startup promise and degrades failures to warnings. Its shared-CA trust, resolver +launch and credential-relay boundaries are owned by [ChatGPT Desktop](clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 15674c7b081..b293826f81a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -27,6 +27,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/chatgpt/desktop-unblock/ws-upstream.ts` | Fetch tunnels 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. | @@ -299,7 +300,9 @@ admission or account-snapshot pairing. The forwarding contract is covered in the compatibility config facade does not own a second activation path. `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-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts index 96df45f5e6a..84ac6b9952a 100644 --- a/tests/clients/desktop-chatgpt-config.test.ts +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -15,7 +15,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(); @@ -31,7 +31,11 @@ describe("experimental ChatGPT desktop config", () => { if (process.platform !== "darwin") expect(diagnostics.warnings?.join(" ")).toContain("macOS only"); }); 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..2ea440b2fa4 --- /dev/null +++ b/tests/clients/desktop-rewrite.test.ts @@ -0,0 +1,288 @@ +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 mixed = JSON.stringify({ + rate_limit: { allowed: false, limit_reached: true }, + 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 }); + expect(usage.blocked_features).toHaveLength(1); + const conversation = JSON.parse(stripSendBlocksFromJson(mixed, "conversation")!); + expect(conversation.rate_limit).toEqual({ allowed: false, limit_reached: true }); + 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: 42 } } }; + expect(unlockRateLimitGate(snapshot)).toBe(true); + expect(snapshot.rate_limit.allowed).toBe(true); + expect(snapshot.rate_limit.primary_window.used_percent).toBe(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-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..e9e77336f01 --- /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, 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 unknown PAC config is rejected", () => { + for (const block of [{ unblockSend: false }, { appServerShim: true }, { unblockSend: true }, { appServerShim: true, unblockSend: true, port: 10300 }]) { + expect(validateConfigCandidate(candidate(block)).ok).toBe(true); + } + expect(validateConfigCandidate(candidate({ unblockSend: true, pacFallback: true })).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-launch-script.test.ts b/tests/clients/desktop-unblock-launch-script.test.ts new file mode 100644 index 00000000000..02367bd27bf --- /dev/null +++ b/tests/clients/desktop-unblock-launch-script.test.ts @@ -0,0 +1,342 @@ +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, + chatgptCommandLineHasRule, +} from "../../src/chatgpt/desktop-unblock/launch-watcher"; + +const PORT = 10300; +const RESOLVER = "--host-resolver-rules=MAP chatgpt.com 127.0.0.1:10300"; + +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 +}`; + +type AppState = "none" | "plain" | "flagged"; + +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. + stub("pgrep", `[ "$1" = -a ] && shift +[ "$1" = -x ] || { echo "stub pgrep only supports -x" >&2; exit 2; } +found=1 +while IFS='|' read -r pid name command; do + [ "$name" = "$2" ] && { echo "$pid"; found=0; } +done < "$STUB_DIR/processes" +exit $found`); + stub("ps", `pid="\${@: -1}" +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. + stub("curl", `case "$STUB_LISTENER" in + ours) echo '{"service":"opencodex-chatgpt-unblock","preservedSendBlocks":[]}' ;; + foreign) echo 'another server' ;; + *) exit 7 ;; +esac`); + stub("scutil", `cat "$STUB_DIR/scutil.txt"`); + // Quitting removes the app's main process (helpers exit with it only in reality; irrelevant here). + stub("osascript", `echo quit >> "$STUB_DIR/calls" +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" +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("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; + listener?: "ours" | "foreign" | "down"; + quitIgnored?: boolean; + decoy?: boolean; + configDir?: string; + openArgs?: string[]; +}) { + const processes = [ + options.app === "plain" ? `400|ChatGPT|${APP_BINARY}` : null, + options.app === "flagged" ? `400|ChatGPT|${APP_BINARY} ${RESOLVER} --proxy-bypass-list=chatgpt.com` : 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); + const script = join(dir, "launch.sh"); + writeFileSync(script, buildChatgptUnblockWatcherScript(PORT, options.configDir ?? dir, options.openArgs)); + const result = spawnSync("/bin/bash", [script, mode], { + encoding: "utf8", + env: { + PATH: `${stubs}:/usr/bin:/bin`, + TMPDIR: join(dir, "tmp"), + STUB_DIR: dir, + STUB_LISTENER: options.listener ?? "ours", + STUB_QUIT_IGNORED: options.quitIgnored ? "1" : "0", + }, + }); + 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), + openEnv: read("open-env").split("\n").filter(Boolean), + log: read("chatgpt-unblock-watcher.log"), + }; +} + +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 with 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,direct://", + "--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,direct://", + "--proxy-bypass-list=chatgpt.com", + ]); + }); + + 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]); + }); + + test("unreadable scutil output degrades to the resolver switch alone", () => { + const r = run("launch", { app: "none", scutil: "" }); + expect(r.openArgs).toEqual([RESOLVER]); + }); +}); + +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("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("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("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); + }); +}); + + +test.skipIf(process.platform === "win32")("explicit open arguments force a fresh process and preserve shell quoting", () => { + const value = "EXPERIMENTAL_LAUNCH_PATH=/cfg/quoted' & path"; + const result = run("launch", { app: "flagged", openArgs: ["--env", value] }); + expect(result.status).toBe(0); + expect(result.calls).toEqual(["quit", "open"]); + expect(result.openEnv).toEqual([value]); + expect(result.openArgs).toEqual([RESOLVER]); +}); diff --git a/tests/clients/desktop-unblock-listener.test.ts b/tests/clients/desktop-unblock-listener.test.ts new file mode 100644 index 00000000000..053c04455ce --- /dev/null +++ b/tests/clients/desktop-unblock-listener.test.ts @@ -0,0 +1,180 @@ +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", () => { + const locked = JSON.stringify({ usage: { rate_limit: { allowed: false, limit_reached: true } } }); + const opened = JSON.stringify({ usage: { rate_limit: { allowed: true, limit_reached: false } } }); + + 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 } }); + + 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-runtime.test.ts b/tests/clients/desktop-unblock-runtime.test.ts new file mode 100644 index 00000000000..10e5ab70bc4 --- /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 without PAC or 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..f5dca767ca1 --- /dev/null +++ b/tests/clients/desktop-unblock-watcher-install.test.ts @@ -0,0 +1,141 @@ +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 { + 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 { createLocalInterceptCa, 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 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("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 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(createLocalInterceptCa(), ["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!); + 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..88e0404bc1d --- /dev/null +++ b/tests/clients/desktop-unblock-ws-upstream.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import type { AddressInfo, Server } 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())); + } + }); + } +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 236a542357f..f33b8ee2168 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,15 @@ { + "desktop-unblock-ws-frame.test.ts": "clients", + "desktop-unblock-watcher-install.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-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", "desktop-app-server-shim.test.ts": "clients", "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", 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..a01f6941397 --- /dev/null +++ b/tests/lib/socks5-handshake.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { socks5Credentials, socks5Handshake, Socks5HandshakeError } from "../../src/lib/socks5-handshake"; + +/** + * Byte-level contract of the SOCKS5 handshake both raw transports ride: method + * negotiation, RFC 1929 subnegotiation, CONNECT framing, and reply parsing. + * `tests/lib/socks5-fetch.test.ts` and `tests/chatgpt-unblock/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("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)]); + }); +}); From 51a92cf8799e4c7f03e743602e95512f85687cff Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 16:58:14 +0900 Subject: [PATCH 3/4] docs(structure): keep runtime.md within its 600-line budget Fold the send-unblock lifecycle note into one sentence; the details stay in structure/clients/chatgpt-desktop.md. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- structure/runtime.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/structure/runtime.md b/structure/runtime.md index 291c8901eec..1d38e8c4f29 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -597,10 +597,4 @@ registration succeeds. 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 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. - -The optional macOS ChatGPT send-unblock listener joins this same synchronous startup -composition and awaited shutdown through `src/server/index/chatgpt-unblock-lifecycle.ts`. -It starts only for `chatgptDesktop.unblockSend === true` outside the client role, stores its -startup promise and degrades failures to warnings. Its shared-CA trust, resolver -launch and credential-relay boundaries are owned by [ChatGPT Desktop](clients/chatgpt-desktop.md#optional-local-ca-send-unblock-intercept). +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 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. 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, 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). From f89bffc3c70ce7745cb61daf1f33f5fcb2cd11df Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 17:16:33 +0900 Subject: [PATCH 4/4] perf(chatgpt): load the send-unblock intercept only when it is enabled The server lifecycle imported the TLS listener, local CA, WebSocket relay and launch watcher modules on every start. Load them dynamically, and only when chatgptDesktop.unblockSend is on, so a default install evaluates none of them and startServer stays synchronous. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- src/server/index/chatgpt-unblock-lifecycle.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts index 11db857290a..61ce80c93a4 100644 --- a/src/server/index/chatgpt-unblock-lifecycle.ts +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -1,6 +1,5 @@ +import type { OcxConfig } from "../../types"; import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; -import { startChatgptUnblock } from "../../chatgpt/desktop-unblock/runtime"; -import { chatgptAppCommandLine, chatgptCommandLineHasRule } from "../../chatgpt/desktop-unblock/launch-watcher"; /** * Owns the ChatGPT desktop send-unblock listener on behalf of `startServer`. The listener is @@ -8,6 +7,10 @@ import { chatgptAppCommandLine, chatgptCommandLineHasRule } from "../../chatgpt/ * because every other duty keeps working without it. `startServer` stays synchronous, so the * start is fire-and-forget and `stop()` awaits whatever it produced. * + * 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. @@ -17,33 +20,46 @@ export interface ChatgptUnblockLifecycle { 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); return { start(options) { - pending = startChatgptUnblock(options).then(handle => { - if (handle) { - console.log(`🔓 ChatGPT send-unblock active on https://127.0.0.1:${handle.port} (CA: ${handle.caCertPath})`); - 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; - }); + if (!unblockRequested(options.config)) { + pending = Promise.resolve(null); + return; + } + 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})`); + 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; + }); }, async stop() { const handle = await pending; if (!handle) return; await handle.stop(); - warnIfAppStillRouted(handle.port); + await warnIfAppStillRouted(handle.port); }, }; } -function warnIfAppStillRouted(port: number): void { +async function warnIfAppStillRouted(port: number): Promise { if (process.platform !== "darwin") return; try { + const { chatgptAppCommandLine, chatgptCommandLineHasRule } = await import("../../chatgpt/desktop-unblock/launch-watcher"); const app = chatgptAppCommandLine(); if (app === null) return; if (!chatgptCommandLineHasRule(app, port)) return;