From efddc1364f73d7c7f39bac2197dd1bcb5ca05f6e Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:48 +0800 Subject: [PATCH 1/4] feat: ChatGPT desktop send-unblock intercept (opt-in) Adds an opt-in `chatgptDesktop.unblockSend` integration for the ChatGPT desktop app. When enabled, the service binds a local TLS listener for `chatgpt.com` (shared intercept CA, port defaults to public port + 200) and rewrites the subscription-quota send locks out of the payloads the desktop app reads: - `blocked_features[send|tpp_send]` and `limits_progress[send]` entries in conversation payloads - `rate_limit.allowed` / `rate_limit.limit_reached` gate flags in the `/backend-api/wham/usage` snapshot and usage stream Quota display stays honest: percentages, reset timestamps and the upsell banner pass through byte-identical, so the app keeps showing the account's real usage while the composer unlocks for turns whose model calls are routed to third-party providers by opencodex. Design notes: - Launch rule (`--host-resolver-rules=MAP chatgpt.com 127.0.0.1:`) is printed at startup; only the exact host `chatgpt.com` is mapped, so auth.openai.com and the codex-cloud WebSocket stay native. - Fire-and-forget lifecycle like the Claude intercept: a bind failure degrades to a warning and never blocks startup. - SSE is rewritten line-buffered; untouched streams keep their exact chunking and line endings. - Config group is opt-in, off by default, malformed reads as off. Tests: 11 cases over JSON/SSE rewrite, gate flipping, unchanged detection, malformed-entry passthrough and display-field preservation. --- .../001_test_inventory.md | 4 + scripts/test-layout/layout.json | 7 + src/chatgpt/desktop-unblock/listener.ts | 140 ++++++++++++++++ src/chatgpt/desktop-unblock/rewrite.ts | 146 +++++++++++++++++ src/chatgpt/desktop-unblock/runtime.ts | 75 +++++++++ src/config/schema/config-schema.ts | 6 + src/server/index/chatgpt-unblock-lifecycle.ts | 34 ++++ src/server/index/optional-listeners.ts | 6 + src/types/config.ts | 15 ++ structure/INDEX.md | 1 + structure/manifest.json | 7 +- tests/chatgpt-unblock/rewrite.test.ts | 151 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 7 + 13 files changed, 598 insertions(+), 1 deletion(-) 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/server/index/chatgpt-unblock-lifecycle.ts create mode 100644 tests/chatgpt-unblock/rewrite.test.ts diff --git a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md index 44defb0acd3..8d1ff88ce8e 100644 --- a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md +++ b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md @@ -415,6 +415,10 @@ Sum of the table: **1061**. Zero leftover. `phase100-native-parity.test.ts` +#### `tests/chatgpt-unblock/` (1) + +`rewrite.test.ts` + ## 3. Cross-cutting coupling ### 3.A tests/helpers imported by how many tests (unique files) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c10d490a8e3..7efe33a7a6e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1596,6 +1596,7 @@ "responses-xai-request-compat.test.ts": "responses", "restore-completes-shared-teardown.test.ts": "cli", "retry-after-429.test.ts": "server", + "rewrite.test.ts": "chatgpt-unblock", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", "routed-agent-messages.test.ts": "adapters", @@ -1779,6 +1780,12 @@ "ultrafast-tier-honesty.test.ts": "codex-integration", "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", + "unblock-ca-trust.test.ts": "chatgpt-unblock", + "unblock-launch-script.test.ts": "chatgpt-unblock", + "unblock-listener.test.ts": "chatgpt-unblock", + "unblock-watcher-install.test.ts": "chatgpt-unblock", + "unblock-ws-frame.test.ts": "chatgpt-unblock", + "unblock-ws-relay.test.ts": "chatgpt-unblock", "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", diff --git a/src/chatgpt/desktop-unblock/listener.ts b/src/chatgpt/desktop-unblock/listener.ts new file mode 100644 index 00000000000..9b4bdf4af95 --- /dev/null +++ b/src/chatgpt/desktop-unblock/listener.ts @@ -0,0 +1,140 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "../../claude/intercept/local-ca"; +import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; +import { stripSendBlocksFromJson, stripSendBlocksFromSseLine } from "./rewrite"; + +/** + * 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. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", +]); + +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; +} + +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(debug?: (line: string, rewritten: string | null) => void): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ""; + 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 = stripSendBlocksFromSseLine(line); + debug?.(line, rewritten); + controller.enqueue(encoder.encode(`${rewritten ?? line}\n`)); + } + }, + flush(controller) { + if (pending.length === 0) return; + const rewritten = stripSendBlocksFromSseLine(pending); + debug?.(pending, rewritten); + controller.enqueue(encoder.encode(rewritten ?? pending)); + pending = ""; + }, + }); +} + +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, +): Promise { + const url = new URL(req.url); + 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 contentType = upstream.headers.get("content-type") ?? ""; + if (isJsonContentType(contentType)) { + let text: string; + try { + text = await upstream.text(); + } catch { + return new Response(JSON.stringify({ error: { message: "chatgpt unblock upstream read failed" } }), { status: 502, headers }); + } + const rewritten = stripSendBlocksFromJson(text); + return new Response(rewritten ?? text, { status: upstream.status, statusText: upstream.statusText, headers }); + } + if (isEventStreamContentType(contentType) && upstream.body) { + return new Response(upstream.body.pipeThrough(sseRewriteStream()), { status: upstream.status, statusText: upstream.statusText, headers }); + } + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); +} + +/** Bind the intercept TLS listener on an ephemeral loopback port. */ +export function startChatgptUnblockListener(options: ChatgptUnblockListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CHATGPT_UNBLOCK_UPSTREAM; + 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, + async fetch(req) { + return relayWithSendUnblock(req, upstreamBase, options.fetchImpl); + }, + }); +} diff --git a/src/chatgpt/desktop-unblock/rewrite.ts b/src/chatgpt/desktop-unblock/rewrite.ts new file mode 100644 index 00000000000..91719c13d88 --- /dev/null +++ b/src/chatgpt/desktop-unblock/rewrite.ts @@ -0,0 +1,146 @@ +/** + * Send-unblock rewriting for the ChatGPT desktop intercept. + * + * The ChatGPT desktop app disables the conversation composer from two backend data shapes: + * + * 1. Conversation payloads (`/conversation/init` and friends) attach `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. + * + * Both describe the account's own subscription quota -- data that is meaningless for turns + * whose model calls are routed to third-party providers by opencodex. + * + * The rewriter removes exactly the send-lock entries and flips exactly the usage gate flags. + * Quota display stays honest: `banner_info` / `rate_limit_upsell`, the `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 while the composer + * unlocks. + */ + +/** `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"; + +export interface RewriteResult { + value: unknown; + changed: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSendBlockedFeature(entry: unknown): boolean { + return isRecord(entry) && SEND_BLOCKED_FEATURE_NAMES.has(String(entry.name ?? "")); +} + +function isExhaustedSendLimit(entry: unknown): boolean { + if (!isRecord(entry) || entry.feature_name !== SEND_LIMIT_FEATURE_NAME) return false; + const remaining = entry.remaining; + return typeof remaining === "number" && remaining <= 0; +} + +/** + * Recursively strip send-lock entries from any `blocked_features` / `limits_progress` arrays. + * Malformed entries are kept: the rewrite owns removal of known-shaped blocks, not validation. + */ +export function stripSendBlocks(value: unknown): RewriteResult { + if (Array.isArray(value)) { + let changed = false; + const items = value.map(item => { + const result = stripSendBlocks(item); + 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" && Array.isArray(child)) { + const kept = child.filter(entry => !isSendBlockedFeature(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + if (key === "limits_progress" && Array.isArray(child)) { + const kept = child.filter(entry => !isExhaustedSendLimit(entry)); + changed ||= kept.length !== child.length; + out[key] = kept; + continue; + } + const result = stripSendBlocks(child); + changed ||= result.changed; + out[key] = result.value; + } + return { value: out, changed }; +} + +/** + * Flip the desktop usage snapshot's send gate in place: `rate_limit.allowed` false -> true and + * `rate_limit.limit_reached` true -> false, at any depth (top-level for snapshot endpoints, + * under `usage` for stream events). Window percentages, reset timestamps, the upsell banner + * and every other display field are left exactly as the backend sent them. + * + * Returns whether anything changed. + */ +export function unlockRateLimitGate(value: unknown): boolean { + let changed = false; + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (!isRecord(node)) return; + const rateLimit = node.rate_limit; + if (isRecord(rateLimit)) { + if (rateLimit.allowed === false) { + rateLimit.allowed = true; + changed = true; + } + if (rateLimit.limit_reached === true) { + rateLimit.limit_reached = false; + changed = true; + } + } + for (const child of Object.values(node)) visit(child); + }; + visit(value); + return changed; +} + +/** + * Rewrite a JSON response body. Returns `null` when the body is not valid JSON or contains + * nothing to rewrite, so callers can pass the original bytes through untouched. + */ +export function stripSendBlocksFromJson(text: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + const stripped = stripSendBlocks(parsed); + const unlocked = unlockRateLimitGate(stripped.value); + return stripped.changed || unlocked ? JSON.stringify(stripped.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. Returns `null` when the + * line is unchanged. + */ +export function stripSendBlocksFromSseLine(line: string): string | null { + const match = /^(data: ?)(.*)$/.exec(line); + if (!match) return null; + const rewritten = stripSendBlocksFromJson(match[2]!); + if (rewritten === null) return null; + return `${match[1]}${rewritten}`; +} diff --git a/src/chatgpt/desktop-unblock/runtime.ts b/src/chatgpt/desktop-unblock/runtime.ts new file mode 100644 index 00000000000..2171ee85f90 --- /dev/null +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -0,0 +1,75 @@ +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"; + +/** + * 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 (config.runtimeRole === "client") return false; + return config.chatgptDesktop?.unblockSend === true; +} + +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; + return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; +} + +/** 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}`; +} + +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 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 resolver rule names it. + const listener = startChatgptUnblockListener({ leaf, port: chatgptUnblockPort(options.config, options.publicPort) }); + return { + port: listener.port ?? chatgptUnblockPort(options.config, options.publicPort), + caCertPath: claudeInterceptCaCertPath(configDir), + listener, + stop: async () => { + await listener.stop(true); + }, + }; +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index c881a269fc1..45c39c145cf 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -249,6 +249,12 @@ export const configSchema = z.object({ enabled: z.boolean().optional(), leadTimeMinutes: z.number().int().min(1).max(60).optional(), }).optional().catch(undefined), + // ChatGPT desktop send-unblock (opt-in, default off). Same degrade-to-off rule: a malformed + // group must never cost the operator their other settings. + chatgptDesktop: z.object({ + unblockSend: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + }).optional().catch(undefined), // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool // feature must never cost the operator their providers. pool: z.object({ diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts new file mode 100644 index 00000000000..95bcef64b09 --- /dev/null +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -0,0 +1,34 @@ +import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; +import { chatgptUnblockResolverRule, startChatgptUnblock } from "../../chatgpt/desktop-unblock/runtime"; + +/** + * Owns the ChatGPT desktop send-unblock listener on behalf of `startServer`. The listener is + * an optional integration: a bind failure degrades to a warning, never to a startup failure, + * because every other duty keeps working without it. `startServer` stays synchronous, so the + * start is fire-and-forget and `stop()` awaits whatever it produced. + */ +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: open -a ChatGPT --args --host-resolver-rules='${chatgptUnblockResolverRule(handle.port)}'`); + } + return handle; + }).catch((error: unknown) => { + console.warn(`⚠ ChatGPT send-unblock could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + await (await pending)?.stop(); + }, + }; +} diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index 9542585bfa3..1836a361bef 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -4,6 +4,7 @@ import { createClaudeInterceptLifecycle, type ClaudeInterceptLifecycle, } from "./claude-intercept-lifecycle"; +import { createChatgptUnblockLifecycle, type ChatgptUnblockLifecycle } from "./chatgpt-unblock-lifecycle"; import { createLinkListenerLifecycle, linkRouteAllowed, @@ -43,6 +44,9 @@ export interface OptionalListenerSet { export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): OptionalListenerSet { const claudeIntercept: ClaudeInterceptLifecycle = createClaudeInterceptLifecycle(); + // Opt-in ChatGPT desktop send-unblock listener; like the Claude intercept it binds its own + // loopback port, starts fire-and-forget and degrades to a warning. + const chatgptUnblock: ChatgptUnblockLifecycle = createChatgptUnblockLifecycle(); const linkListener: LinkListenerLifecycle = createLinkListenerLifecycle(linkDeps); let activeConfig: OcxConfig | undefined; const supervisor = createLinkSupervisor({ @@ -91,6 +95,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(), @@ -107,6 +112,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 ad59d6f9f65..fcdda2063e8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1032,6 +1032,21 @@ export interface OcxConfig { * spends a second credit. A malformed value reads as off. */ resetCreditAutoRedeem?: { enabled?: boolean; leadTimeMinutes?: number }; + /** + * ChatGPT desktop-app integration, opt-in and off by default. + * + * With `unblockSend: true` the service binds a local TLS listener for `chatgpt.com` and + * rewrites the subscription-quota send locks out of the payloads the desktop app reads: + * `blocked_features[send]` / `limits_progress[send]` entries in conversation payloads, and + * `rate_limit.allowed` / `rate_limit.limit_reached` in the `/backend-api/wham/usage` + * snapshot and stream. Quota display (percentages, reset times, upsell banner) is left + * untouched, so the app keeps showing the account's real usage while the composer unlocks + * for turns whose model calls are routed to third-party providers. The app must be launched + * with the resolver rule printed at startup, and the intercept CA must be trusted once (see + * the startup log). A malformed value reads as off. `port` (1–65535) overrides the default + * listener port (public port + 200). + */ + chatgptDesktop?: { unblockSend?: boolean; port?: number }; /** * Shared account-pool kernel, opt-in and off by default. * diff --git a/structure/INDEX.md b/structure/INDEX.md index 4f653bf3fb0..5403ae1b423 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -166,6 +166,7 @@ A source area can be described by more than one doc, because these docs are orga | Source path | Why | | --- | --- | +| `src/chatgpt/` | ChatGPT desktop send-unblock intercept; upstream PR ships no structure doc for this feature area | ## Cross-cutting contracts diff --git a/structure/manifest.json b/structure/manifest.json index 734e7d71aa8..0de57a6c5ac 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -616,7 +616,12 @@ ] }, "grace": { - "undocumentedSourceAreas": [], + "undocumentedSourceAreas": [ + { + "path": "src/chatgpt/", + "reason": "ChatGPT desktop send-unblock intercept; upstream PR ships no structure doc for this feature area" + } + ], "unboundInvariants": [ { "id": "INV-HOME-01", diff --git a/tests/chatgpt-unblock/rewrite.test.ts b/tests/chatgpt-unblock/rewrite.test.ts new file mode 100644 index 00000000000..9e90830ca56 --- /dev/null +++ b/tests/chatgpt-unblock/rewrite.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { stripSendBlocks, stripSendBlocksFromJson, stripSendBlocksFromSseLine, unlockRateLimitGate } 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 send locks and keeps quota display data", () => { + const result = stripSendBlocks(blockedPayload); + expect(result.changed).toBe(true); + const value = result.value as typeof blockedPayload; + expect(value.blocked_features).toEqual([ + { name: "image_gen", block_reason: "usage_limit", resets_after: "2026-09-26T19:46:00Z" }, + ]); + // 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("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)); + expect(rewritten).not.toBeNull(); + const parsed = JSON.parse(rewritten!) as typeof blockedPayload; + expect(parsed.blocked_features).toHaveLength(1); + expect(parsed.blocked_features[0]!.name).toBe("image_gen"); + expect(parsed.banner_info).toEqual(blockedPayload.banner_info); + }); + + 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); + }); +}); + +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("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(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 22679a1b04f..a02e41384cc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1421,6 +1421,7 @@ "responses-usage-passthrough.test.ts": "responses", "responses-xai-request-compat.test.ts": "responses", "restore-completes-shared-teardown.test.ts": "cli", + "rewrite.test.ts": "chatgpt-unblock", "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", @@ -1605,6 +1606,12 @@ "ultrafast-tier-honesty.test.ts": "codex-integration", "umans-provider.test.ts": "providers", "uninstall.test.ts": "cli", + "unblock-ca-trust.test.ts": "chatgpt-unblock", + "unblock-launch-script.test.ts": "chatgpt-unblock", + "unblock-listener.test.ts": "chatgpt-unblock", + "unblock-watcher-install.test.ts": "chatgpt-unblock", + "unblock-ws-frame.test.ts": "chatgpt-unblock", + "unblock-ws-relay.test.ts": "chatgpt-unblock", "update-async-routes.test.ts": "server", "update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update", From 4ea5720bde3cc569867600b25d118248fd93d2c6 Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:56 +0800 Subject: [PATCH 2/4] feat: launch watcher for ChatGPT desktop send-unblock The Chromium resolver rule only applies when the app is launched with it, so a normal Dock/Spotlight launch reaches the real chatgpt.com and the composer locks again. Adds a launchd agent that watches the app's Electron SingletonLock (written on every launch) and, exactly once per launch, restarts the app with the resolver rule if it was started without one. There is no resident polling process: launchd wakes the one-shot script on the lock event and the script exits after one check. - `ocx chatgpt status|install-watcher|uninstall-watcher|launch` - The watcher only acts when the opencodex intercept listener is actually listening, so with the feature off the app stays native. - Install is idempotent (bootout + bootstrap) and survives reboots. --- src/chatgpt/desktop-unblock/launch-watcher.ts | 182 ++++++++++++++++++ src/cli/chatgpt-command.ts | 92 +++++++++ src/cli/dispatch.ts | 4 + src/cli/registry.ts | 13 ++ 4 files changed, 291 insertions(+) create mode 100644 src/chatgpt/desktop-unblock/launch-watcher.ts create mode 100644 src/cli/chatgpt-command.ts diff --git a/src/chatgpt/desktop-unblock/launch-watcher.ts b/src/chatgpt/desktop-unblock/launch-watcher.ts new file mode 100644 index 00000000000..0f7fc8bd50a --- /dev/null +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -0,0 +1,182 @@ +import { execFileSync } from "node:child_process"; +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 } from "./listener"; +import { chatgptUnblockResolverRule } from "./runtime"; + +/** + * Launch integration for the ChatGPT desktop send-unblock intercept. + * + * The Chromium resolver rule only applies when the app is launched with it, 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 resolver rule if it was + * started without one. There is no resident polling process: launchd wakes the script on the + * lock event and the script exits after one check. + * + * The watcher only acts when the opencodex intercept listener is actually listening, so with + * the feature off the app is left completely native. + */ + +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; +} + +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}`), + }; +} + +/** The one-shot launchd script: restart the app with the rule if this launch lacked it. */ +export function buildChatgptUnblockWatcherScript(port: number): string { + const rule = chatgptUnblockResolverRule(port); + return `#!/bin/bash +# opencodex ChatGPT send-unblock launch watcher (one-shot, launchd-triggered). +# Fires when the ChatGPT desktop app creates its Electron SingletonLock (i.e. on every +# launch). If the app was started WITHOUT the host-resolver rule that points ${CHATGPT_INTERCEPT_HOST} at +# the opencodex TLS listener (normal Dock/Spotlight launch), it is restarted once with the +# rule. Correctly-launched instances and an absent intercept are left alone. + +PORT=${port} +RULE='${rule}' +LOG="$HOME/.opencodex/chatgpt-unblock-watcher.log" + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } + +# Intercept must be listening; otherwise leave the app alone. +if ! lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + exit 0 +fi +# App running? +if ! pgrep -f "ChatGPT.app/Contents/MacOS/ChatGPT" >/dev/null 2>&1; then + exit 0 +fi +# Already launched with the rule? +if pgrep -f "MacOS/ChatGPT $RULE" >/dev/null 2>&1; then + exit 0 +fi +log "unflagged ChatGPT detected; restarting with resolver rule" +osascript -e 'quit app "ChatGPT"' >/dev/null 2>&1 +sleep 3 +open -a ChatGPT --args "$RULE" +log "relaunched with rule" +`; +} + +/** 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 + ${scriptPath} + + WatchPaths + + ${watchPath} + + StandardErrorPath + ${errPath} + + +`; +} + +function sh(command: string, args: string[]): { ok: boolean; output: string } { + try { + const output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { ok: true, output }; + } catch (error) { + const err = error as { status?: number; stdout?: string; stderr?: string }; + return { ok: false, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() }; + } +} + +export interface InstallChatgptUnblockWatcherOptions { + port: number; + configDir?: string; + /** Test seam: skip the macOS / app-presence guards. */ + assumeSupported?: boolean; +} + +/** Install the launch watcher: write script + agent plist and load it with launchd. */ +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 paths = chatgptUnblockWatcherPaths(options.configDir); + mkdirSync(expandHome("~/Library/LaunchAgents"), { recursive: true }); + writeFileSync(paths.scriptPath, buildChatgptUnblockWatcherScript(options.port), { mode: 0o700 }); + writeFileSync(paths.plistPath, buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath)); + // Idempotent load: boot out any previous generation first. + sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + sh("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, paths.plistPath]); +} + +/** Remove the launch watcher: unload the agent and delete its files. */ +export function uninstallChatgptUnblockWatcher(configDir?: string): void { + const paths = chatgptUnblockWatcherPaths(configDir); + sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); + for (const path of [paths.plistPath, paths.scriptPath]) { + try { + rmSync(path); + } catch { + /* already gone */ + } + } +} + +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", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; + const scriptUpToDate = scriptInstalled + && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port); + const plistUpToDate = plistInstalled + && readFileSync(paths.plistPath, "utf8") === buildChatgptUnblockWatcherPlist(paths.scriptPath, paths.lockPath, paths.errPath); + return { scriptInstalled, plistInstalled, agentLoaded, scriptUpToDate, plistUpToDate }; +} + +/** Launch the ChatGPT desktop app with the resolver rule (macOS). */ +export function launchChatgptWithRule(port: number): void { + if (process.platform !== "darwin") { + throw new Error("launching the ChatGPT desktop app is only supported on macOS"); + } + execFileSync("open", ["-a", "ChatGPT", "--args", chatgptUnblockResolverRule(port)], { stdio: "ignore" }); +} diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts new file mode 100644 index 00000000000..edeeb35e619 --- /dev/null +++ b/src/cli/chatgpt-command.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { loadConfig } from "../config"; +import { findLiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; +import { chatgptUnblockWatcherStatus, installChatgptUnblockWatcher, launchChatgptWithRule, uninstallChatgptUnblockWatcher } from "../chatgpt/desktop-unblock/launch-watcher"; +import { CHATGPT_UNBLOCK_PORT_OFFSET, chatgptUnblockResolverRule } from "../chatgpt/desktop-unblock/runtime"; + +/** + * `ocx chatgpt` — inspect and operate the ChatGPT desktop send-unblock integration. + * + * ocx chatgpt status Feature, listener, watcher and app state + * ocx chatgpt install-watcher Install the launch watcher (Dock/Spotlight launches too) + * ocx chatgpt uninstall-watcher Remove the launch watcher + * ocx chatgpt launch Launch the app with the resolver rule + */ + +function sh(command: string, args: string[]): boolean { + try { + execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + return true; + } catch { + return false; + } +} + +/** Port the intercept listens on: explicit config, else live proxy + offset, else default + offset. */ +export function resolveChatgptUnblockPort(config: OcxConfig, livePort: number | undefined): number { + const configured = config.chatgptDesktop?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + const publicPort = livePort ?? (typeof config.port === "number" ? config.port : 10100); + return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; +} + +export async function handleChatgptCommand(args: string[]): Promise { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + console.log(`Usage: + ocx chatgpt status Feature, listener, watcher and app state + ocx chatgpt install-watcher Install the launch watcher (covers Dock/Spotlight launches) + ocx chatgpt uninstall-watcher Remove the launch watcher + ocx chatgpt launch Launch the ChatGPT app with the resolver rule`); + return sub ? 0 : 64; + } + + const config = loadConfig(); + const live = await findLiveProxy().catch(() => null); + const port = resolveChatgptUnblockPort(config, live?.port); + const rule = chatgptUnblockResolverRule(port); + + if (sub === "status") { + const enabled = config.chatgptDesktop?.unblockSend === true; + const listening = sh("lsof", ["-nP", "-iTCP", `:${port}`, "-sTCP:LISTEN"]); + const watcher = chatgptUnblockWatcherStatus(port); + const appRunning = sh("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"]); + const appFlagged = sh("pgrep", ["-f", `MacOS/ChatGPT ${rule}`]); + console.log(`ChatGPT send-unblock: + feature enabled: ${enabled ? "yes" : "no (set chatgptDesktop.unblockSend: true)"} + listener port: ${port}${listening ? " (listening)" : " (not listening)"} + resolver rule: ${rule} + watcher script: ${watcher.scriptInstalled ? (watcher.scriptUpToDate ? "installed" : "installed (outdated; reinstall)") : "not installed"} + watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "not installed"} + app: ${appRunning ? (appFlagged ? "running with rule" : "running WITHOUT rule (composer will lock)") : "not running"}`); + return 0; + } + + if (sub === "install-watcher") { + if (config.chatgptDesktop?.unblockSend !== true) { + console.error("chatgptDesktop.unblockSend is not enabled; add it to ~/.opencodex/config.json first:"); + console.error(' { "chatgptDesktop": { "unblockSend": true } }'); + return 1; + } + installChatgptUnblockWatcher({ port }); + console.log(`🛰 Launch watcher installed for port ${port}.`); + console.log(" Normal Dock/Spotlight launches of the ChatGPT app are now corrected automatically."); + return 0; + } + + if (sub === "uninstall-watcher") { + uninstallChatgptUnblockWatcher(); + console.log("Launch watcher removed."); + return 0; + } + + if (sub === "launch") { + launchChatgptWithRule(port); + console.log(`Launched the ChatGPT app with ${rule}`); + return 0; + } + + console.error(`unknown subcommand: ${sub}`); + return 64; +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 9df115fa24b..d7f6da8df97 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -959,6 +959,10 @@ const commandRunners: Record = { } return await cmdClaude(deps.args.slice(1)); }, + chatgpt: async deps => { + const { handleChatgptCommand } = await import("./chatgpt-command"); + return await handleChatgptCommand(deps.args.slice(1)); + }, opencode: async deps => { const { cmdOpencode } = await import("./opencode"); return await cmdOpencode(deps.args.slice(1)); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 214baa31a25..d8c929259ee 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -487,6 +487,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Claude Code settings: ocx claude config ...", ], }, + { + name: "chatgpt", + usage: "ocx chatgpt ", + summary: "Inspect and operate the ChatGPT desktop send-unblock integration.", + details: [ + "status Feature, intercept listener, watcher and app launch state.", + "install-watcher Install the launchd watcher so Dock/Spotlight launches of the ChatGPT", + " app are automatically corrected to carry the host-resolver rule.", + "uninstall-watcher Remove the launch watcher script and agent.", + "launch Launch the ChatGPT app with the resolver rule.", + "Requires chatgptDesktop.unblockSend: true in config for install-watcher.", + ], + }, { name: "opencode", usage: "ocx opencode [opencode args...]", From 492b9a91c29902fed38f0c3882acc8417afad879 Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:55:53 +0800 Subject: [PATCH 3/4] fix(chatgpt-unblock): working launch path, WebSocket relay and review fixes Launch and watcher - Pass the resolver rule as one --host-resolver-rules= switch (a bare MAP argument is ignored by Chromium) and detect it the same way. - Build launch arguments from the system proxy at launch time: with an HTTP(S)/SOCKS system proxy add --proxy-server=,direct:// and --proxy-bypass-list=chatgpt.com, otherwise the resolver rule alone. No VPN rules are required; PAC degrades to the rule alone. - One script serves the launchd watcher, `ocx chatgpt launch` and the new `ocx chatgpt restore` (native relaunch). Quit is confirmed before reopening, runs are serialised with a lock, and the app is found by exact process name. - The watcher acts only when the port answers the listener's identity path, not merely when something listens there. - install/uninstall-watcher propagate launchctl failures; install asks for confirmation (--yes non-interactive); plist and script values are escaped. Listener - Relay WebSocket upgrades on the intercepted host (voice dictation and any other endpoint) through the configured proxy (HTTP CONNECT / SOCKS5 / direct). - Rewrite only conversation/init, the conversation stream and wham/usage; everything else passes through byte-identical. - Remove only quota send blocks; keep eligibility and unknown reasons (e.g. work_subscription_required) and report them in status. - Handle 204/205/304 and HEAD without a body, CRLF-framed SSE lines, and drop alt-svc so the app does not attempt HTTP/3. - Reject an out-of-range derived port with guidance. Status and docs - `ocx chatgpt status` reports listener identity, CA trust, kept send blocks and an app routed at a listener that is gone; stop warns about the latter. - List `ocx chatgpt` in the help banner (missing on the PR head) and note the ChatGPT lifecycle in the optionalListeners.start() synchronous-window entry. - Add the ChatGPT Desktop guide in English and all seven locales. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-site/astro.config.mjs | 1 + .../content/docs/fr/guides/chatgpt-desktop.md | 115 ++++++ .../content/docs/guides/chatgpt-desktop.md | 111 +++++ .../content/docs/ja/guides/chatgpt-desktop.md | 104 +++++ .../content/docs/ko/guides/chatgpt-desktop.md | 104 +++++ .../content/docs/ru/guides/chatgpt-desktop.md | 113 +++++ .../content/docs/tr/guides/chatgpt-desktop.md | 112 +++++ .../docs/zh-cn/guides/chatgpt-desktop.md | 100 +++++ .../docs/zh-tw/guides/chatgpt-desktop.md | 100 +++++ scripts/test-layout/layout.json | 9 +- src/chatgpt/desktop-unblock/ca-trust.ts | 51 +++ src/chatgpt/desktop-unblock/launch-watcher.ts | 387 +++++++++++++++--- src/chatgpt/desktop-unblock/listener.ts | 113 ++++- src/chatgpt/desktop-unblock/rewrite.ts | 130 ++++-- src/chatgpt/desktop-unblock/runtime.ts | 26 +- src/chatgpt/desktop-unblock/ws-frame.ts | 156 +++++++ src/chatgpt/desktop-unblock/ws-relay.ts | 341 +++++++++++++++ src/chatgpt/desktop-unblock/ws-upstream.ts | 239 +++++++++++ src/cli/chatgpt-command.ts | 185 ++++++--- src/cli/help.ts | 1 + src/cli/registry.ts | 8 +- src/server/index/chatgpt-unblock-lifecycle.ts | 25 +- tests/chatgpt-unblock/rewrite.test.ts | 98 ++++- .../chatgpt-unblock/unblock-ca-trust.test.ts | 68 +++ .../unblock-launch-script.test.ts | 325 +++++++++++++++ .../chatgpt-unblock/unblock-listener.test.ts | 151 +++++++ .../unblock-watcher-install.test.ts | 139 +++++++ .../chatgpt-unblock/unblock-ws-frame.test.ts | 98 +++++ .../chatgpt-unblock/unblock-ws-relay.test.ts | 346 ++++++++++++++++ tests/fixtures/test-layout-expected.json | 6 + tests/lab/core-lab-boundary.test.ts | 2 +- 31 files changed, 3590 insertions(+), 174 deletions(-) create mode 100644 docs-site/src/content/docs/fr/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/ja/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/ko/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/ru/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/tr/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/zh-cn/guides/chatgpt-desktop.md create mode 100644 docs-site/src/content/docs/zh-tw/guides/chatgpt-desktop.md create mode 100644 src/chatgpt/desktop-unblock/ca-trust.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 tests/chatgpt-unblock/unblock-ca-trust.test.ts create mode 100644 tests/chatgpt-unblock/unblock-launch-script.test.ts create mode 100644 tests/chatgpt-unblock/unblock-listener.test.ts create mode 100644 tests/chatgpt-unblock/unblock-watcher-install.test.ts create mode 100644 tests/chatgpt-unblock/unblock-ws-frame.test.ts create mode 100644 tests/chatgpt-unblock/unblock-ws-relay.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index fc4162f8879..711d1ed959b 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -100,6 +100,7 @@ export default defineConfig({ { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성", fr: "Compatibilité du contexte natif", "zh-CN": "原生上下文兼容性", "zh-TW": "原生脈絡相容性", ru: "Совместимость с нативным контекстом", ja: "ネイティブコンテキストの互換性", tr: "Yerel bağlam uyumluluğu" }, slug: "guides/codex-native-context" }, { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, { label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" }, + { label: "ChatGPT Desktop Send Unblock", translations: { fr: "Déblocage de l'envoi dans ChatGPT Desktop", ko: "ChatGPT 데스크톱 전송 잠금 해제", "zh-CN": "ChatGPT 桌面版发送键解锁", "zh-TW": "ChatGPT 桌面版傳送鍵解鎖", ru: "Разблокировка отправки в ChatGPT Desktop", ja: "ChatGPT デスクトップの送信ロック解除", tr: "ChatGPT Masaüstü Gönderme Kilidini Açma" }, slug: "guides/chatgpt-desktop" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Protocol Paths", translations: { fr: "Chemins de protocole", ko: "프로토콜 경로", "zh-CN": "协议路径", "zh-TW": "協定路徑", ru: "Пути протоколов", ja: "プロトコル経路", tr: "Protokol Yolları" }, slug: "guides/protocol-paths" }, diff --git a/docs-site/src/content/docs/fr/guides/chatgpt-desktop.md b/docs-site/src/content/docs/fr/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..c489d59f312 --- /dev/null +++ b/docs-site/src/content/docs/fr/guides/chatgpt-desktop.md @@ -0,0 +1,115 @@ +--- +title: Déblocage de l'envoi dans ChatGPT Desktop +description: Garder utilisable la zone de saisie de l'application ChatGPT de bureau quand le quota d'utilisation du compte est épuisé (macOS, sur activation). +--- + +Quand le compte ChatGPT connecté épuise son quota d'utilisation, l'application de bureau ChatGPT +grise son bouton d'envoi, même pour les conversations dont opencodex route les appels de modèle vers +d'autres fournisseurs. Cette intégration macOS, à activer explicitement, garde la zone de saisie +utilisable. Elle est désactivée par défaut. + +## Ce qu'elle modifie + +opencodex exécute un écouteur TLS local pour `chatgpt.com`. L'application est lancée avec une option +Chromium qui envoie `chatgpt.com` vers cet écouteur ; tous les autres hôtes, sous-domaines compris, +gardent leur route habituelle. Les requêtes sont relayées vers le vrai `chatgpt.com` avec les +identifiants de l'application, et les WebSockets (comme la dictée vocale) sont relayés aussi. Rien +n'est journalisé ni stocké. + +Les réponses passent sans modification, sauf pour deux points de terminaison : + +- les métadonnées de conversation (`/backend-api/conversation/init` et le flux de conversation) : les + verrous d'envoi dus au quota d'utilisation sont retirés ; +- l'instantané d'utilisation (`/backend-api/wham/usage`) : la barrière « limite atteinte » est ouverte. + +Les verrous d'envoi ayant une autre raison, comme un abonnement requis, sont conservés et listés par +`ocx chatgpt status`. L'utilisation affichée (pourcentages, heures de réinitialisation, bannières) +n'est jamais modifiée, et les serveurs d'OpenAI appliquent toujours toutes les limites à leurs propres +requêtes. + +## Configuration + +1. Activez la fonctionnalité dans `~/.opencodex/config.json` puis redémarrez opencodex : + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + L'écouteur utilise le port du proxy plus 200 (`10300` par défaut). Définissez + `chatgptDesktop.port` pour choisir un autre port. + +2. Faites confiance une fois à l'autorité de certification locale. La commande demande votre mot de + passe de session ; exécutez-la vous-même : + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + Sans cette confiance, l'application ne peut pas charger les pages de compte, d'utilisation ni de + réglages. Si vous utilisez un répertoire opencodex personnalisé, `ocx chatgpt status` affiche la + commande exacte pour votre configuration. + +3. Lancez l'application via opencodex : + + ```bash + ocx chatgpt launch + ``` + +4. Facultatif : faire utiliser la route aussi aux lancements normaux depuis le Dock ou Spotlight : + + ```bash + ocx chatgpt install-watcher + ``` + + Le surveillant s'exécute à chaque démarrage de l'application. Si l'application a été ouverte + normalement pendant qu'opencodex tourne, il la quitte juste après son lancement et la rouvre avec + la route. Il n'agit jamais sur une application déjà en cours d'utilisation et ne fait rien quand + opencodex ne tourne pas. La commande demande une confirmation ; `--yes` confirme sans interaction. + +## Configurations réseau + +Aucune règle de VPN ou de proxy n'est nécessaire. Les arguments de lancement sont choisis d'après le +proxy système à chaque démarrage de l'application : + +| Configuration | Arguments de lancement de l'application | +|---|---| +| Sans proxy | La route `chatgpt.com` seule. | +| VPN en mode proxy système | La route, le proxy système avec repli direct, et un contournement pour `chatgpt.com` seulement. | +| VPN en mode TUN | La route seule ; le trafic de boucle locale n'entre jamais dans le tunnel. | +| Fichier PAC | La route seule. Le fichier PAC peut laisser `chatgpt.com` sur le proxy, la zone de saisie peut donc rester verrouillée, mais rien d'autre ne casse. | + +opencodex joint le vrai `chatgpt.com` via son propre réglage `proxy`, comme tout son autre trafic +sortant. + +## Vérifier l'état + +```bash +ocx chatgpt status +``` + +La commande indique si la fonctionnalité est active, si l'écouteur du port est celui d'opencodex, si +le certificat est de confiance, l'état du surveillant, si l'application en cours porte la route, et +les verrous d'envoi conservés volontairement. + +## Désactiver + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` rouvre une application routée avec le réseau natif. Réglez ensuite +`chatgptDesktop.unblockSend` sur `false` et redémarrez opencodex. L'autorité de certification est +partagée avec les intégrations Claude d'opencodex ; ne retirez sa confiance que si vous n'utilisez +ni l'une ni l'autre. + +## Dépannage + +- **Les pages de compte, d'utilisation ou de réglages ne se chargent pas :** le certificat n'est pas + de confiance. Refaites l'étape 2 ; `ocx chatgpt status` affiche l'état de confiance. +- **Le bouton d'envoi reste grisé :** consultez `ocx chatgpt status`. L'application tourne peut-être + sans la route (lancez `ocx chatgpt launch`), ou le verrou a une raison autre que le quota + d'utilisation, listée sous « send blocks kept ». +- **L'application ne charge plus rien après l'arrêt d'opencodex :** une application routée dépend de + l'écouteur. Redémarrez opencodex, ou lancez `ocx chatgpt restore`. 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..b6457e7574a --- /dev/null +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -0,0 +1,111 @@ +--- +title: ChatGPT Desktop Send Unblock +description: Keep the ChatGPT desktop app's composer usable when the account's usage quota runs out (macOS, opt-in). +--- + +When the logged-in ChatGPT account runs out of usage quota, the ChatGPT desktop app greys out +its send button, even for conversations whose model calls opencodex routes to other providers. +This opt-in macOS integration keeps the composer usable. It is off by default. + +## What it changes + +opencodex runs a local TLS listener for `chatgpt.com`. The app is launched with a Chromium +switch that sends `chatgpt.com` to that listener; every other host, including its subdomains, +keeps its normal route. Requests are relayed to the real `chatgpt.com` with the app's own +credentials, and WebSockets (such as voice dictation) are relayed as well. Nothing is logged or +stored. + +Responses are passed through unchanged except for two endpoints: + +- conversation metadata (`/backend-api/conversation/init` and the conversation stream): send + locks caused by usage quota are removed; +- the usage snapshot (`/backend-api/wham/usage`): the "limit reached" gate is opened. + +Send locks with any other reason, such as a subscription requirement, are kept, and +`ocx chatgpt status` lists them. Displayed usage (percentages, reset times, banners) is never +changed, and OpenAI's servers still enforce every limit on their own requests. + +## Setup + +1. Enable the feature in `~/.opencodex/config.json` and restart opencodex: + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + The listener uses the proxy port plus 200 (`10300` by default). Set + `chatgptDesktop.port` to choose another port. + +2. Trust the local certificate authority once. The command asks for your login password, so + run it yourself: + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + Without this trust the app cannot load account, usage or settings pages. If you use a + custom opencodex home, `ocx chatgpt status` prints the exact command for your setup. + +3. Launch the app through opencodex: + + ```bash + ocx chatgpt launch + ``` + +4. Optional: make normal Dock and Spotlight launches use the route too: + + ```bash + ocx chatgpt install-watcher + ``` + + The watcher runs each time the app starts. If the app was opened normally while opencodex + is running, it quits the app right after launch and reopens it with the route. It never acts + on an app that is already in use, and does nothing while opencodex is not running. The + command asks for confirmation; `--yes` confirms non-interactively. + +## Network setups + +No VPN or proxy rules are needed. The launch arguments are chosen from the system proxy each +time the app starts: + +| Setup | What the app is launched with | +|---|---| +| No proxy | The `chatgpt.com` route only. | +| VPN in system-proxy mode | The route, the system proxy with a direct fallback, and a bypass for `chatgpt.com` only. | +| VPN in TUN mode | The route only; loopback traffic never enters the tunnel. | +| PAC file | The route only. The PAC file may keep `chatgpt.com` on the proxy, so the composer can stay locked, but nothing else breaks. | + +opencodex reaches the real `chatgpt.com` through its own `proxy` setting, like all its other +outbound traffic. + +## Check the state + +```bash +ocx chatgpt status +``` + +It reports whether the feature is on, whether the listener on the port is opencodex's, whether +the certificate is trusted, the watcher state, whether the running app carries the route, and +any send locks that were kept on purpose. + +## Turn it off + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` reopens a routed app with native networking. Then set +`chatgptDesktop.unblockSend` to `false` and restart opencodex. The certificate authority is +shared with opencodex's Claude integrations; remove its trust only if you use neither. + +## Troubleshooting + +- **Account, usage or settings pages do not load:** the certificate is not trusted. Run + step 2 again; `ocx chatgpt status` shows the trust state. +- **The send button is still grey:** check `ocx chatgpt status`. The app may be running + without the route (run `ocx chatgpt launch`), or the lock may have a reason other than usage + quota, which is listed under "send blocks kept". +- **The app cannot load anything after opencodex stops:** a routed app depends on the + listener. Start opencodex again, or run `ocx chatgpt restore`. diff --git a/docs-site/src/content/docs/ja/guides/chatgpt-desktop.md b/docs-site/src/content/docs/ja/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..1789c48cbf5 --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/chatgpt-desktop.md @@ -0,0 +1,104 @@ +--- +title: ChatGPT デスクトップの送信ロック解除 +description: アカウントの使用量上限に達しても ChatGPT デスクトップアプリの入力欄を使えるようにします(macOS、オプトイン)。 +--- + +ログイン中の ChatGPT アカウントが使用量の上限に達すると、ChatGPT デスクトップアプリは送信ボタンを +グレーアウトします。その会話のモデル呼び出しを opencodex が別のプロバイダーにルーティングしている +場合でも同様です。このオプトインの macOS 連携は入力欄を使える状態に保ちます。既定ではオフです。 + +## 変更される内容 + +opencodex は `chatgpt.com` 用のローカル TLS リスナーを動かします。アプリは `chatgpt.com` を +このリスナーに向ける Chromium スイッチ付きで起動され、それ以外のホスト(サブドメインを含む)は +通常の経路のままです。リクエストはアプリ自身の認証情報で本物の `chatgpt.com` に中継され、 +WebSocket(音声入力など)も中継されます。ログや保存は一切行いません。 + +レスポンスは次の 2 つのエンドポイントを除き、そのまま通過します。 + +- 会話メタデータ(`/backend-api/conversation/init` と会話ストリーム): 使用量上限による送信ロックを取り除きます。 +- 使用量スナップショット(`/backend-api/wham/usage`): 「上限到達」のゲートを開きます。 + +サブスクリプションが必要な場合など、それ以外の理由による送信ロックは残され、 +`ocx chatgpt status` に表示されます。表示される使用量(割合、リセット時刻、バナー)は変更されず、 +OpenAI のサーバーは自身へのリクエストに対してすべての制限を引き続き適用します。 + +## セットアップ + +1. `~/.opencodex/config.json` で機能を有効にし、opencodex を再起動します。 + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + リスナーはプロキシのポートに 200 を足したポート(既定は `10300`)を使います。 + 別のポートを使うには `chatgptDesktop.port` を設定します。 + +2. ローカル認証局を一度だけ信頼します。このコマンドはログインパスワードを求めるので、ご自身で実行してください。 + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + この信頼がないと、アプリはアカウント、使用量、設定のページを読み込めません。opencodex の + ホームを変更している場合は、`ocx chatgpt status` が環境に合った正確なコマンドを表示します。 + +3. opencodex 経由でアプリを起動します。 + + ```bash + ocx chatgpt launch + ``` + +4. 任意: Dock や Spotlight からの通常の起動でも経路を使うようにします。 + + ```bash + ocx chatgpt install-watcher + ``` + + ウォッチャーはアプリの起動ごとに動きます。opencodex の実行中にアプリが通常の方法で開かれた場合、 + 起動直後にアプリを終了し、経路付きで開き直します。使用中のアプリには何もせず、opencodex が + 動いていないときも何もしません。このコマンドは確認を求めます。`--yes` で非対話的に確認できます。 + +## ネットワーク構成 + +VPN やプロキシのルール設定は不要です。起動引数はアプリの起動のたびにシステムプロキシから選ばれます。 + +| 構成 | アプリの起動引数 | +|---|---| +| プロキシなし | `chatgpt.com` の経路のみ。 | +| システムプロキシモードの VPN | 経路、直接接続へのフォールバック付きのシステムプロキシ、`chatgpt.com` のみのバイパス。 | +| TUN モードの VPN | 経路のみ。ループバック通信はトンネルに入りません。 | +| PAC ファイル | 経路のみ。PAC ファイルが `chatgpt.com` をプロキシに残す場合、入力欄はロックされたままになることがありますが、ほかの機能は壊れません。 | + +opencodex は、ほかの外向き通信と同じく自身の `proxy` 設定で本物の `chatgpt.com` に接続します。 + +## 状態の確認 + +```bash +ocx chatgpt status +``` + +機能が有効か、ポートのリスナーが opencodex のものか、証明書が信頼されているか、ウォッチャーの状態、 +実行中のアプリが経路を持っているか、意図的に残された送信ロックを表示します。 + +## 無効にする + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` は経路付きのアプリをネイティブのネットワークで開き直します。そのあと +`chatgptDesktop.unblockSend` を `false` にして opencodex を再起動します。認証局は opencodex の +Claude 連携と共用です。どちらも使わない場合にのみ信頼を削除してください。 + +## トラブルシューティング + +- **アカウント、使用量、設定のページが読み込めない:** 証明書が信頼されていません。手順 2 をもう一度 + 実行してください。`ocx chatgpt status` に信頼の状態が表示されます。 +- **送信ボタンがグレーのまま:** `ocx chatgpt status` を確認してください。アプリが経路なしで動いている + (`ocx chatgpt launch` を実行)か、ロックの理由が使用量上限ではなく「send blocks kept」に表示されて + いる可能性があります。 +- **opencodex を止めるとアプリが何も読み込めない:** 経路付きのアプリはリスナーに依存します。 + opencodex を再び起動するか、`ocx chatgpt restore` を実行してください。 diff --git a/docs-site/src/content/docs/ko/guides/chatgpt-desktop.md b/docs-site/src/content/docs/ko/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..c25795e0dd0 --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/chatgpt-desktop.md @@ -0,0 +1,104 @@ +--- +title: ChatGPT 데스크톱 전송 잠금 해제 +description: 계정 사용량 한도가 소진되어도 ChatGPT 데스크톱 앱의 입력창을 계속 쓸 수 있게 합니다(macOS, 옵트인). +--- + +로그인한 ChatGPT 계정의 사용량 한도가 소진되면 ChatGPT 데스크톱 앱은 전송 버튼을 비활성화합니다. +그 대화의 모델 호출을 opencodex가 다른 프로바이더로 라우팅하는 경우에도 마찬가지입니다. 이 옵트인 +macOS 통합은 입력창을 계속 쓸 수 있게 합니다. 기본값은 꺼짐입니다. + +## 바뀌는 것 + +opencodex는 `chatgpt.com`용 로컬 TLS 리스너를 실행합니다. 앱은 `chatgpt.com`을 이 리스너로 보내는 +Chromium 스위치와 함께 실행되며, 서브도메인을 포함한 다른 모든 호스트는 평소 경로를 유지합니다. +요청은 앱 자신의 자격 증명으로 실제 `chatgpt.com`에 중계되고, WebSocket(음성 받아쓰기 등)도 +중계됩니다. 아무것도 기록하거나 저장하지 않습니다. + +응답은 다음 두 엔드포인트를 제외하고 그대로 전달됩니다. + +- 대화 메타데이터(`/backend-api/conversation/init`와 대화 스트림): 사용량 한도로 인한 전송 잠금을 제거합니다. +- 사용량 스냅샷(`/backend-api/wham/usage`): "한도 도달" 게이트를 엽니다. + +구독 필요 등 다른 이유의 전송 잠금은 그대로 두며 `ocx chatgpt status`에 표시됩니다. 표시되는 +사용량(비율, 초기화 시각, 배너)은 바뀌지 않으며, OpenAI 서버는 자체 요청에 모든 한도를 계속 적용합니다. + +## 설정 + +1. `~/.opencodex/config.json`에서 기능을 켜고 opencodex를 다시 시작합니다. + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + 리스너는 프록시 포트에 200을 더한 포트(기본 `10300`)를 씁니다. 다른 포트를 쓰려면 + `chatgptDesktop.port`를 설정합니다. + +2. 로컬 인증 기관을 한 번만 신뢰합니다. 이 명령은 로그인 암호를 묻기 때문에 직접 실행하세요. + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + 이 신뢰가 없으면 앱이 계정, 사용량, 설정 페이지를 불러오지 못합니다. opencodex 홈을 바꿔 쓰는 + 경우 `ocx chatgpt status`가 환경에 맞는 정확한 명령을 출력합니다. + +3. opencodex를 통해 앱을 실행합니다. + + ```bash + ocx chatgpt launch + ``` + +4. 선택: Dock과 Spotlight에서 평소처럼 실행해도 경로를 쓰게 합니다. + + ```bash + ocx chatgpt install-watcher + ``` + + 감시자는 앱이 시작될 때마다 동작합니다. opencodex가 실행 중일 때 앱이 평소 방식으로 열리면, + 실행 직후 앱을 종료하고 경로와 함께 다시 엽니다. 이미 사용 중인 앱에는 아무것도 하지 않고, + opencodex가 실행 중이 아닐 때도 아무것도 하지 않습니다. 이 명령은 확인을 요청하며, `--yes`로 + 비대화식으로 확인할 수 있습니다. + +## 네트워크 환경 + +VPN이나 프록시 규칙을 설정할 필요가 없습니다. 실행 인자는 앱이 시작될 때마다 시스템 프록시에 따라 +정해집니다. + +| 환경 | 앱 실행 인자 | +|---|---| +| 프록시 없음 | `chatgpt.com` 경로만. | +| 시스템 프록시 모드 VPN | 경로, 직접 연결 폴백이 있는 시스템 프록시, `chatgpt.com`만 우회. | +| TUN 모드 VPN | 경로만. 루프백 트래픽은 터널에 들어가지 않습니다. | +| PAC 파일 | 경로만. PAC 파일이 `chatgpt.com`을 프록시에 남길 수 있어 입력창이 잠긴 채로 있을 수 있지만, 다른 기능은 망가지지 않습니다. | + +opencodex는 다른 외부 트래픽과 마찬가지로 자체 `proxy` 설정으로 실제 `chatgpt.com`에 접속합니다. + +## 상태 확인 + +```bash +ocx chatgpt status +``` + +기능이 켜져 있는지, 포트의 리스너가 opencodex 것인지, 인증서가 신뢰되는지, 감시자 상태, 실행 중인 +앱이 경로를 갖고 있는지, 의도적으로 남긴 전송 잠금을 보고합니다. + +## 끄기 + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore`는 경로가 적용된 앱을 기본 네트워크로 다시 엽니다. 그런 다음 +`chatgptDesktop.unblockSend`를 `false`로 설정하고 opencodex를 다시 시작합니다. 인증 기관은 +opencodex의 Claude 통합과 공유되므로, 둘 다 쓰지 않을 때만 신뢰를 제거하세요. + +## 문제 해결 + +- **계정, 사용량, 설정 페이지가 로드되지 않음:** 인증서가 신뢰되지 않았습니다. 2단계를 다시 + 실행하세요. `ocx chatgpt status`가 신뢰 상태를 보여 줍니다. +- **전송 버튼이 여전히 회색:** `ocx chatgpt status`를 확인하세요. 앱이 경로 없이 실행 중이거나 + (`ocx chatgpt launch` 실행), 잠금 이유가 사용량 한도가 아니어서 "send blocks kept"에 표시될 수 있습니다. +- **opencodex를 멈추면 앱이 아무것도 불러오지 못함:** 경로가 적용된 앱은 리스너에 의존합니다. + opencodex를 다시 시작하거나 `ocx chatgpt restore`를 실행하세요. diff --git a/docs-site/src/content/docs/ru/guides/chatgpt-desktop.md b/docs-site/src/content/docs/ru/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..85dbae6adb3 --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/chatgpt-desktop.md @@ -0,0 +1,113 @@ +--- +title: Разблокировка отправки в ChatGPT Desktop +description: Поле ввода настольного приложения ChatGPT остаётся доступным, когда квота использования аккаунта исчерпана (macOS, включается вручную). +--- + +Когда у аккаунта ChatGPT, под которым выполнен вход, заканчивается квота использования, настольное +приложение ChatGPT делает кнопку отправки неактивной — даже для разговоров, вызовы моделей в которых +opencodex направляет другим провайдерам. Эта интеграция для macOS, включаемая вручную, сохраняет поле +ввода доступным. По умолчанию она выключена. + +## Что она меняет + +opencodex запускает локальный TLS-слушатель для `chatgpt.com`. Приложение запускается с переключателем +Chromium, который направляет `chatgpt.com` на этот слушатель; все остальные хосты, включая поддомены, +идут обычным путём. Запросы ретранслируются на настоящий `chatgpt.com` с учётными данными самого +приложения, WebSocket-соединения (например, голосовой ввод) тоже ретранслируются. Ничего не +журналируется и не сохраняется. + +Ответы проходят без изменений, кроме двух конечных точек: + +- метаданные разговора (`/backend-api/conversation/init` и поток разговора): удаляются блокировки + отправки, вызванные квотой использования; +- снимок использования (`/backend-api/wham/usage`): открывается шлюз «лимит достигнут». + +Блокировки отправки по другим причинам, например из-за необходимости подписки, сохраняются и +перечисляются в `ocx chatgpt status`. Отображаемое использование (проценты, время сброса, баннеры) +никогда не меняется, а серверы OpenAI по-прежнему применяют все лимиты к собственным запросам. + +## Настройка + +1. Включите функцию в `~/.opencodex/config.json` и перезапустите opencodex: + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + Слушатель использует порт прокси плюс 200 (по умолчанию `10300`). Чтобы выбрать другой порт, + задайте `chatgptDesktop.port`. + +2. Один раз доверьте локальный центр сертификации. Команда запрашивает пароль входа, поэтому + выполните её сами: + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + Без этого доверия приложение не может загрузить страницы аккаунта, использования и настроек. Если + вы используете нестандартный каталог opencodex, `ocx chatgpt status` выведет точную команду для + вашей конфигурации. + +3. Запустите приложение через opencodex: + + ```bash + ocx chatgpt launch + ``` + +4. Необязательно: чтобы обычные запуски из Dock и Spotlight тоже использовали маршрут: + + ```bash + ocx chatgpt install-watcher + ``` + + Наблюдатель срабатывает при каждом запуске приложения. Если приложение открыто обычным способом, + пока работает opencodex, он закрывает его сразу после запуска и открывает снова с маршрутом. Он + никогда не трогает уже используемое приложение и ничего не делает, пока opencodex не запущен. + Команда запрашивает подтверждение; `--yes` подтверждает без диалога. + +## Сетевые конфигурации + +Правила VPN или прокси не нужны. Аргументы запуска выбираются по системному прокси при каждом запуске +приложения: + +| Конфигурация | С чем запускается приложение | +|---|---| +| Без прокси | Только маршрут `chatgpt.com`. | +| VPN в режиме системного прокси | Маршрут, системный прокси с прямым резервным подключением и обход только для `chatgpt.com`. | +| VPN в режиме TUN | Только маршрут; трафик loopback никогда не попадает в туннель. | +| PAC-файл | Только маршрут. PAC-файл может оставить `chatgpt.com` на прокси, и поле ввода может остаться заблокированным, но больше ничего не ломается. | + +opencodex обращается к настоящему `chatgpt.com` через собственную настройку `proxy`, как и весь +остальной исходящий трафик. + +## Проверка состояния + +```bash +ocx chatgpt status +``` + +Команда показывает, включена ли функция, принадлежит ли слушатель на порту opencodex, доверен ли +сертификат, состояние наблюдателя, есть ли маршрут у запущенного приложения и какие блокировки +отправки намеренно сохранены. + +## Отключение + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` заново открывает приложение с маршрутом, но уже с обычной сетью. Затем установите +`chatgptDesktop.unblockSend` в `false` и перезапустите opencodex. Центр сертификации общий с +интеграциями Claude в opencodex; удаляйте доверие к нему, только если не используете ни то, ни другое. + +## Устранение неполадок + +- **Не загружаются страницы аккаунта, использования или настроек:** сертификат не доверен. Повторите + шаг 2; `ocx chatgpt status` показывает состояние доверия. +- **Кнопка отправки остаётся серой:** проверьте `ocx chatgpt status`. Приложение может работать без + маршрута (выполните `ocx chatgpt launch`), или у блокировки другая причина, не квота использования; + она указана в разделе «send blocks kept». +- **После остановки opencodex приложение ничего не загружает:** приложение с маршрутом зависит от + слушателя. Снова запустите opencodex или выполните `ocx chatgpt restore`. diff --git a/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..309dd6c6605 --- /dev/null +++ b/docs-site/src/content/docs/tr/guides/chatgpt-desktop.md @@ -0,0 +1,112 @@ +--- +title: ChatGPT Masaüstü Gönderme Kilidini Açma +description: Hesabın kullanım kotası bittiğinde ChatGPT masaüstü uygulamasının yazma alanını kullanılabilir tutar (macOS, isteğe bağlı). +--- + +Oturum açılmış ChatGPT hesabının kullanım kotası bittiğinde ChatGPT masaüstü uygulaması gönder +düğmesini devre dışı bırakır; opencodex'in model çağrılarını başka sağlayıcılara yönlendirdiği +konuşmalarda bile. Bu isteğe bağlı macOS entegrasyonu yazma alanını kullanılabilir tutar. Varsayılan +olarak kapalıdır. + +## Neyi değiştirir + +opencodex, `chatgpt.com` için yerel bir TLS dinleyicisi çalıştırır. Uygulama, `chatgpt.com`'u bu +dinleyiciye yönlendiren bir Chromium anahtarıyla başlatılır; alt alan adları dahil diğer tüm ana +bilgisayarlar normal yollarını korur. İstekler uygulamanın kendi kimlik bilgileriyle gerçek +`chatgpt.com`'a aktarılır; WebSocket bağlantıları (sesli dikte gibi) da aktarılır. Hiçbir şey +kaydedilmez veya saklanmaz. + +Yanıtlar iki uç nokta dışında değiştirilmeden geçer: + +- konuşma meta verileri (`/backend-api/conversation/init` ve konuşma akışı): kullanım kotasından + kaynaklanan gönderme kilitleri kaldırılır; +- kullanım anlık görüntüsü (`/backend-api/wham/usage`): "sınıra ulaşıldı" geçidi açılır. + +Abonelik gerekmesi gibi başka nedenli gönderme kilitleri korunur ve `ocx chatgpt status` tarafından +listelenir. Gösterilen kullanım (yüzdeler, sıfırlanma zamanları, afişler) hiçbir zaman değiştirilmez +ve OpenAI sunucuları kendi isteklerinde tüm sınırları uygulamaya devam eder. + +## Kurulum + +1. Özelliği `~/.opencodex/config.json` içinde açın ve opencodex'i yeniden başlatın: + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + Dinleyici, proxy bağlantı noktasının 200 fazlasını kullanır (varsayılan `10300`). Başka bir bağlantı + noktası seçmek için `chatgptDesktop.port` ayarını yapın. + +2. Yerel sertifika yetkilisine bir kez güvenin. Komut oturum açma parolanızı ister; bu yüzden kendiniz + çalıştırın: + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + Bu güven olmadan uygulama hesap, kullanım ve ayarlar sayfalarını yükleyemez. Özel bir opencodex + dizini kullanıyorsanız `ocx chatgpt status`, kurulumunuza uygun tam komutu yazdırır. + +3. Uygulamayı opencodex üzerinden başlatın: + + ```bash + ocx chatgpt launch + ``` + +4. İsteğe bağlı: Dock ve Spotlight'tan yapılan normal başlatmaların da yolu kullanmasını sağlayın: + + ```bash + ocx chatgpt install-watcher + ``` + + İzleyici, uygulama her başladığında çalışır. opencodex çalışırken uygulama normal şekilde açıldıysa, + başlatmanın hemen ardından uygulamayı kapatır ve yolla yeniden açar. Kullanımda olan bir uygulamaya + asla dokunmaz ve opencodex çalışmıyorken hiçbir şey yapmaz. Komut onay ister; `--yes` etkileşimsiz + onay verir. + +## Ağ kurulumları + +VPN veya proxy kuralı gerekmez. Başlatma bağımsız değişkenleri, uygulama her başladığında sistem +proxy'sine göre seçilir: + +| Kurulum | Uygulamanın başlatıldığı bağımsız değişkenler | +|---|---| +| Proxy yok | Yalnızca `chatgpt.com` yolu. | +| Sistem proxy modunda VPN | Yol, doğrudan bağlantı yedeği olan sistem proxy'si ve yalnızca `chatgpt.com` için atlama. | +| TUN modunda VPN | Yalnızca yol; geri döngü trafiği tünele hiç girmez. | +| PAC dosyası | Yalnızca yol. PAC dosyası `chatgpt.com`'u proxy'de tutabilir, bu yüzden yazma alanı kilitli kalabilir, ancak başka hiçbir şey bozulmaz. | + +opencodex, diğer tüm giden trafiği gibi, gerçek `chatgpt.com`'a kendi `proxy` ayarı üzerinden ulaşır. + +## Durumu kontrol etme + +```bash +ocx chatgpt status +``` + +Özelliğin açık olup olmadığını, bağlantı noktasındaki dinleyicinin opencodex'e ait olup olmadığını, +sertifikanın güvenilir olup olmadığını, izleyici durumunu, çalışan uygulamanın yolu taşıyıp taşımadığını +ve bilerek korunan gönderme kilitlerini bildirir. + +## Kapatma + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore`, yönlendirilmiş bir uygulamayı yerel ağ ile yeniden açar. Ardından +`chatgptDesktop.unblockSend` değerini `false` yapın ve opencodex'i yeniden başlatın. Sertifika +yetkilisi opencodex'in Claude entegrasyonlarıyla paylaşılır; güvenini yalnızca ikisini de +kullanmıyorsanız kaldırın. + +## Sorun giderme + +- **Hesap, kullanım veya ayarlar sayfaları yüklenmiyor:** sertifika güvenilir değil. 2. adımı tekrar + çalıştırın; `ocx chatgpt status` güven durumunu gösterir. +- **Gönder düğmesi hâlâ gri:** `ocx chatgpt status` çıktısına bakın. Uygulama yol olmadan çalışıyor + olabilir (`ocx chatgpt launch` çalıştırın) ya da kilidin nedeni kullanım kotası değildir ve + "send blocks kept" altında listelenir. +- **opencodex durduktan sonra uygulama hiçbir şey yükleyemiyor:** yönlendirilmiş bir uygulama + dinleyiciye bağlıdır. opencodex'i yeniden başlatın ya da `ocx chatgpt restore` çalıştırın. diff --git a/docs-site/src/content/docs/zh-cn/guides/chatgpt-desktop.md b/docs-site/src/content/docs/zh-cn/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..3f0e9c3dd0e --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/chatgpt-desktop.md @@ -0,0 +1,100 @@ +--- +title: ChatGPT 桌面版发送键解锁 +description: 账号用量额度用完时,让 ChatGPT 桌面版的输入框保持可用(macOS,需手动开启)。 +--- + +登录的 ChatGPT 账号用完用量额度后,ChatGPT 桌面版会把发送按钮置灰,即使该对话的模型调用由 +opencodex 路由到其他提供商。这个需手动开启的 macOS 集成可以让输入框保持可用,默认关闭。 + +## 它改变了什么 + +opencodex 为 `chatgpt.com` 运行一个本地 TLS 监听器。app 启动时会带上一个 Chromium 参数, +把 `chatgpt.com` 指向这个监听器;其他所有域名(包括它的子域名)都保持原来的路径。请求会带着 +app 自己的凭据转发到真正的 `chatgpt.com`,WebSocket(例如语音听写)也会一并转发。不记录、 +不存储任何内容。 + +除以下两个接口外,所有响应都原样透传: + +- 对话元数据(`/backend-api/conversation/init` 和对话流):去掉由用量额度导致的发送锁; +- 用量快照(`/backend-api/wham/usage`):打开“已达上限”的开关。 + +其他原因的发送锁(例如需要订阅)会保留,并在 `ocx chatgpt status` 中列出。显示的用量 +(百分比、重置时间、横幅)不会被修改,OpenAI 服务器仍会对其自身的请求执行所有限制。 + +## 设置 + +1. 在 `~/.opencodex/config.json` 中开启该功能,然后重启 opencodex: + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + 监听器使用代理端口加 200(默认 `10300`)。设置 `chatgptDesktop.port` 可以换用其他端口。 + +2. 信任本地证书颁发机构(只需一次)。该命令会要求输入登录密码,请自己运行: + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + 没有这项信任,app 无法加载账户、用量和设置页面。如果你使用了自定义的 opencodex 目录, + `ocx chatgpt status` 会打印适合你环境的准确命令。 + +3. 通过 opencodex 启动 app: + + ```bash + ocx chatgpt launch + ``` + +4. 可选:让普通的 Dock 和聚焦搜索启动也使用该路径: + + ```bash + ocx chatgpt install-watcher + ``` + + watcher 在 app 每次启动时运行。如果 opencodex 正在运行而 app 是以普通方式打开的,它会在 + 启动后立即退出 app 并带上路径重新打开。它不会对正在使用中的 app 做任何操作,opencodex + 未运行时也什么都不做。该命令会请求确认;`--yes` 可以非交互式确认。 + +## 网络环境 + +不需要配置任何 VPN 或代理规则。每次 app 启动时,都会根据系统代理选择启动参数: + +| 环境 | app 的启动参数 | +|---|---| +| 无代理 | 只有 `chatgpt.com` 路径。 | +| VPN 系统代理模式 | 路径、带直连回退的系统代理,以及只针对 `chatgpt.com` 的绕过。 | +| VPN TUN 模式 | 只有路径;本机回环流量不会进入隧道。 | +| PAC 文件 | 只有路径。PAC 文件可能让 `chatgpt.com` 继续走代理,输入框因此可能仍被锁定,但其他功能不受影响。 | + +opencodex 通过自己的 `proxy` 设置访问真正的 `chatgpt.com`,与它的其他出站流量一致。 + +## 查看状态 + +```bash +ocx chatgpt status +``` + +它会报告:功能是否开启、端口上的监听器是否属于 opencodex、证书是否受信任、watcher 状态、 +运行中的 app 是否带有路径,以及被有意保留的发送锁。 + +## 关闭 + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` 会以原生网络重新打开已接管的 app。之后把 `chatgptDesktop.unblockSend` 设为 +`false` 并重启 opencodex。该证书颁发机构与 opencodex 的 Claude 集成共用;只有两者都不使用时 +才移除它的信任。 + +## 故障排查 + +- **账户、用量或设置页面加载不出来:** 证书未受信任。重新执行第 2 步;`ocx chatgpt status` + 会显示信任状态。 +- **发送按钮仍是灰色:** 查看 `ocx chatgpt status`。app 可能没有带着路径运行(运行 + `ocx chatgpt launch`),或者锁的原因不是用量额度,会列在 “send blocks kept” 下。 +- **opencodex 停止后 app 什么都加载不出来:** 已接管的 app 依赖监听器。重新启动 opencodex, + 或运行 `ocx chatgpt restore`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/chatgpt-desktop.md b/docs-site/src/content/docs/zh-tw/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..305495256b2 --- /dev/null +++ b/docs-site/src/content/docs/zh-tw/guides/chatgpt-desktop.md @@ -0,0 +1,100 @@ +--- +title: ChatGPT 桌面版傳送鍵解鎖 +description: 帳號用量額度用完時,讓 ChatGPT 桌面版的輸入框保持可用(macOS,需手動開啟)。 +--- + +登入的 ChatGPT 帳號用完用量額度後,ChatGPT 桌面版會把傳送按鈕變灰,即使該對話的模型呼叫由 +opencodex 路由到其他供應商。這個需手動開啟的 macOS 整合可以讓輸入框保持可用,預設關閉。 + +## 它改變了什麼 + +opencodex 為 `chatgpt.com` 執行一個本機 TLS 監聽器。app 啟動時會帶上一個 Chromium 參數, +把 `chatgpt.com` 指向這個監聽器;其他所有網域(包括它的子網域)都維持原本的路徑。請求會帶著 +app 自己的憑證轉發到真正的 `chatgpt.com`,WebSocket(例如語音聽寫)也會一併轉發。不記錄、 +不儲存任何內容。 + +除以下兩個端點外,所有回應都原樣透傳: + +- 對話中繼資料(`/backend-api/conversation/init` 與對話串流):移除由用量額度造成的傳送鎖; +- 用量快照(`/backend-api/wham/usage`):打開「已達上限」的開關。 + +其他原因的傳送鎖(例如需要訂閱)會保留,並在 `ocx chatgpt status` 中列出。顯示的用量 +(百分比、重置時間、橫幅)不會被修改,OpenAI 伺服器仍會對其自身的請求執行所有限制。 + +## 設定 + +1. 在 `~/.opencodex/config.json` 中開啟此功能,然後重新啟動 opencodex: + + ```json + { "chatgptDesktop": { "unblockSend": true } } + ``` + + 監聽器使用代理連接埠加 200(預設 `10300`)。設定 `chatgptDesktop.port` 可改用其他連接埠。 + +2. 信任本機憑證授權單位(只需一次)。此指令會要求輸入登入密碼,請自行執行: + + ```bash + security add-trusted-cert -r trustRoot -p ssl \ + -k ~/Library/Keychains/login.keychain-db ~/.opencodex/claude-intercept/ca.pem + ``` + + 沒有這項信任,app 無法載入帳戶、用量和設定頁面。如果你使用自訂的 opencodex 目錄, + `ocx chatgpt status` 會列出適合你環境的準確指令。 + +3. 透過 opencodex 啟動 app: + + ```bash + ocx chatgpt launch + ``` + +4. 選用:讓一般的 Dock 與 Spotlight 啟動也使用該路徑: + + ```bash + ocx chatgpt install-watcher + ``` + + watcher 在 app 每次啟動時執行。如果 opencodex 正在執行而 app 是以一般方式開啟的,它會在 + 啟動後立即結束 app 並帶上路徑重新開啟。它不會對正在使用中的 app 做任何操作,opencodex + 未執行時也什麼都不做。此指令會要求確認;`--yes` 可以非互動式確認。 + +## 網路環境 + +不需要設定任何 VPN 或代理規則。每次 app 啟動時,都會依系統代理選擇啟動參數: + +| 環境 | app 的啟動參數 | +|---|---| +| 無代理 | 只有 `chatgpt.com` 路徑。 | +| VPN 系統代理模式 | 路徑、帶直連備援的系統代理,以及只針對 `chatgpt.com` 的略過。 | +| VPN TUN 模式 | 只有路徑;本機回送流量不會進入通道。 | +| PAC 檔案 | 只有路徑。PAC 檔案可能讓 `chatgpt.com` 繼續走代理,輸入框因此可能仍被鎖定,但其他功能不受影響。 | + +opencodex 透過自己的 `proxy` 設定連到真正的 `chatgpt.com`,與它的其他對外流量一致。 + +## 查看狀態 + +```bash +ocx chatgpt status +``` + +它會回報:功能是否開啟、連接埠上的監聽器是否屬於 opencodex、憑證是否受信任、watcher 狀態、 +執行中的 app 是否帶有路徑,以及被刻意保留的傳送鎖。 + +## 關閉 + +```bash +ocx chatgpt uninstall-watcher +ocx chatgpt restore +``` + +`restore` 會以原生網路重新開啟已接管的 app。之後把 `chatgptDesktop.unblockSend` 設為 +`false` 並重新啟動 opencodex。此憑證授權單位與 opencodex 的 Claude 整合共用;只有兩者都不使用時 +才移除它的信任。 + +## 疑難排解 + +- **帳戶、用量或設定頁面載入不出來:** 憑證未受信任。重新執行第 2 步;`ocx chatgpt status` + 會顯示信任狀態。 +- **傳送按鈕仍是灰色:** 查看 `ocx chatgpt status`。app 可能沒有帶著路徑執行(執行 + `ocx chatgpt launch`),或者鎖的原因不是用量額度,會列在「send blocks kept」下。 +- **opencodex 停止後 app 什麼都載入不出來:** 已接管的 app 依賴監聽器。重新啟動 opencodex, + 或執行 `ocx chatgpt restore`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7efe33a7a6e..4ca720f23be 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1947,7 +1947,14 @@ "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", - "server-combo-cooldown-recording.test.ts": "server" + "server-combo-cooldown-recording.test.ts": "server", + "rewrite.test.ts": "chatgpt-unblock", + "unblock-ca-trust.test.ts": "chatgpt-unblock", + "unblock-launch-script.test.ts": "chatgpt-unblock", + "unblock-listener.test.ts": "chatgpt-unblock", + "unblock-watcher-install.test.ts": "chatgpt-unblock", + "unblock-ws-frame.test.ts": "chatgpt-unblock", + "unblock-ws-relay.test.ts": "chatgpt-unblock" }, "migrated": [ "adapters", 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 index 0f7fc8bd50a..6624a88814d 100644 --- a/src/chatgpt/desktop-unblock/launch-watcher.ts +++ b/src/chatgpt/desktop-unblock/launch-watcher.ts @@ -1,10 +1,13 @@ -import { execFileSync } from "node:child_process"; +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 } from "./listener"; -import { chatgptUnblockResolverRule } from "./runtime"; +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. @@ -14,10 +17,25 @@ import { chatgptUnblockResolverRule } from "./runtime"; * 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 resolver rule if it was * started without one. There is no resident polling process: launchd wakes the script on the - * lock event and the script exits after one check. + * lock event and the script exits after one check. Because it fires on launch, it never quits + * an app the user is already working in. * - * The watcher only acts when the opencodex intercept listener is actually listening, so with - * the feature off the app is left completely native. + * The watcher only acts when the opencodex intercept listener answers its identity path, so + * with the feature off -- or another process holding the port -- the app is left native. + * + * The watcher, `ocx chatgpt launch` and `ocx chatgpt restore` run the same script, so the launch + * arguments are built in exactly one place. They are computed at launch time from the system + * proxy: + * - 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"; @@ -29,6 +47,16 @@ 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; @@ -46,39 +74,132 @@ export function chatgptUnblockWatcherPaths(configDir?: string): ChatgptUnblockWa }; } -/** The one-shot launchd script: restart the app with the rule if this launch lacked it. */ -export function buildChatgptUnblockWatcherScript(port: number): string { - const rule = chatgptUnblockResolverRule(port); +/** 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 rule; + * launch (`ocx chatgpt launch`) also starts the app when it is not running; + * native (`ocx chatgpt restore`) restarts a mapped app WITHOUT the rule, for when the + * listener is gone or the feature is being turned off. + */ +export function buildChatgptUnblockWatcherScript(port: number, configDir?: string): string { return `#!/bin/bash -# opencodex ChatGPT send-unblock launch watcher (one-shot, launchd-triggered). -# Fires when the ChatGPT desktop app creates its Electron SingletonLock (i.e. on every -# launch). If the app was started WITHOUT the host-resolver rule that points ${CHATGPT_INTERCEPT_HOST} at -# the opencodex TLS listener (normal Dock/Spotlight launch), it is restarted once with the -# rule. Correctly-launched instances and an absent intercept are left alone. +# opencodex ChatGPT send-unblock launcher. +# watch (launchd, fired by the app's Electron SingletonLock on every launch): if the app is +# running WITHOUT the resolver rule (a normal Dock/Spotlight launch), restart it once +# with the rule. 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 rule without it. PORT=${port} -RULE='${rule}' -LOG="$HOME/.opencodex/chatgpt-unblock-watcher.log" +MODE="\${1:-watch}" +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 -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() { + local pid + pid=$(app_pid) || return 1 + case "$(ps -o command= -p "$pid" 2>/dev/null)" in + *" $RESOLVER_ARG"*) return 0 ;; + esac + return 1 +} +# 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" +} -# Intercept must be listening; otherwise leave the app alone. -if ! lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then +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 -# App running? -if ! pgrep -f "ChatGPT.app/Contents/MacOS/ChatGPT" >/dev/null 2>&1; then + +# 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 || 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_flagged; then say "ChatGPT is already running without the resolver rule"; exit 0; fi + say "ChatGPT carries the resolver rule; restarting it without" + quit_app || { say "ChatGPT did not quit; quit it manually and reopen it"; exit 1; } + open -a ChatGPT + say "relaunched ChatGPT with native networking" exit 0 fi -# Already launched with the rule? -if pgrep -f "MacOS/ChatGPT $RULE" >/dev/null 2>&1; then + +if app_running; then + if app_flagged; then + say "ChatGPT is already running with the resolver rule" + exit 0 + fi + say "ChatGPT is running without the resolver rule; restarting it" + quit_app || { say "ChatGPT did not quit; leaving it running without the rule"; exit 1; } +elif [ "$MODE" != launch ]; then exit 0 fi -log "unflagged ChatGPT detected; restarting with resolver rule" -osascript -e 'quit app "ChatGPT"' >/dev/null 2>&1 -sleep 3 -open -a ChatGPT --args "$RULE" -log "relaunched with rule" + +ARGS=("$RESOLVER_ARG") +while IFS= read -r arg; do + [ -n "$arg" ] && ARGS+=("$arg") +done < <(proxy_args) +open -a ChatGPT --args "\${ARGS[@]}" +say "launched ChatGPT with: \${ARGS[*]}" `; } @@ -93,37 +214,69 @@ export function buildChatgptUnblockWatcherPlist(scriptPath: string, watchPath: s ProgramArguments /bin/bash - ${scriptPath} + ${xmlEscape(scriptPath)} + watch WatchPaths - ${watchPath} + ${xmlEscape(watchPath)} StandardErrorPath - ${errPath} + ${xmlEscape(errPath)} `; } -function sh(command: string, args: string[]): { ok: boolean; output: string } { +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, output }; + return { ok: true, status: 0, output }; } catch (error) { - const err = error as { status?: number; stdout?: string; stderr?: string }; - return { ok: false, output: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() }; + 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. */ +/** + * 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"); @@ -131,26 +284,44 @@ export function installChatgptUnblockWatcher(options: InstallChatgptUnblockWatch if (!options.assumeSupported && !existsSync(CHATGPT_APP_PATH)) { throw new Error(`${CHATGPT_APP_PATH} not found; install the ChatGPT desktop app first`); } - const paths = chatgptUnblockWatcherPaths(options.configDir); - mkdirSync(expandHome("~/Library/LaunchAgents"), { recursive: true }); - writeFileSync(paths.scriptPath, buildChatgptUnblockWatcherScript(options.port), { mode: 0o700 }); + 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)); - // Idempotent load: boot out any previous generation first. - sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); - sh("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, paths.plistPath]); + 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"}`); + } } -/** Remove the launch watcher: unload the agent and delete its files. */ -export function uninstallChatgptUnblockWatcher(configDir?: string): void { - const paths = chatgptUnblockWatcherPaths(configDir); - sh("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]); - for (const path of [paths.plistPath, paths.scriptPath]) { - try { - rmSync(path); - } catch { - /* already gone */ - } +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 { @@ -165,18 +336,122 @@ export function chatgptUnblockWatcherStatus(port: number, configDir?: string): C const paths = chatgptUnblockWatcherPaths(configDir); const scriptInstalled = existsSync(paths.scriptPath); const plistInstalled = existsSync(paths.plistPath); - const agentLoaded = sh("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; + const agentLoaded = sh("launchctl", ["print", `${watcherDomain()}/${CHATGPT_UNBLOCK_WATCHER_LABEL}`]).ok; const scriptUpToDate = scriptInstalled - && readFileSync(paths.scriptPath, "utf8") === buildChatgptUnblockWatcherScript(port); + && 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 }; } -/** Launch the ChatGPT desktop app with the resolver rule (macOS). */ -export function launchChatgptWithRule(port: number): void { +/** + * 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", ["-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): { ok: boolean; output: string } { if (process.platform !== "darwin") { throw new Error("launching the ChatGPT desktop app is only supported on macOS"); } - execFileSync("open", ["-a", "ChatGPT", "--args", chatgptUnblockResolverRule(port)], { stdio: "ignore" }); + // 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 result = spawnSync("/bin/bash", ["-s", mode], { + input: buildChatgptUnblockWatcherScript(port, configDir), + 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): { ok: boolean; output: string } { + return runLaunchScript("launch", port, configDir); +} + +/** Restart an app that carries the resolver rule without it, 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 index 9b4bdf4af95..54c2fd2df95 100644 --- a/src/chatgpt/desktop-unblock/listener.ts +++ b/src/chatgpt/desktop-unblock/listener.ts @@ -1,7 +1,11 @@ import type { Server } from "bun"; import type { PemKeyPair } from "../../claude/intercept/local-ca"; import { forwardHeadersForUpstream } from "../../claude/intercept/listener"; -import { stripSendBlocksFromJson, stripSendBlocksFromSseLine } from "./rewrite"; +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. @@ -21,11 +25,47 @@ 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. +// 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", + "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; @@ -33,6 +73,9 @@ export interface ChatgptUnblockListenerOptions { 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 { @@ -48,10 +91,19 @@ function responseHeaders(source: Response): Headers { * keeps its exact chunking and line endings; only `data:` lines whose JSON loses an entry are * re-serialized. */ -export function sseRewriteStream(debug?: (line: string, rewritten: string | null) => void): TransformStream { +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 }); @@ -59,15 +111,13 @@ export function sseRewriteStream(debug?: (line: string, rewritten: string | null while ((index = pending.indexOf("\n")) !== -1) { const line = pending.slice(0, index); pending = pending.slice(index + 1); - const rewritten = stripSendBlocksFromSseLine(line); - debug?.(line, rewritten); + const rewritten = rewriteLine(line); controller.enqueue(encoder.encode(`${rewritten ?? line}\n`)); } }, flush(controller) { if (pending.length === 0) return; - const rewritten = stripSendBlocksFromSseLine(pending); - debug?.(pending, rewritten); + const rewritten = rewriteLine(pending); controller.enqueue(encoder.encode(rewritten ?? pending)); pending = ""; }, @@ -86,8 +136,12 @@ 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; @@ -108,6 +162,11 @@ export async function relayWithSendUnblock( ); } 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)) { let text: string; @@ -116,25 +175,45 @@ export async function relayWithSendUnblock( } catch { return new Response(JSON.stringify({ error: { message: "chatgpt unblock upstream read failed" } }), { status: 502, headers }); } - const rewritten = stripSendBlocksFromJson(text); - return new Response(rewritten ?? text, { status: upstream.status, statusText: upstream.statusText, headers }); + 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()), { status: upstream.status, statusText: upstream.statusText, headers }); + return new Response(upstream.body.pipeThrough(sseRewriteStream({ surface, diagnostics })), init); } - return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); + return new Response(upstream.body, init); } -/** Bind the intercept TLS listener on an ephemeral loopback port. */ -export function startChatgptUnblockListener(options: ChatgptUnblockListenerOptions): Server { +/** + * 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; - return Bun.serve({ + 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, - async fetch(req) { - return relayWithSendUnblock(req, upstreamBase, options.fetchImpl); + 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 index 91719c13d88..c4d0e6676ce 100644 --- a/src/chatgpt/desktop-unblock/rewrite.ts +++ b/src/chatgpt/desktop-unblock/rewrite.ts @@ -3,21 +3,26 @@ * * The ChatGPT desktop app disables the conversation composer from two backend data shapes: * - * 1. Conversation payloads (`/conversation/init` and friends) attach `blocked_features` - * entries named `send` (or `tpp_send`) and `limits_progress` entries for `send` with - * `remaining <= 0`. + * 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. * - * Both describe the account's own subscription quota -- data that is meaningless for turns - * whose model calls are routed to third-party providers by opencodex. + * 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: * - * The rewriter removes exactly the send-lock entries and flips exactly the usage gate flags. - * Quota display stays honest: `banner_info` / `rate_limit_upsell`, the `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 while the composer - * unlocks. + * - 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. */ @@ -26,6 +31,36 @@ 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 usage quota, e.g. `usage_limit`. Anything else -- + * subscription, policy, or a reason this code has never seen -- is left in place. + */ +const QUOTA_BLOCK_REASON = /limit|quota|exhaust/i; + +/** 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; @@ -35,10 +70,16 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isSendBlockedFeature(entry: unknown): 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_REASON.test(reason); +} + function isExhaustedSendLimit(entry: unknown): boolean { if (!isRecord(entry) || entry.feature_name !== SEND_LIMIT_FEATURE_NAME) return false; const remaining = entry.remaining; @@ -46,14 +87,15 @@ function isExhaustedSendLimit(entry: unknown): boolean { } /** - * Recursively strip send-lock entries from any `blocked_features` / `limits_progress` arrays. - * Malformed entries are kept: the rewrite owns removal of known-shaped blocks, not validation. + * 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): RewriteResult { +export function stripSendBlocks(value: unknown, preserved: PreservedSendBlock[] = []): RewriteResult { if (Array.isArray(value)) { let changed = false; const items = value.map(item => { - const result = stripSendBlocks(item); + const result = stripSendBlocks(item, preserved); changed ||= result.changed; return result.value; }); @@ -64,7 +106,12 @@ export function stripSendBlocks(value: unknown): RewriteResult { const out: Record = {}; for (const [key, child] of Object.entries(value)) { if (key === "blocked_features" && Array.isArray(child)) { - const kept = child.filter(entry => !isSendBlockedFeature(entry)); + const kept = child.filter(entry => { + if (!isSendBlockedFeature(entry)) return true; + if (isQuotaBlockReason(entry.block_reason)) return false; + preserved.push({ name: String(entry.name), reason: String(entry.block_reason) }); + return true; + }); changed ||= kept.length !== child.length; out[key] = kept; continue; @@ -75,7 +122,7 @@ export function stripSendBlocks(value: unknown): RewriteResult { out[key] = kept; continue; } - const result = stripSendBlocks(child); + const result = stripSendBlocks(child, preserved); changed ||= result.changed; out[key] = result.value; } @@ -84,9 +131,10 @@ export function stripSendBlocks(value: unknown): RewriteResult { /** * Flip the desktop usage snapshot's send gate in place: `rate_limit.allowed` false -> true and - * `rate_limit.limit_reached` true -> false, at any depth (top-level for snapshot endpoints, - * under `usage` for stream events). Window percentages, reset timestamps, the upsell banner - * and every other display field are left exactly as the backend sent them. + * `rate_limit.limit_reached` true -> false, at any depth (top-level for the snapshot endpoint, + * under `usage` for stream events). Callers apply this to the usage endpoints only. Window + * percentages, reset timestamps, the upsell banner and every other display field are left + * exactly as the backend sent them. * * Returns whether anything changed. */ @@ -116,31 +164,49 @@ export function unlockRateLimitGate(value: unknown): boolean { } /** - * Rewrite a JSON response body. Returns `null` when the body is not valid JSON or contains - * nothing to rewrite, so callers can pass the original bytes through untouched. + * 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): string | null { +export function stripSendBlocksFromJson( + text: string, + surface?: RewriteSurface, + preserved: PreservedSendBlock[] = [], +): string | null { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return null; } - const stripped = stripSendBlocks(parsed); - const unlocked = unlockRateLimitGate(stripped.value); - return stripped.changed || unlocked ? JSON.stringify(stripped.value) : 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. Returns `null` when the - * line is unchanged. + * 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): string | null { - const match = /^(data: ?)(.*)$/.exec(line); +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]!); + const rewritten = stripSendBlocksFromJson(match[2]!, surface, preserved); if (rewritten === null) return null; - return `${match[1]}${rewritten}`; + return `${match[1]}${rewritten}${cr}`; } diff --git a/src/chatgpt/desktop-unblock/runtime.ts b/src/chatgpt/desktop-unblock/runtime.ts index 2171ee85f90..133a93b3b09 100644 --- a/src/chatgpt/desktop-unblock/runtime.ts +++ b/src/chatgpt/desktop-unblock/runtime.ts @@ -7,6 +7,7 @@ import { 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. @@ -25,10 +26,21 @@ export function chatgptUnblockEnabled(config: Pick, publicPort: number): number { const configured = config.chatgptDesktop?.port; if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; - return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; + 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. */ @@ -36,13 +48,21 @@ 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; + listener: Server; stop(): Promise; } @@ -63,7 +83,7 @@ export async function startChatgptUnblock(options: StartChatgptUn const ca = await ensureLocalInterceptCaForStartup(configDir); const leaf = issueLocalInterceptLeaf(ca, [CHATGPT_INTERCEPT_HOST]); // The port must be the configured one, not ephemeral: the launcher's resolver rule names it. - const listener = startChatgptUnblockListener({ leaf, port: chatgptUnblockPort(options.config, options.publicPort) }); + const listener = startChatgptUnblockListener({ leaf, port: chatgptUnblockPort(options.config, options.publicPort) }); return { port: listener.port ?? chatgptUnblockPort(options.config, options.publicPort), caCertPath: claudeInterceptCaCertPath(configDir), 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..06b922a7554 --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-relay.ts @@ -0,0 +1,341 @@ +import type { Server, ServerWebSocket } from "bun"; +import { randomBytes } from "node:crypto"; +import type { TLSSocket } from "node:tls"; +import { encodeWsFrame, parseWsFrames, 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; + +/** + * 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. */ +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); + 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[] } | 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] }; + if (fin) this.flushMessage(); + return; + } + if (opcode === WsOpcode.CONTINUATION) { + if (this.fragmentation === null) return; + this.fragmentation.chunks.push(payload); + if (this.fragmentation.chunks.length > MAX_MESSAGE_CHUNKS) { + 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..e337f33485d --- /dev/null +++ b/src/chatgpt/desktop-unblock/ws-upstream.ts @@ -0,0 +1,239 @@ +import { connect as connectSocket } from "node:net"; +import { connect as connectTls } from "node:tls"; +import { effectiveProxyFor } from "../../lib/proxy-env"; +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; + * 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); + 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()); +} + +async function dialRaw(target: RawTarget, proxy: string | null, route: UpstreamTunnel["route"], timeout: number): Promise { + if (route === "direct") return tcpConnect(target.host, target.port, timeout); + const proxyUrl = new URL(proxy!); + const proxyHost = proxyUrl.hostname.replace(/^\[|\]$/g, ""); + const proxyPort = Number(proxyUrl.port) || (route === "socks5" ? 1080 : proxyUrl.protocol === "https:" ? 443 : 8080); + const proxySocket = await tcpConnect(proxyHost, proxyPort, timeout); + const reader = new ProxyHandshakeReader(proxySocket, timeout); + try { + if (route === "http-connect") await httpConnectThrough(reader, target); + else await socks5ConnectThrough(reader, target); + } catch (error) { + reader.dispose(); + proxySocket.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 proxySocket; +} + +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 or timeout fails every pending step; after `dispose()` the + * reader no longer owns the socket's data 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")); + + constructor(private readonly socket: Socket, timeout: number) { + socket.on("data", this.onData); + socket.on("error", this.onError); + socket.setTimeout(timeout, this.onTimeout); + } + + write(bytes: Buffer | 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.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): Promise { + const authority = `${target.host}:${target.port}`; + reader.write([ + `CONNECT ${authority} HTTP/1.1`, + `Host: ${authority}`, + `Proxy-Connection: Keep-Alive`, + "", + "", + ].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 socks5ConnectThrough(reader: ProxyHandshakeReader, target: RawTarget): Promise { + // Byte-level SOCKS5 CONNECT (RFC 1928), no-auth only: proxy selection upstream of this + // module never picks an authenticated SOCKS proxy it cannot hand to a raw socket. + reader.write(Buffer.from([0x05, 0x01, 0x00])); // VER, 1 method, NO AUTH + const greeting = await reader.readExact(2); + if (greeting[0] !== 0x05 || greeting[1] !== 0x00) throw new Error("SOCKS5 greeting rejected"); + const hostBytes = Buffer.from(target.host, "utf8"); + reader.write(Buffer.from([ + 0x05, // VER + 0x01, // CONNECT + 0x00, // RSV + 0x03, // ATYP = domain + hostBytes.length, + ...hostBytes, + target.port >> 8, + target.port & 0xff, + ])); + const replyHead = await reader.readExact(4); + if (replyHead[0] !== 0x05 || replyHead[1] !== 0x00) throw new Error("SOCKS5 CONNECT refused"); + const atyp = replyHead[3]!; + const addressLength = atyp === 0x01 ? 4 : atyp === 0x03 ? (await reader.readExact(1))[0]! : 16; + await reader.readExact(addressLength + 2); +} + +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); + }); + }); +} diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index edeeb35e619..8704055c025 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -1,92 +1,173 @@ -import { execFileSync } from "node:child_process"; import { loadConfig } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; -import { chatgptUnblockWatcherStatus, installChatgptUnblockWatcher, launchChatgptWithRule, uninstallChatgptUnblockWatcher } from "../chatgpt/desktop-unblock/launch-watcher"; -import { CHATGPT_UNBLOCK_PORT_OFFSET, chatgptUnblockResolverRule } from "../chatgpt/desktop-unblock/runtime"; +import { + chatgptAppCommandLine, + chatgptCommandLineHasRule, + chatgptUnblockWatcherStatus, + installChatgptUnblockWatcher, + launchChatgptWithRule, + probeChatgptUnblockListener, + restoreChatgptNative, + uninstallChatgptUnblockWatcher, +} from "../chatgpt/desktop-unblock/launch-watcher"; +import { chatgptUnblockPort, chatgptUnblockResolverArg } from "../chatgpt/desktop-unblock/runtime"; +import { chatgptCaTrustCommand, inspectChatgptCaTrust } from "../chatgpt/desktop-unblock/ca-trust"; +import { claudeInterceptCaCertPath } from "../claude/intercept/local-ca"; +import { getConfigDir } from "../config/paths"; +import { interactiveConfirm } from "./interactive-confirm"; /** - * `ocx chatgpt` — inspect and operate the ChatGPT desktop send-unblock integration. + * `ocx chatgpt` — inspect and operate the ChatGPT desktop send-unblock integration (macOS). * - * ocx chatgpt status Feature, listener, watcher and app state - * ocx chatgpt install-watcher Install the launch watcher (Dock/Spotlight launches too) - * ocx chatgpt uninstall-watcher Remove the launch watcher - * ocx chatgpt launch Launch the app with the resolver rule + * ocx chatgpt status Feature, listener, trust, watcher and app state + * ocx chatgpt install-watcher [--yes] Install the launch watcher (Dock/Spotlight launches too) + * ocx chatgpt uninstall-watcher Remove the launch watcher + * ocx chatgpt launch Launch the app with the resolver rule + * ocx chatgpt restore Relaunch a mapped app with native networking */ -function sh(command: string, args: string[]): boolean { - try { - execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"] }); - return true; - } catch { - return false; - } -} +const USAGE = `Usage: + ocx chatgpt status Feature, listener, certificate trust, watcher and app state + ocx chatgpt install-watcher [--yes] Install the launch watcher (covers Dock/Spotlight launches) + ocx chatgpt uninstall-watcher Remove the launch watcher + ocx chatgpt launch Launch the ChatGPT app with the resolver rule + ocx chatgpt restore Relaunch a mapped ChatGPT app with native networking`; /** Port the intercept listens on: explicit config, else live proxy + offset, else default + offset. */ export function resolveChatgptUnblockPort(config: OcxConfig, livePort: number | undefined): number { - const configured = config.chatgptDesktop?.port; - if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; - const publicPort = livePort ?? (typeof config.port === "number" ? config.port : 10100); - return publicPort + CHATGPT_UNBLOCK_PORT_OFFSET; + return chatgptUnblockPort(config, livePort ?? (typeof config.port === "number" ? config.port : 10100)); } -export async function handleChatgptCommand(args: string[]): Promise { +const WATCHER_CONSENT = `The launch watcher runs each time the ChatGPT app starts. If the app was opened +normally (Dock, Spotlight) while opencodex is running, it quits the app right after launch +and reopens it with the opencodex route. It never acts on an app that is already in use, +and does nothing while opencodex is not running. Remove it any time with +'ocx chatgpt uninstall-watcher'.`; + +export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { - console.log(`Usage: - ocx chatgpt status Feature, listener, watcher and app state - ocx chatgpt install-watcher Install the launch watcher (covers Dock/Spotlight launches) - ocx chatgpt uninstall-watcher Remove the launch watcher - ocx chatgpt launch Launch the ChatGPT app with the resolver rule`); + console.log(USAGE); return sub ? 0 : 64; } + if (!["status", "install-watcher", "uninstall-watcher", "launch", "restore"].includes(sub)) { + console.error(`unknown subcommand: ${sub}`); + return 64; + } + if (platform !== "darwin") { + // lsof/pgrep/launchd do not exist elsewhere; answering "not running" would be a false report. + console.error("The ChatGPT desktop send-unblock integration is only supported on macOS."); + return sub === "status" ? 0 : 1; + } const config = loadConfig(); const live = await findLiveProxy().catch(() => null); - const port = resolveChatgptUnblockPort(config, live?.port); - const rule = chatgptUnblockResolverRule(port); - - if (sub === "status") { - const enabled = config.chatgptDesktop?.unblockSend === true; - const listening = sh("lsof", ["-nP", "-iTCP", `:${port}`, "-sTCP:LISTEN"]); - const watcher = chatgptUnblockWatcherStatus(port); - const appRunning = sh("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"]); - const appFlagged = sh("pgrep", ["-f", `MacOS/ChatGPT ${rule}`]); - console.log(`ChatGPT send-unblock: - feature enabled: ${enabled ? "yes" : "no (set chatgptDesktop.unblockSend: true)"} - listener port: ${port}${listening ? " (listening)" : " (not listening)"} - resolver rule: ${rule} - watcher script: ${watcher.scriptInstalled ? (watcher.scriptUpToDate ? "installed" : "installed (outdated; reinstall)") : "not installed"} - watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "not installed"} - app: ${appRunning ? (appFlagged ? "running with rule" : "running WITHOUT rule (composer will lock)") : "not running"}`); - return 0; + let port: number; + try { + port = resolveChatgptUnblockPort(config, live?.port); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; } + if (sub === "status") return await printStatus(config, port); + if (sub === "install-watcher") { if (config.chatgptDesktop?.unblockSend !== true) { console.error("chatgptDesktop.unblockSend is not enabled; add it to ~/.opencodex/config.json first:"); console.error(' { "chatgptDesktop": { "unblockSend": true } }'); return 1; } - installChatgptUnblockWatcher({ port }); + console.log(WATCHER_CONSENT); + if (!args.includes("--yes")) { + if (!process.stdin.isTTY) { + console.error("Re-run with --yes to confirm installing the launch watcher."); + return 1; + } + if (!(await interactiveConfirm({ question: "Install the launch watcher?", defaultYes: false }))) { + console.log("Launch watcher not installed."); + return 1; + } + } + try { + installChatgptUnblockWatcher({ port }); + } catch (error) { + console.error(`Launch watcher not installed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } console.log(`🛰 Launch watcher installed for port ${port}.`); - console.log(" Normal Dock/Spotlight launches of the ChatGPT app are now corrected automatically."); return 0; } if (sub === "uninstall-watcher") { - uninstallChatgptUnblockWatcher(); + try { + uninstallChatgptUnblockWatcher(); + } catch (error) { + console.error(`Launch watcher not removed: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } console.log("Launch watcher removed."); return 0; } - if (sub === "launch") { - launchChatgptWithRule(port); - console.log(`Launched the ChatGPT app with ${rule}`); - return 0; + if (sub === "launch") return report(launchChatgptWithRule(port)); + + // restore: the watcher would put the rule straight back on the relaunch while opencodex runs. + const watcher = chatgptUnblockWatcherStatus(port); + if (watcher.agentLoaded && (await probeChatgptUnblockListener(port)).state === "ours") { + console.error("The launch watcher would re-apply the opencodex route on relaunch while opencodex is running."); + console.error("Run 'ocx chatgpt uninstall-watcher' first, or stop opencodex, then 'ocx chatgpt restore'."); + return 1; } + return report(restoreChatgptNative(port)); +} - console.error(`unknown subcommand: ${sub}`); - return 64; +function report(result: { ok: boolean; output: string }): number { + if (result.output) (result.ok ? console.log : console.error)(result.output); + return result.ok ? 0 : 1; +} + +async function printStatus(config: OcxConfig, port: number): Promise { + const enabled = config.chatgptDesktop?.unblockSend === true; + const listener = await probeChatgptUnblockListener(port); + const watcher = chatgptUnblockWatcherStatus(port); + const appCommandLine = chatgptAppCommandLine(); + const appRunning = appCommandLine !== null; + const appFlagged = appRunning && chatgptCommandLineHasRule(appCommandLine, port); + const caPath = claudeInterceptCaCertPath(getConfigDir()); + const trust = await inspectChatgptCaTrust(caPath); + const listenerLine = { + ours: "listening", + foreign: "held by ANOTHER process (not opencodex); set chatgptDesktop.port to a free port", + down: "not listening", + }[listener.state]; + const trustLine = { + trusted: "trusted", + untrusted: "NOT trusted (account, usage and settings pages will fail to load)", + missing: "not created yet (start opencodex with the feature enabled)", + unknown: "could not be checked", + unsupported: "not applicable on this platform", + }[trust]; + console.log(`ChatGPT send-unblock: + feature enabled: ${enabled ? "yes" : "no (set chatgptDesktop.unblockSend: true)"} + listener port: ${port} (${listenerLine}) + resolver rule: ${chatgptUnblockResolverArg(port)} + CA trust: ${trustLine} + watcher script: ${watcher.scriptInstalled ? (watcher.scriptUpToDate ? "installed" : "installed (outdated; reinstall)") : "not installed"} + watcher agent: ${watcher.agentLoaded ? "loaded" : watcher.plistInstalled ? "installed but not loaded" : "not installed"} + app: ${appRunning ? (appFlagged ? "running with rule" : "running WITHOUT rule (composer will lock)") : "not running"}`); + if (trust === "untrusted") console.log(` restore trust with: ${chatgptCaTrustCommand(caPath)}`); + if (listener.state === "ours" && listener.preservedSendBlocks.length > 0) { + // Non-quota send blocks are deliberately left in place; name them so a locked composer has a cause. + console.log(" send blocks kept: (not usage quota, so not lifted)"); + for (const block of listener.preservedSendBlocks) console.log(` - ${block.name}: ${block.reason} (last seen ${block.lastSeen})`); + } + if (appFlagged && listener.state !== "ours") { + console.log(` + ⚠ The app is routed to port ${port}, but opencodex's listener is not answering there. + Every chatgpt.com request from the app fails until opencodex runs again, or run + 'ocx chatgpt restore' to relaunch the app with native networking.`); + } + return 0; } diff --git a/src/cli/help.ts b/src/cli/help.ts index 967e431add0..9f36cb4094d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -92,6 +92,7 @@ Usage: ocx lab Read-only Compatibility Lab projection inspection 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 chatgpt ChatGPT desktop send-unblock (status|install-watcher|launch|restore) ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) ocx mcode [args...] Launch MiniMax Code through its managed provider ocx mmx text [args] Launch MiniMax CLI text through the proxy diff --git a/src/cli/registry.ts b/src/cli/registry.ts index d8c929259ee..2805969990e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -489,14 +489,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "chatgpt", - usage: "ocx chatgpt ", - summary: "Inspect and operate the ChatGPT desktop send-unblock integration.", + usage: "ocx chatgpt ", + summary: "Inspect and operate the ChatGPT desktop send-unblock integration (macOS).", details: [ - "status Feature, intercept listener, watcher and app launch state.", + "status Feature, intercept listener, certificate trust, watcher and app state.", "install-watcher Install the launchd watcher so Dock/Spotlight launches of the ChatGPT", " app are automatically corrected to carry the host-resolver rule.", + " Asks for confirmation; --yes confirms non-interactively.", "uninstall-watcher Remove the launch watcher script and agent.", "launch Launch the ChatGPT app with the resolver rule.", + "restore Relaunch a routed ChatGPT app with native networking.", "Requires chatgptDesktop.unblockSend: true in config for install-watcher.", ], }, diff --git a/src/server/index/chatgpt-unblock-lifecycle.ts b/src/server/index/chatgpt-unblock-lifecycle.ts index 95bcef64b09..cd509fde798 100644 --- a/src/server/index/chatgpt-unblock-lifecycle.ts +++ b/src/server/index/chatgpt-unblock-lifecycle.ts @@ -1,11 +1,16 @@ import type { ChatgptUnblockHandle, StartChatgptUnblockOptions } from "../../chatgpt/desktop-unblock/runtime"; -import { chatgptUnblockResolverRule, startChatgptUnblock } 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; @@ -19,7 +24,7 @@ export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { 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: open -a ChatGPT --args --host-resolver-rules='${chatgptUnblockResolverRule(handle.port)}'`); + console.log(" Launch the ChatGPT app with: ocx chatgpt launch (or `ocx chatgpt install-watcher` for Dock launches)"); } return handle; }).catch((error: unknown) => { @@ -28,7 +33,21 @@ export function createChatgptUnblockLifecycle(): ChatgptUnblockLifecycle { }); }, async stop() { - await (await pending)?.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 || !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/tests/chatgpt-unblock/rewrite.test.ts b/tests/chatgpt-unblock/rewrite.test.ts index 9e90830ca56..be150d9ce8c 100644 --- a/tests/chatgpt-unblock/rewrite.test.ts +++ b/tests/chatgpt-unblock/rewrite.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { stripSendBlocks, stripSendBlocksFromJson, stripSendBlocksFromSseLine, unlockRateLimitGate } from "../../src/chatgpt/desktop-unblock/rewrite"; +import { + rewriteSurfaceFor, + stripSendBlocks, + stripSendBlocksFromJson, + stripSendBlocksFromSseLine, + unlockRateLimitGate, + type PreservedSendBlock, +} from "../../src/chatgpt/desktop-unblock/rewrite"; const blockedPayload = { banner_info: { @@ -21,13 +28,17 @@ const blockedPayload = { }; describe("stripSendBlocks", () => { - test("removes send locks and keeps quota display data", () => { - const result = stripSendBlocks(blockedPayload); + 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" }, @@ -46,6 +57,35 @@ describe("stripSendBlocks", () => { 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("keeps malformed entries and recurses into nested payloads", () => { const nested = { conversation: { blocked_features: [{ name: "send" }, "junk", 7] } }; const result = stripSendBlocks(nested); @@ -56,14 +96,26 @@ describe("stripSendBlocks", () => { describe("stripSendBlocksFromJson", () => { test("rewrites a conversation-init style body", () => { - const rewritten = stripSendBlocksFromJson(JSON.stringify(blockedPayload)); + const rewritten = stripSendBlocksFromJson(JSON.stringify(blockedPayload), "conversation"); expect(rewritten).not.toBeNull(); const parsed = JSON.parse(rewritten!) as typeof blockedPayload; - expect(parsed.blocked_features).toHaveLength(1); - expect(parsed.blocked_features[0]!.name).toBe("image_gen"); + 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(); @@ -142,6 +194,17 @@ describe("stripSendBlocksFromSseLine", () => { 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(); @@ -149,3 +212,26 @@ describe("stripSendBlocksFromSseLine", () => { 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/chatgpt-unblock/unblock-ca-trust.test.ts b/tests/chatgpt-unblock/unblock-ca-trust.test.ts new file mode 100644 index 00000000000..2a0c6c464c5 --- /dev/null +++ b/tests/chatgpt-unblock/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/chatgpt-unblock/unblock-launch-script.test.ts b/tests/chatgpt-unblock/unblock-launch-script.test.ts new file mode 100644 index 00000000000..7e0ee797241 --- /dev/null +++ b/tests/chatgpt-unblock/unblock-launch-script.test.ts @@ -0,0 +1,325 @@ +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" = -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`); + // The listener's identity path: opencodex answers with its service id, another server with + // something else, and a closed port makes curl fail. + 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 only what follows --args, i.e. what the app itself receives. + stub("open", `echo open >> "$STUB_DIR/calls" +args=(); after=0 +for a in "$@"; do + if [ $after = 1 ]; then args+=("$a"); elif [ "$a" = --args ]; then after=1; fi +done +[ \${#args[@]} -gt 0 ] && printf '%s\\n' "\${args[@]}" > "$STUB_DIR/open-args" +echo "500|ChatGPT|${APP_BINARY} \${args[*]}" >> "$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; +}) { + 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)); + 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), + log: read("chatgpt-unblock-watcher.log"), + }; +} + +describe("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("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 resolver rule"); + }); +}); + +describe("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("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); + }); +}); diff --git a/tests/chatgpt-unblock/unblock-listener.test.ts b/tests/chatgpt-unblock/unblock-listener.test.ts new file mode 100644 index 00000000000..e3a0909854d --- /dev/null +++ b/tests/chatgpt-unblock/unblock-listener.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { + CHATGPT_UNBLOCK_IDENTITY_PATH, + 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`); + }); +}); diff --git a/tests/chatgpt-unblock/unblock-watcher-install.test.ts b/tests/chatgpt-unblock/unblock-watcher-install.test.ts new file mode 100644 index 00000000000..3904bdb4f01 --- /dev/null +++ b/tests/chatgpt-unblock/unblock-watcher-install.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + 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/chatgpt-unblock/unblock-ws-frame.test.ts b/tests/chatgpt-unblock/unblock-ws-frame.test.ts new file mode 100644 index 00000000000..43dec2d678e --- /dev/null +++ b/tests/chatgpt-unblock/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/chatgpt-unblock/unblock-ws-relay.test.ts b/tests/chatgpt-unblock/unblock-ws-relay.test.ts new file mode 100644 index 00000000000..4f1f4c95f44 --- /dev/null +++ b/tests/chatgpt-unblock/unblock-ws-relay.test.ts @@ -0,0 +1,346 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +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, sendableCloseCode } from "../../src/chatgpt/desktop-unblock/ws-relay"; +import { 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 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[] } { + const requests: 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); + 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 }; +} + +/** No-auth SOCKS5 proxy that sends every CONNECT to the fake upstream, recording the target. */ +function startSocks5Proxy(upstreamPort: () => number): { server: NetServer; targets: string[] } { + const targets: string[] = []; + const server = createNetServer(client => { + let buffer = Buffer.alloc(0); + let greeted = false; + client.on("error", () => {}); + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + if (!greeted) { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]!) return; + buffer = buffer.subarray(2 + buffer[1]!); + greeted = true; + client.write(Buffer.from([0x05, 0x00])); + } + 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 }; +} + +/** 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("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(); + }); +}); + +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); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a02e41384cc..5882f7c851d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1423,6 +1423,12 @@ "restore-completes-shared-teardown.test.ts": "cli", "rewrite.test.ts": "chatgpt-unblock", "retry-after-429.test.ts": "server", + "unblock-ca-trust.test.ts": "chatgpt-unblock", + "unblock-launch-script.test.ts": "chatgpt-unblock", + "unblock-listener.test.ts": "chatgpt-unblock", + "unblock-watcher-install.test.ts": "chatgpt-unblock", + "unblock-ws-frame.test.ts": "chatgpt-unblock", + "unblock-ws-relay.test.ts": "chatgpt-unblock", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", "routed-agent-messages.test.ts": "adapters", diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 7cbbc315b5c..69b925b1339 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 the opt-in ChatGPT send-unblock lifecycles, both fire-and-forget: each stores its start promise, never awaits it, and turns every failure into a warning. 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.", }; From f7492c492fbf5e0692cc907cba3ebe3dabca000d Mon Sep 17 00:00:00 2001 From: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:37:01 +0800 Subject: [PATCH 4/4] fix(chatgpt-unblock): cap fragmented WS messages by bytes and honor https:// + credentialed proxies - The relay's fragmentation buffer now enforces WEBSOCKET_MAX_FRAME_BYTES as a total payload limit alongside the 1024-chunk cap, failing with 1009. - dialUpstreamTunnel TLS-wraps https:// proxy sockets before the CONNECT handshake (SNI omitted for IP-literal proxies, which node:tls forbids) and sends Basic Proxy-Authorization when the proxy URL carries credentials. Co-Authored-By: Claude Sonnet 5 --- src/chatgpt/desktop-unblock/ws-relay.ts | 11 ++- src/chatgpt/desktop-unblock/ws-upstream.ts | 54 ++++++++++--- .../chatgpt-unblock/unblock-ws-relay.test.ts | 79 ++++++++++++++++++- 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/src/chatgpt/desktop-unblock/ws-relay.ts b/src/chatgpt/desktop-unblock/ws-relay.ts index 06b922a7554..e3aaa42d874 100644 --- a/src/chatgpt/desktop-unblock/ws-relay.ts +++ b/src/chatgpt/desktop-unblock/ws-relay.ts @@ -1,7 +1,7 @@ import type { Server, ServerWebSocket } from "bun"; import { randomBytes } from "node:crypto"; import type { TLSSocket } from "node:tls"; -import { encodeWsFrame, parseWsFrames, WsOpcode } from "./ws-frame"; +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"; @@ -28,6 +28,8 @@ const HANDSHAKE_TIMEOUT_MS = 10_000; 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 @@ -151,7 +153,7 @@ function readResponseHead(socket: TLSSocket, timeoutMs: number): Promise<{ head: export class WsRelay { private client: ServerWebSocket | null = null; private pendingParse: Buffer = Buffer.alloc(0); - private fragmentation: { opcode: WsOpcode; chunks: Buffer[] } | null = null; + private fragmentation: { opcode: WsOpcode; chunks: Buffer[]; bytes: number } | null = null; private clientClosed = false; private closed = false; private drainTimer: ReturnType | null = null; @@ -222,14 +224,15 @@ export class WsRelay { 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] }; + 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); - if (this.fragmentation.chunks.length > MAX_MESSAGE_CHUNKS) { + 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; } diff --git a/src/chatgpt/desktop-unblock/ws-upstream.ts b/src/chatgpt/desktop-unblock/ws-upstream.ts index e337f33485d..6dffafc0c0c 100644 --- a/src/chatgpt/desktop-unblock/ws-upstream.ts +++ b/src/chatgpt/desktop-unblock/ws-upstream.ts @@ -56,7 +56,7 @@ export async function dialUpstreamTunnel(options: DialUpstreamOptions = {}): Pro 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); + const raw = await dialRaw(target, proxy, route, timeout, options.ca); const socket = await wrapTls(raw, timeout, options.ca); return { socket, route }; } catch { @@ -73,25 +73,35 @@ function socks5Route(proxy: string | null): boolean { return proxy !== null && /^socks5h?:\/\//i.test(proxy.trim()); } -async function dialRaw(target: RawTarget, proxy: string | null, route: UpstreamTunnel["route"], timeout: number): Promise { +function isIpLiteral(host: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":"); +} + +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 = Number(proxyUrl.port) || (route === "socks5" ? 1080 : proxyUrl.protocol === "https:" ? 443 : 8080); const proxySocket = await tcpConnect(proxyHost, proxyPort, timeout); - const reader = new ProxyHandshakeReader(proxySocket, 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); try { - if (route === "http-connect") await httpConnectThrough(reader, target); + if (route === "http-connect") await httpConnectThrough(reader, target, proxyUrl); else await socks5ConnectThrough(reader, target); } catch (error) { reader.dispose(); - proxySocket.destroy(); + 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 proxySocket; + return plain; } async function tcpConnect(host: string, port: number, timeout: number): Promise { @@ -186,15 +196,20 @@ class ProxyHandshakeReader { } } -async function httpConnectThrough(reader: ProxyHandshakeReader, target: RawTarget): Promise { +async function httpConnectThrough(reader: ProxyHandshakeReader, target: RawTarget, proxyUrl: URL): Promise { const authority = `${target.host}:${target.port}`; - reader.write([ + const lines = [ `CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`, `Proxy-Connection: Keep-Alive`, - "", - "", - ].join(CRLF)); + ]; + // 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}`); @@ -237,3 +252,20 @@ async function wrapTls(raw: Socket, timeout: number, ca: string | undefined): Pr }); }); } + +/** 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/tests/chatgpt-unblock/unblock-ws-relay.test.ts b/tests/chatgpt-unblock/unblock-ws-relay.test.ts index 4f1f4c95f44..a294a13ba14 100644 --- a/tests/chatgpt-unblock/unblock-ws-relay.test.ts +++ b/tests/chatgpt-unblock/unblock-ws-relay.test.ts @@ -7,7 +7,7 @@ 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, sendableCloseCode } from "../../src/chatgpt/desktop-unblock/ws-relay"; -import { parseWsFrames, WEBSOCKET_GUID, WsOpcode } from "../../src/chatgpt/desktop-unblock/ws-frame"; +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(); @@ -83,6 +83,15 @@ function startFakeUpstream(): { server: TlsServer; log: UpstreamLog; port: () => } 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}`)); } } @@ -93,9 +102,37 @@ function startFakeUpstream(): { server: TlsServer; log: UpstreamLog; port: () => } /** HTTP CONNECT proxy that sends every tunnel to the fake upstream, recording the request line. */ -function startConnectProxy(upstreamPort: () => number): { server: NetServer; requests: string[] } { +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) => { @@ -311,6 +348,34 @@ describe("chatgpt unblock websocket relay", () => { 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()); @@ -324,6 +389,16 @@ describe("chatgpt unblock websocket relay", () => { expect(proxy.targets).toEqual(["chatgpt.com:443"]); app.ws.close(); }); + + 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", () => {