From 4e0e8f4b13f1411651fa5502ecec69f4a2c16358 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 16:25:51 +0900 Subject: [PATCH 1/4] feat(chatgpt): add experimental macOS app-server quota-gate shim Split the app-server shim out of #5947. ocx chatgpt launch|restore|status relaunches ChatGPT with CODEX_CLI_PATH pointing at a generated launcher that execs the bundled codex app-server unchanged and pipes only its stdout through the hidden 'ocx internal chatgpt-app-server-filter'. The filter clears the plain-quota gate in account/rateLimits/read and account/rateLimits/updated and passes every other line through byte for byte. The launcher falls back to the untouched binary when the platform check, runtime or filter self-test fails. Default off behind chatgptDesktop.appServerShim; macOS only; experimental. Refs #6196 Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- docs-site/astro.config.mjs | 1 + .../content/docs/guides/chatgpt-desktop.md | 87 ++++++++ scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 14 +- .../app-server-shim/app-server-rewrite.ts | 45 ++++ src/chatgpt/app-server-shim/filter.ts | 101 +++++++++ src/chatgpt/app-server-shim/gate-rewrite.ts | 98 +++++++++ src/chatgpt/app-server-shim/launcher.ts | 42 ++++ src/cli/capabilities.ts | 7 + src/cli/chatgpt-command.ts | 99 +++++++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/internal-command.ts | 17 +- src/cli/registry.ts | 6 + src/config/diagnostics.ts | 8 + src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 3 + src/types/config.ts | 2 + structure/INDEX.md | 2 + structure/clients/chatgpt-desktop.md | 47 +++++ structure/config.md | 2 +- structure/manifest.json | 9 + structure/runtime.md | 2 +- .../desktop-app-server-shim-launcher.test.ts | 92 +++++++++ tests/clients/desktop-app-server-shim.test.ts | 192 ++++++++++++++++++ tests/clients/desktop-chatgpt-config.test.ts | 40 ++++ tests/fixtures/test-layout-expected.json | 3 + 27 files changed, 920 insertions(+), 9 deletions(-) create mode 100644 docs-site/src/content/docs/guides/chatgpt-desktop.md create mode 100644 src/chatgpt/app-server-shim/app-server-rewrite.ts create mode 100644 src/chatgpt/app-server-shim/filter.ts create mode 100644 src/chatgpt/app-server-shim/gate-rewrite.ts create mode 100644 src/chatgpt/app-server-shim/launcher.ts create mode 100644 src/cli/chatgpt-command.ts create mode 100644 structure/clients/chatgpt-desktop.md create mode 100644 tests/clients/desktop-app-server-shim-launcher.test.ts create mode 100644 tests/clients/desktop-app-server-shim.test.ts create mode 100644 tests/clients/desktop-chatgpt-config.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 0c5f250128f..61b3274da9d 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -108,6 +108,7 @@ export default defineConfig({ { label: "Grok Build", translations: { fr: "Grok Build", ko: "Grok Build", "zh-CN": "Grok Build", "zh-TW": "Grok Build", ru: "Grok Build", ja: "Grok Build", tr: "Grok Build" }, slug: "guides/grok-build" }, { label: "opencode", translations: { fr: "opencode", ko: "opencode", "zh-CN": "opencode", "zh-TW": "opencode", ru: "opencode", ja: "opencode", tr: "opencode" }, slug: "guides/opencode" }, { label: "Pi", translations: { fr: "Pi", ko: "Pi", "zh-CN": "Pi", "zh-TW": "Pi", ru: "Pi", ja: "Pi", tr: "Pi" }, slug: "guides/pi" }, + { label: "ChatGPT Desktop (experimental)", slug: "guides/chatgpt-desktop" }, { label: "Integrations", translations: { fr: "Intégrations", ko: "연동", "zh-CN": "集成", "zh-TW": "整合", ru: "Интеграции", ja: "連携", tr: "Entegrasyonlar" }, slug: "guides/integrations" }, { label: "MiniMax clients", translations: { fr: "Clients MiniMax", ko: "MiniMax 클라이언트", "zh-CN": "MiniMax 客户端", "zh-TW": "MiniMax 客戶端", ru: "Клиенты MiniMax", ja: "MiniMax クライアント", tr: "MiniMax İstemcileri" }, slug: "guides/minimax" }, { label: "Sidecars: Web Search & Vision", translations: { fr: "Services auxiliaires : recherche web et vision", ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", "zh-TW": "邊車:網路搜尋與視覺", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン", tr: "Sidecar'lar: Web Arama ve Görme" }, slug: "guides/sidecars" }, diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md new file mode 100644 index 00000000000..a8fdc61c87b --- /dev/null +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -0,0 +1,87 @@ +--- +title: ChatGPT Desktop app-server shim (experimental) +description: An opt-in macOS experiment that rewrites plain-quota gate fields on the bundled app-server stdout pipe. +--- + +This experiment is **macOS only and off by default**. It filters the bundled ChatGPT +app-server's JSON-RPC stdout to open known plain-quota gates. It does not increase +an account's quota or make an upstream service accept a request it refuses. + +Enable it in your OpenCodex `config.json`: + +```json +{ + "chatgptDesktop": { "appServerShim": true } +} +``` + +Then run: + +```bash +ocx chatgpt launch +ocx chatgpt status +``` + +`launch` creates an executable launcher under the OpenCodex config directory, +quits ChatGPT if it is running, and relaunches it with +`open -a ChatGPT --env CODEX_CLI_PATH=`. Save ongoing work first: this +restarts the app. It does not require a running OpenCodex proxy. + +To remove the launcher and relaunch without the override: + +```bash +ocx chatgpt restore +``` + +Restore leaves the config flag as configured. Set `chatgptDesktop.appServerShim` +to `false` or remove it to disable future explicit shim launches. Normal launches +from Dock or Spotlight do not apply the shim automatically. + +## Rewrite boundary + +Only `account/rateLimits/updated` notifications and responses whose top-level +result contains `rateLimits`, `rateLimitsByLimitId`, or `ordinaryUsageAllowed` +are eligible. Plain `rate_limit_reached` markers are cleared; known quota gate +flags (`allowed`, `limit_reached` / `limitReached`, `ordinaryUsageAllowed`) are +opened where the payload permits it. `ordinaryUsageAllowed` requires plain-quota +exhaustion evidence. Workspace, credit, unknown reached-type and spend-control +restrictions keep the usage gate closed. + +Displayed usage stays honest: percentages, reset times, window durations, plan +information and other display fields stay as received. Unrelated JSON-RPC +messages, nested tool output, conversation send-block metadata and malformed +lines pass through. Only changed lines are serialized again; other bytes retain +their original encoding and line endings. Stdin, stderr and the real binary's +exit status retain their direct connection to the app. + +## Executable and environment security + +The generated launcher has mode `0755` and embeds the current OpenCodex executable +and, for source installs, the CLI entry path. `CODEX_CLI_PATH` tells ChatGPT to +execute this launcher instead of its bundled binary directly. Keep the launcher, +its config directory, and the OpenCodex installation under your control: changing +these executable paths changes code the app runs. The launcher still `exec`s the +bundled binary at its standard `/Applications/ChatGPT.app` location. + +This integration installs no certificate, network listener, PAC, or background +watcher. It does not log the app's messages or environment. Status reports whether +the running ChatGPT bundle process carries the expected launcher override. + +## Failure behavior and known limits + +A failed precondition or a failed self-test runs the original binary with untouched stdout. +A filter that passes the self-test and then dies mid-session closes the pipe. +Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a write error and Desktop respawns it through the same launcher. +The filter's passthrough mode limits this to an exit/crash case: a rewrite exception +passes its line through, and an unexpected rewrite-machinery failure switches the +remaining stream to raw bytes. + +The experiment depends on the bundled binary path, the app honoring +`CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved +or removed OpenCodex installation fails the launcher preflight and runs the +original binary. Run `ocx chatgpt launch` again after relocating the installation. + +This standalone shim does not rewrite conversation metadata or route model calls. +Other app gates or upstream refusals can still prevent sending. Evidence reported +on an exhausted Plus account also used an intercept, so it does not establish +that this shim alone resolves every desktop send lock. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b4d27b9425e..08e48f415a7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,9 @@ "version": 1, "root": "tests", "explicit": { + "desktop-app-server-shim.test.ts": "clients", + "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-chatgpt-config.test.ts": "clients", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 15c52cd6c71..3b54d20e6b0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -513,6 +513,16 @@ JSON mode: `payload`. Each of these writes. Check the flags column before running one unattended. +### `ocx chatgpt` + +Experimental ChatGPT app-server shim: launch, restore or status (macOS only). + +Drives no management route. + +JSON mode: `none`. + +- Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher. + ### `ocx link issue` Issue one link credential and record its tunnel metadata. @@ -1207,6 +1217,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 67 -- of those, state-changing: 37 +- declared capabilities: 68 +- of those, state-changing: 38 - head-resolved invocations: 2 diff --git a/src/chatgpt/app-server-shim/app-server-rewrite.ts b/src/chatgpt/app-server-shim/app-server-rewrite.ts new file mode 100644 index 00000000000..ae0d97a82b5 --- /dev/null +++ b/src/chatgpt/app-server-shim/app-server-rewrite.ts @@ -0,0 +1,45 @@ +import { unlockRateLimitGate } from "./gate-rewrite"; + +/** + * Line-level rewrite for the app-server's JSON-RPC output. + * + * Only the two messages that carry the composer's gate are rewritten: the + * `account/rateLimits/updated` notification and a result whose top level holds the rate-limit + * snapshot (the `account/rateLimits/read` response). Every other message is left alone, including + * thread items and tool results that happen to nest a `rate_limit` object. Lines that do not + * mention the snapshot fields are not even parsed, so the hot path costs two substring checks and + * the bytes stay identical. + */ +const RATE_LIMITS_UPDATED = "account/rateLimits/updated"; +const RATE_LIMIT_RESULT_FIELDS = ["rateLimits", "rateLimitsByLimitId", "ordinaryUsageAllowed"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The part of a message the gate rewrite may touch, or null when the message carries no gate. */ +function gatePayload(message: unknown): Record | null { + if (!isRecord(message)) return null; + if (message.method === RATE_LIMITS_UPDATED) return isRecord(message.params) ? message.params : null; + if ("method" in message) return null; + const result = message.result; + return isRecord(result) && RATE_LIMIT_RESULT_FIELDS.some(field => field in result) ? result : null; +} + +/** + * Returns the rewritten line, or null when the line is not a gate-bearing JSON-RPC message or the + * rewrite has nothing to change. Parse failures also return null: the rewrite owns removal of + * known-shaped locks, not validation. + */ +export function rewriteAppServerLine(line: string): string | null { + if (!line.includes("rateLimit") && !line.includes("ordinaryUsageAllowed")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + const payload = gatePayload(parsed); + if (payload === null) return null; + return unlockRateLimitGate(payload) ? JSON.stringify(parsed) : null; +} diff --git a/src/chatgpt/app-server-shim/filter.ts b/src/chatgpt/app-server-shim/filter.ts new file mode 100644 index 00000000000..6a9312da816 --- /dev/null +++ b/src/chatgpt/app-server-shim/filter.ts @@ -0,0 +1,101 @@ +import { rewriteAppServerLine } from "./app-server-rewrite"; + +/** Byte-preserving stdout filter; unexpected rewrite machinery failures disable filtering. */ +const NEWLINE = 0x0a; + +/** + * Splits a byte stream into lines and rewrites the ones that need it. Returns the bytes to write + * for each chunk; a partial trailing line is held back until its newline arrives (or `flush`). + */ +export function createRpcLineFilter( + rewrite: (line: string) => string | null = rewriteAppServerLine, +): { push(chunk: Uint8Array): Uint8Array[]; flush(): Uint8Array[] } { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending: Uint8Array = new Uint8Array(0); + let passthrough = false; + + // `line` excludes the newline, `whole` includes it when there is one. An untouched line is + // returned as the very bytes that arrived; only a rewritten one is re-encoded. + const emit = (line: Uint8Array, whole: Uint8Array, terminated: boolean): Uint8Array => { + let rewritten: string | null; + try { + rewritten = rewrite(decoder.decode(line)); + } catch { + return whole; + } + if (rewritten === null) return whole; + const body = encoder.encode(rewritten); + if (!terminated) return body; + const out = new Uint8Array(body.length + 1); + out.set(body, 0); + out[body.length] = NEWLINE; + return out; + }; + + return { + push(chunk) { + if (passthrough) return [chunk]; + const previous = pending; + try { + const joined = new Uint8Array(previous.length + chunk.length); + joined.set(previous, 0); + joined.set(chunk, previous.length); + const out: Uint8Array[] = []; + let start = 0; + for (let i = 0; i < joined.length; i++) { + if (joined[i] !== NEWLINE) continue; + out.push(emit(joined.subarray(start, i), joined.subarray(start, i + 1), true)); + start = i + 1; + } + pending = joined.slice(start); + return out; + } catch { + pending = new Uint8Array(0); + passthrough = true; + return previous.length ? [previous, chunk] : [chunk]; + } + }, + flush() { + if (pending.length === 0) return []; + const last = pending; + pending = new Uint8Array(0); + if (passthrough) return [last]; + try { return [emit(last, last, false)]; } catch { return [last]; } + }, + }; +} + +/** Copy `input` to `write`, rewriting gate lines on the way. Resolves when `input` ends. */ +export async function runStdoutFilter( + input: AsyncIterable, + write: (bytes: Uint8Array) => Promise | unknown, + rewrite?: (line: string) => string | null, +): Promise { + let filter: ReturnType | undefined; + try { filter = createRpcLineFilter(rewrite); } catch { /* Raw passthrough if setup fails. */ } + for await (const chunk of input) { + for (const out of filter ? filter.push(chunk) : [chunk]) await write(out); + } + for (const out of filter?.flush() ?? []) await write(out); +} + +/** Hidden CLI entry; the self-test emits no stdout so the launcher can probe it safely. */ +export async function runChatgptAppServerFilter({ selfTest = false }: { selfTest?: boolean } = {}): Promise { + if (selfTest) { + try { + const fixture = JSON.stringify({ id: 1, result: { + ordinaryUsageAllowed: false, + rateLimits: { rateLimitReachedType: "rate_limit_reached", primary: { usedPercent: 100 } }, + } }); + const rewritten = rewriteAppServerLine(fixture); + if (!rewritten) return 1; + const result = JSON.parse(rewritten).result; + return result.ordinaryUsageAllowed === true + && result.rateLimits.rateLimitReachedType === null + && result.rateLimits.primary.usedPercent === 100 ? 0 : 1; + } catch { return 1; } + } + await runStdoutFilter(Bun.stdin.stream(), bytes => Bun.write(Bun.stdout, bytes)); + return 0; +} diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts new file mode 100644 index 00000000000..b1d393cc3fc --- /dev/null +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -0,0 +1,98 @@ +/** Plain-quota gate rewriting; usage display and non-quota restrictions are preserved. */ +const PLAIN_QUOTA_REACHED_TYPE = "rate_limit_reached"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasNonQuotaBlock(node: unknown): boolean { + if (Array.isArray(node)) return node.some(hasNonQuotaBlock); + if (!isRecord(node)) return false; + if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) return true; + if (isRecord(node.spend_control) && node.spend_control.reached === true) return true; + const reached = node.rate_limit_reached_type; + if (isRecord(reached) && typeof reached.type === "string" && reached.type !== PLAIN_QUOTA_REACHED_TYPE) return true; + const rpcReached = node.rateLimitReachedType; + const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined; + if (typeof rpcType === "string" && rpcType !== PLAIN_QUOTA_REACHED_TYPE) return true; + return Object.values(node).some(hasNonQuotaBlock); +} + +export function unlockRateLimitGate(value: unknown): boolean { + let changed = false; + const payloadBlocked = hasNonQuotaBlock(value); + // A subtree shows the plain quota as the reason when a plain-quota reached type was removed in + // it or a usage window reads 100%; it stays "blocked" when a non-quota reason (workspace or + // credit reached type, spend control) is still standing in it. + const visit = (node: unknown): { cleared: boolean; exhausted: boolean; blocked: boolean } => { + let cleared = false; + let exhausted = false; + let blocked = false; + if (Array.isArray(node)) { + for (const item of node) { + const r = visit(item); + cleared ||= r.cleared; + exhausted ||= r.exhausted; + blocked ||= r.blocked; + } + return { cleared, exhausted, blocked }; + } + if (!isRecord(node)) return { cleared, exhausted, blocked }; + if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true; + if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true; + if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true; + const reachedType = node.rate_limit_reached_type; + if (isRecord(reachedType) && reachedType.type === PLAIN_QUOTA_REACHED_TYPE) { + delete node.rate_limit_reached_type; + changed = true; + cleared = true; + } else if (isRecord(reachedType) && typeof reachedType.type === "string") { + blocked = true; + } + // The app-server's JSON-RPC spelling of the same field: a nullable string. + const rpcReached = node.rateLimitReachedType; + const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined; + if (rpcType === PLAIN_QUOTA_REACHED_TYPE) { + node.rateLimitReachedType = null; + changed = true; + cleared = true; + } else if (typeof rpcType === "string") { + blocked = true; + } + for (const child of Object.values(node)) { + const r = visit(child); + cleared ||= r.cleared; + exhausted ||= r.exhausted; + blocked ||= r.blocked; + } + // The rate-limit flags are opened only when no workspace, credit or spend-control reason + // stands anywhere in this payload; with one present the flags stay as the server sent them. + if (!blocked && !payloadBlocked) { + for (const key of ["rate_limit", "rateLimit"] as const) { + const rateLimit = node[key]; + if (!isRecord(rateLimit)) continue; + if (rateLimit.allowed === false) { + rateLimit.allowed = true; + changed = true; + } + for (const limitKey of ["limit_reached", "limitReached"] as const) { + if (rateLimit[limitKey] === true) { + rateLimit[limitKey] = false; + changed = true; + } + } + } + } + // `ordinaryUsageAllowed: false` is the same quota gate seen from the RPC side, and the only + // field the app reads for it. Open it only when the plain quota is the visible reason and + // nothing else still explains the block. + if (node.ordinaryUsageAllowed === false && (cleared || exhausted) && !blocked && !payloadBlocked) { + node.ordinaryUsageAllowed = true; + changed = true; + } + return { cleared, exhausted, blocked }; + }; + visit(value); + return changed; +} + diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts new file mode 100644 index 00000000000..a8710174920 --- /dev/null +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -0,0 +1,42 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../../config/paths"; +import { selfLaunchArgv } from "../../lib/self-launch-argv"; + +export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; + +export function chatgptShimLauncherPath(configDir = getConfigDir()): string { + return join(configDir, "chatgpt-codex-shim.sh"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * A failed precondition or a failed self-test runs the original binary with untouched stdout. + * A filter that passes the self-test and then dies mid-session closes the pipe. + * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a + * write error and Desktop respawns it through the same launcher. + */ +export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string { + return `#!/bin/bash +# opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. +REAL=${shellQuote(real)} +FILTER=(${argv.map(shellQuote).join(" ")}) +if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ + && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then + exec "$REAL" "$@" > >(exec "\${FILTER[@]}") +fi +exec "$REAL" "$@" +`; +} + +export function writeChatgptShimLauncher(configDir = getConfigDir()): string { + mkdirSync(configDir, { recursive: true }); + const path = chatgptShimLauncherPath(configDir); + const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; + writeFileSync(path, buildChatgptShimLauncher(argv), { mode: 0o755 }); + chmodSync(path, 0o755); + return path; +} diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 484454a802f..7ff66234ac8 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -95,6 +95,13 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [ * A capability must not name a route the command does not actually fetch. */ export const CAPABILITIES: readonly Capability[] = [ + { + command: ["chatgpt"], + summary: "Experimental ChatGPT app-server shim: launch, restore or status (macOS only).", + routes: [], flags: [], mutates: true, json: "none", + bannerLines: ["ocx chatgpt Experimental app-server shim: launch|restore|status (macOS)"], + details: ["Default off; launch requires chatgptDesktop.appServerShim: true. Restore removes the generated launcher."], + }, { command: ["link", "port"], summary: "Allocate a free loopback port for a remote home link.", diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts new file mode 100644 index 00000000000..06f1c5914ed --- /dev/null +++ b/src/cli/chatgpt-command.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { loadConfig } from "../config"; +import { chatgptShimLauncherPath, writeChatgptShimLauncher } from "../chatgpt/app-server-shim/launcher"; + +const USAGE = `Usage (experimental, macOS only): + ocx chatgpt launch Relaunch ChatGPT with the experimental app-server shim (requires appServerShim: true) + ocx chatgpt restore Relaunch ChatGPT without the experimental shim and remove its launcher + ocx chatgpt status Inspect experimental flag, launcher and running app environment`; + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000 }); + return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; +} + +/** + * Only inspect the named bundle process; never print the environment being inspected. + * `-a` keeps ancestors in the match: when ocx runs inside a ChatGPT/Codex session the app is + * one of this process's ancestors, and plain `pgrep -x` would report it as not running. + */ +function appState(launcher: string): { running: boolean; shim: boolean } { + const pids = run("pgrep", ["-a", "-x", "ChatGPT"]); + if (!pids.ok) return { running: false, shim: false }; + for (const pid of pids.output.split(/\s+/).filter(value => /^\d+$/.test(value))) { + const command = run("ps", ["eww", "-o", "command=", "-p", pid]); + if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) { + const marker = `CODEX_CLI_PATH=${launcher}`; + const start = command.output.indexOf(marker); + return { running: true, shim: start >= 0 && (start === 0 || command.output[start - 1] === " ") + && (start + marker.length === command.output.length || command.output[start + marker.length] === " ") }; + } + } + return { running: false, shim: false }; +} + +/** Adapted from #5947: open ignores new launch settings until the previous app exits. */ +async function quitApp(launcher: string): Promise { + if (!appState(launcher).running) return true; + for (let attempt = 0; attempt < 3; attempt++) { + run("osascript", ["-e", 'quit app "ChatGPT"']); + for (let poll = 0; poll < 20; poll++) { + if (!appState(launcher).running) return true; + await Bun.sleep(250); + } + } + return !appState(launcher).running; +} + +export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise { + const sub = args[0]; + if (!sub || ["help", "--help", "-h"].includes(sub)) { + console.log(USAGE); + return sub ? 0 : 64; + } + if (!["launch", "restore", "status"].includes(sub) || args.length !== 1) { + console.error(USAGE); + return 64; + } + if (platform !== "darwin") { + console.error("ChatGPT app-server shim (experimental): macOS only."); + return 1; + } + try { + const config = loadConfig(); + const launcher = chatgptShimLauncherPath(); + if (sub === "status") { + const app = appState(launcher); + console.log(`app-server shim (experimental): ${config.chatgptDesktop?.appServerShim === true ? "on" : "off"} +launcher: ${existsSync(launcher) ? "present" : "absent"} +app: ${app.running ? "running" : "not running"} +CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); + return 0; + } + if (sub === "launch") { + if (config.chatgptDesktop?.appServerShim !== true) { + console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); + return 1; + } + writeChatgptShimLauncher(); + } + if (!(await quitApp(launcher))) { + console.error("ChatGPT did not quit; quit it manually and retry."); + return 1; + } + // Remove an inherited override too: restore must launch without CODEX_CLI_PATH. + const env = { ...process.env }; + delete env.CODEX_CLI_PATH; + const result = spawnSync("open", ["-a", "ChatGPT", ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { + encoding: "utf8", env, timeout: 10000, + }); + if (result.status !== 0) throw new Error(result.error?.message ?? (result.stderr?.trim() || "open failed")); + if (sub === "restore") rmSync(launcher, { force: true }); + console.log(`ChatGPT relaunched ${sub === "launch" ? "with" : "without"} the experimental app-server shim.`); + return 0; + } catch (error) { + console.error(`ChatGPT shim (experimental): ${error instanceof Error ? error.message : String(error)}`); + return 1; + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 450829c20d1..0e9ef882b6d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -947,6 +947,10 @@ const commandRunners: Record = { const { handleLabCommand } = await import("./lab"); return await handleLabCommand(deps.args.slice(1)); }, + chatgpt: async deps => { + const { handleChatgptCommand } = await import("./chatgpt-command"); + return await handleChatgptCommand(deps.args.slice(1)); + }, claude: async deps => { const { cmdClaude } = await import("./claude"); // "ocx claude desktop" → write Desktop 3P config diff --git a/src/cli/help.ts b/src/cli/help.ts index 488df7bc4ff..b5043bb8329 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -90,6 +90,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection + ocx chatgpt Experimental app-server shim: launch|restore|status (macOS) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) diff --git a/src/cli/internal-command.ts b/src/cli/internal-command.ts index ef2e5961555..7799ceb52b2 100644 --- a/src/cli/internal-command.ts +++ b/src/cli/internal-command.ts @@ -3,21 +3,20 @@ * * Deliberately NOT in `src/cli/registry.ts` and NOT in `src/cli/capabilities.ts`, so it * is absent from help, from the generated skill surface, and from the registry-parity - * gate. It is not a user-facing capability and must not become one: it exists so the - * detached restart helper is the same audited binary running the same audited ladder, - * rather than a second implementation in a shell script. + * gate. These are implementation entrypoints for the detached restart helper and + * experimental ChatGPT stdout filter; compiled builds re-enter the same CLI binary. * * It is routed before the dispatch table for the same reason `help` is - adding it as a * runner key would make it a command the registry-parity test expects to find * documented. * - * It is intentionally unauthenticated, and that is not an oversight. Any process running + * The restart handoff is intentionally unauthenticated, and that is not an oversight. Any process running * as this user can invoke it with a hand-written plan file, and it gains nothing by * doing so: the helper only does what the public `--restart-codex` flag already does for * that same user, and a same-uid process could call `kill` directly. A token here would * protect nothing and would imply a boundary that does not exist. */ -const USAGE = "Usage: ocx internal desktop-restart-handoff --plan "; +const USAGE = "Usage: ocx internal desktop-restart-handoff --plan | chatgpt-app-server-filter [--self-test]"; function optionValue(args: readonly string[], name: string): string | undefined { const index = args.indexOf(name); @@ -26,6 +25,14 @@ function optionValue(args: readonly string[], name: string): string | undefined export async function handleInternalCommand(args: readonly string[]): Promise { const sub = args[0]; + if (sub === "chatgpt-app-server-filter") { + if (args.length > 2 || (args[1] !== undefined && args[1] !== "--self-test")) { + console.error(USAGE); + return 2; + } + const { runChatgptAppServerFilter } = await import("../chatgpt/app-server-shim/filter"); + return await runChatgptAppServerFilter({ selfTest: args[1] === "--self-test" }); + } if (sub !== "desktop-restart-handoff") { console.error(`Unknown internal command: ${sub ?? "(none)"}. ${USAGE}`); return 2; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3dcc639c7e2..25b7f8258ec 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -8,6 +8,12 @@ export interface CliCommandEntry { } export const CLI_COMMANDS: CliCommandEntry[] = [ + { + name: "chatgpt", + usage: "ocx chatgpt ", + summary: "Experimental ChatGPT app-server shim (macOS only, default off).", + details: ["Experimental launch requires chatgptDesktop.appServerShim: true; restore removes its launcher."], + }, { name: "init", aliases: ["setup"], diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index dd559ed6097..1e079eb5649 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -64,6 +64,7 @@ import { remoteGuiConfigSchema, runtimeRoleSchema, spendSchema, + chatgptDesktopSchema, compactionRoutingSchema, skillsConfigSchema, memoryModelsSchema, @@ -102,6 +103,9 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf const rawEffort = rawClaudeSubagentEffort(rawParsed); const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); const warnings = configPlaceholderWarnings(normalized); + if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") { + warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform"); + } warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); @@ -619,6 +623,10 @@ function skillsConfigError(value: unknown): string | null { } export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { + const chatgptDesktop = rawConfigRecord(value)?.chatgptDesktop; + if (chatgptDesktop !== undefined && !chatgptDesktopSchema.safeParse(chatgptDesktop).success) { + return { ok: false, error: "schema_invalid: chatgptDesktop: requires an optional boolean appServerShim and no other fields" }; + } const compactionRouting = rawConfigRecord(value)?.compactionRouting; if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) { return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }; diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 6febc06293f..3302e836d4b 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -17,6 +17,7 @@ import { remoteGuiConfigSchema, runtimeRoleSchema, spendSchema, + chatgptDesktopSchema, skillsConfigSchema, configuredCodexPoolAccountIds, apiKeyEntrySchema, @@ -69,6 +70,7 @@ import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/inte import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; export const configSchema = z.object({ + chatgptDesktop: chatgptDesktopSchema.optional().catch(undefined), codexNativeSteering: z.boolean().optional().catch(false), codexNativeInjection: z.boolean().optional().catch(false), port: z.number().int().min(0).max(65535).default(10100), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index bce878456d4..8ccd389929c 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -43,6 +43,9 @@ import { import { getConfigDir } from "../paths"; import { COMPACTION_TRIGGERS } from "./compaction-triggers"; +/** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */ +export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict(); + /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value === value.trim(); diff --git a/src/types/config.ts b/src/types/config.ts index 1c0f7c5c83c..cd76ad5e0ad 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -495,6 +495,8 @@ export interface OcxClientConnectionConfig { } export interface OcxConfig { + /** Experimental macOS app-server stdout shim; absent or false disables it. */ + chatgptDesktop?: { appServerShim?: boolean }; port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ runtimeRole?: OcxRuntimeRole; diff --git a/structure/INDEX.md b/structure/INDEX.md index 53f9e34cebd..398edd1e2c2 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -79,6 +79,7 @@ The dashboard, the management API, and third-party client config ownership. | [`gui-and-management-api.md`](gui-and-management-api.md) | Dashboard serving, authentication boundaries, /api/* ownership, and startup safety. | | [`dashboard-and-usage.md`](dashboard-and-usage.md) | Dashboard page contracts, usage accounting and request metrics, and per-surface management settings. | | [`clients/integrations.md`](clients/integrations.md) | Third-party client config ownership, snapshots, refresh, disable, and restore. | +| [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -115,6 +116,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | | `src/claude/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md new file mode 100644 index 00000000000..3fd7a7ee34a --- /dev/null +++ b/structure/clients/chatgpt-desktop.md @@ -0,0 +1,47 @@ +# ChatGPT Desktop app-server shim + +The experimental macOS integration is owned by `src/chatgpt/` and exposed through +`src/cli/chatgpt-command.ts`. It is default off and requires +`chatgptDesktop.appServerShim === true` for an explicit launch. Its config leaf +accepts only an optional boolean; malformed reads disable the leaf, while live +writes reject malformed values and unknown fields. + +The launcher under the config directory re-enters the current CLI using +`process.execPath` and `selfLaunchArgv()`. Its hidden internal filter command stays +out of the public command registry and generated skill surface. Source installs +include the CLI entry argument; compiled builds use only the executable and +internal command arguments. + +The launcher checks macOS, executable presence and a successful filter self-test, +then replaces itself with the bundled app-server using shell exec. Only stdout +passes through the filter. Stdin, stderr, process identity and the real server's +exit status retain the direct app/server relationship. + +A failed precondition or a failed self-test runs the original binary with untouched stdout. +A filter that passes the self-test and then dies mid-session closes the pipe. +Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a write error and Desktop respawns it through the same launcher. +The filter's passthrough mode limits this to an exit/crash case. + +The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC +rate-limit notifications and top-level rate-limit results. Workspace, credit, +unknown reached-type and spend-control restrictions preserve closed gate flags. +Usage percentages, resets and window durations remain accurate. Unrelated messages +and malformed lines remain byte-identical; changed lines are reserialized. +A per-line rewrite exception preserves that line. A failure in the framing/rewrite +machinery preserves buffered bytes and switches the rest of the stream to raw +passthrough. Output-write failures propagate; they are not rewrite failures. + +Launch writes a mode-0755 executable, waits for the old ChatGPT instance to quit, +then invokes open with the launcher in CODEX_CLI_PATH. Restore relaunches without +that override and removes the launcher only after open succeeds. Status reports +the experimental flag, launcher presence, and the named bundle process's override +without printing its environment. Other platforms reject all three operations. + +The launcher and its executable paths are local code-execution inputs. This +integration installs no network listener, CA, PAC or background watcher, and +starts no proxy timer. Explicit launches are its only activation point. + +Tests cover rewriting, byte framing, passthrough degradation, source/compiled +launcher text, stub launcher execution, hidden preflight, and strict config writes +in tests/clients/desktop-*.test.ts. Pipe-crash behavior is mock evidence only; +no bundled-app respawn guarantee is asserted. diff --git a/structure/config.md b/structure/config.md index 2b92b202384..e8ff6b998ae 100644 --- a/structure/config.md +++ b/structure/config.md @@ -597,4 +597,4 @@ so wrong types and unknown nested fields are rejected rather than silently saved `apiSurfaces` and `protocols` on `src/types/config.ts` are parsed by `src/protocols/settings.ts` only; [Protocol Paths](data-planes/protocol-paths.md#settings) owns their schema handling, meaning and the one writer (`PATCH /api/protocols/settings`), including why closing Messages also writes `claudeCode.enabled` through `commitClaudeCodeBlock` (`src/claude/claude-code-block.ts`, the sentinel-stamping block writer every management route uses). Stored Direct substitution follows the [credential identity contract](providers/openai-accounts.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged. -Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). +Proxy activation and credential-safe CLI output follow [Proxy Configuration](config-proxy.md). The experimental `chatgptDesktop` leaf accepts only optional boolean `appServerShim`, default off. Malformed reads disable this leaf; live writes reject malformed values and unknown keys. Its activation and local executable boundary follow [ChatGPT Desktop](clients/chatgpt-desktop.md). diff --git a/structure/manifest.json b/structure/manifest.json index 533183c97bd..d378c6df6e1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -439,6 +439,15 @@ "src/lib/" ] }, + { + "path": "clients/chatgpt-desktop.md", + "tier": 5, + "title": "ChatGPT Desktop", + "scope": "Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries.", + "documents": [ + "src/chatgpt/" + ] + }, { "path": "clients/claude-desktop.md", "tier": 5, diff --git a/structure/runtime.md b/structure/runtime.md index 85e0a73cb16..5c904cd7e60 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -597,4 +597,4 @@ registration succeeds. Bun updater lease and recovery behavior follows the [update transaction contract](ops/service-and-sidecars.md#bun-updater-ownership-transaction). -Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). +Companion timeline and filtered totals follow the [companion usage contract](companion.md). [Ongoing priority failback](providers/openai-accounts.md#ongoing-priority-failback) reuses request-triggered quota priming and captured-account dispatch; it adds no periodic worker or mid-request account switch. The serving-install census and bounded foreground delegation in `src/config/serving-runtimes.ts` follow [service command selection](ops/service-and-sidecars.md#background-service-command-selection). The experimental macOS `ocx chatgpt` launcher, restore and status commands follow the [ChatGPT Desktop contract](clients/chatgpt-desktop.md); the internal stdout filter remains hidden from public capability discovery. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts new file mode 100644 index 00000000000..a7379016f0a --- /dev/null +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { buildChatgptShimLauncher, writeChatgptShimLauncher, chatgptShimLauncherPath } from "../../src/chatgpt/app-server-shim/launcher"; +import { selfLaunchArgv } from "../../src/lib/self-launch-argv"; +import { repoPath } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const bashAvailable = process.platform !== "win32" + && spawnSync("/bin/bash", ["-c", "exit 0"]).status === 0; +const substitutionAvailable = bashAvailable + && spawnSync("/bin/bash", ["-c", "printf ok > >(cat)"], { encoding: "utf8" }).stdout === "ok"; +const executionTest = substitutionAvailable ? test : test.skip; +const executable = (dir: string, name: string, body: string) => { + const path = join(dir, name); + writeFileSync(path, `#!/bin/bash\n${body}\n`); + chmodSync(path, 0o755); + return path; +}; +function withDir(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); + try { run(dir); } finally { removeTreeWithRetry(dir); } +} +const internalArgs = ["internal", "chatgpt-app-server-filter"]; + +describe("experimental app-server launcher", () => { + test("source and compiled argv re-enter the CLI with shell-safe quoting", () => { + for (const standalone of [false, true]) { + const argv = ["/runtime with 'quote", ...selfLaunchArgv(internalArgs, { + isStandaloneExecutable: standalone, sourceEntrypoint: "/source with space/index.ts", + })]; + const launcher = buildChatgptShimLauncher(argv, "/real with space"); + expect(launcher).toContain("'internal' 'chatgpt-app-server-filter'"); + expect(launcher.includes("'/source with space/index.ts'")).toBe(!standalone); + expect(launcher).toContain("'\\''"); + expect(launcher).toContain('--self-test >/dev/null 2>&1'); + expect(launcher).toContain('"$(uname -s)" = "Darwin"'); + expect(launcher).toContain('exec "$REAL" "$@" > >(exec "${FILTER[@]}")'); + } + }); + + test("writer creates the launcher under the selected config dir with mode 0755", () => withDir(dir => { + const path = writeChatgptShimLauncher(dir); + expect(path).toBe(chatgptShimLauncherPath(dir)); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o755); + })); + + // Stub uname activates the macOS precondition on Linux without requiring the actual app. + const environment = (dir: string) => { + executable(dir, "uname", 'echo Darwin'); + return { ...process.env, PATH: `${dir}:${process.env.PATH ?? "/usr/bin:/bin"}` }; + }; + for (const scenario of ["missing runtime", "failing self-test", "passing self-test"] as const) { + const scenarioTest = (scenario === "passing self-test" ? substitutionAvailable : bashAvailable) ? test : test.skip; + scenarioTest(scenario, () => withDir(dir => { + const real = executable(dir, "real with 'quote.sh", 'echo "PID:$$ ARGS:$*"\nread -r line\necho "STDIN:$line"\necho "to-stderr" >&2\nexit 7'); + const filter = scenario === "missing runtime" ? join(dir, "missing") : executable(dir, "filter.sh", + `if [ "$1" = --self-test ]; then exit ${scenario === "failing self-test" ? 1 : 0}; fi\nsed 's/STDIN/FILTERED/'`); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const out = spawnSync("/bin/bash", [launcher, "app-server", "--flag"], { + encoding: "utf8", input: "hello\n", env: environment(dir), timeout: 5000, + }); + expect(out.status).toBe(7); + expect(out.stdout).toBe(`PID:${out.pid} ARGS:app-server --flag\n${scenario === "passing self-test" ? "FILTERED" : "STDIN"}:hello\n`); + expect(out.stderr).toBe("to-stderr\n"); + })); + } + + executionTest("the real hidden CLI filter passes preflight and rewrites the gate", () => withDir(dir => { + const real = executable(dir, "real.sh", `printf '%s\\n' '{"id":1,"result":{"ordinaryUsageAllowed":false,"rateLimits":{"primary":{"usedPercent":100}}}}'`); + const argv = [process.execPath, ...selfLaunchArgv(internalArgs, { + isStandaloneExecutable: false, sourceEntrypoint: repoPath("src/cli/index.ts"), + })]; + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher(argv, real)); + const out = spawnSync("/bin/bash", [launcher, "app-server"], { encoding: "utf8", env: environment(dir), timeout: 10000 }); + expect(out.status).toBe(0); + const result = JSON.parse(out.stdout).result; + expect(result.ordinaryUsageAllowed).toBe(true); + expect(result.rateLimits.primary.usedPercent).toBe(100); + })); + + executionTest("a filter exit after successful preflight closes the pipe and terminates a mock server", () => withDir(dir => { + const real = executable(dir, "real.sh", 'trap "exit 23" PIPE\nwhile :; do printf "%s\\n" "mock server stdout" || exit 23; done'); + const filter = executable(dir, "filter.sh", 'if [ "$1" = --self-test ]; then exit 0; fi\nexit 1'); + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([filter], real)); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", env: environment(dir), timeout: 5000 }); + expect(out.error).toBeUndefined(); + expect(out.status).toBe(23); + })); +}); diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts new file mode 100644 index 00000000000..c2b8b850340 --- /dev/null +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { rewriteAppServerLine } from "../../src/chatgpt/app-server-shim/app-server-rewrite"; +import { createRpcLineFilter, runStdoutFilter, runChatgptAppServerFilter } from "../../src/chatgpt/app-server-shim/filter"; + +/** + * The desktop app reads the composer's send gate from the bundled app-server over JSON-RPC, not + * from Chromium (#6196). The shim sits on that one stdio pipe and opens the plain-quota gate. + */ + +const rpcResult = (result: unknown) => JSON.stringify({ id: 2, result }); + +const EXHAUSTED_RATE_LIMITS = { + ordinaryUsageAllowed: false, + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: 1790000000 }, + planType: "pro", + rateLimitReachedType: "rate_limit_reached", + }, + rateLimitsByLimitId: { codex: { limitId: "codex", rateLimitReachedType: "rate_limit_reached" } }, +}; + +describe("app-server line rewrite", () => { + test("a plain-quota reached type is cleared and ordinary usage is allowed again", () => { + const out = JSON.parse(rewriteAppServerLine(rpcResult(EXHAUSTED_RATE_LIMITS))!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimitsByLimitId.codex.rateLimitReachedType).toBeNull(); + // Displayed usage is never changed. + expect(out.result.rateLimits.primary).toEqual(EXHAUSTED_RATE_LIMITS.rateLimits.primary); + expect(out.result.rateLimits.planType).toBe("pro"); + }); + + test("a workspace or credit reached type is left as the server sent it", () => { + for (const type of ["workspace_owner_usage_limit_reached", "workspace_member_credits_depleted"]) { + const line = rpcResult({ ...EXHAUSTED_RATE_LIMITS, rateLimits: { ...EXHAUSTED_RATE_LIMITS.rateLimits, rateLimitReachedType: type }, rateLimitsByLimitId: {} }); + expect(rewriteAppServerLine(line)).toBeNull(); + } + }); + + test("a plain quota next to a workspace block keeps ordinary usage closed", () => { + const line = rpcResult({ + ordinaryUsageAllowed: false, + rateLimits: { rateLimitReachedType: "rate_limit_reached" }, + rateLimitsByLimitId: { other: { rateLimitReachedType: "workspace_owner_credits_depleted" } }, + }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimitsByLimitId.other.rateLimitReachedType).toBe("workspace_owner_credits_depleted"); + expect(out.result.ordinaryUsageAllowed).toBe(false); + }); + + test("a Plus account with only the 5-hour window exhausted is opened and both windows are kept (#6196)", () => { + // The reported state: 5-hour window at 100 %, weekly window at 32 %, Send disabled. + const line = rpcResult({ + ordinaryUsageAllowed: false, + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: 1790000000 }, + secondary: { usedPercent: 32, windowDurationMins: 10080, resetsAt: 1790500000 }, + planType: "plus", + rateLimitReachedType: "rate_limit_reached", + }, + }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out.result.rateLimits.primary).toEqual({ usedPercent: 100, windowDurationMins: 300, resetsAt: 1790000000 }); + expect(out.result.rateLimits.secondary).toEqual({ usedPercent: 32, windowDurationMins: 10080, resetsAt: 1790500000 }); + }); + + test("a quota window at 100% opens ordinary usage even when no reached type is sent", () => { + const line = rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 100, windowDurationMins: 10080 }, rateLimitReachedType: null } }); + const out = JSON.parse(rewriteAppServerLine(line)!); + expect(out.result.ordinaryUsageAllowed).toBe(true); + expect(out.result.rateLimits.primary.usedPercent).toBe(100); + }); + + test("ordinary usage stays closed without quota evidence, or when spend control also blocks", () => { + // Closed for a reason the payload does not show: not ours to argue with. + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 12 } } }))).toBeNull(); + // Quota is exhausted but a spend control also stands. + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 100 }, spendControlReached: { used: 5, limit: 5 } } }))).toBeNull(); + }); + + test("notifications carrying the same fields are rewritten too", () => { + const line = JSON.stringify({ method: "account/rateLimits/updated", params: { rateLimits: { rateLimitReachedType: "rate_limit_reached" } } }); + expect(JSON.parse(rewriteAppServerLine(line)!).params.rateLimits.rateLimitReachedType).toBeNull(); + }); + + test("only rate-limit messages are rewritten; tool results and other results that nest gate fields are left alone", () => { + const toolResult = JSON.stringify({ + method: "item/completed", + params: { item: { type: "commandExecution", output: { rate_limit: { allowed: false, limit_reached: true }, rateLimitReachedType: "rate_limit_reached" } } }, + }); + expect(rewriteAppServerLine(toolResult)).toBeNull(); + + const otherResult = JSON.stringify({ id: 9, result: { data: { rate_limit: { allowed: false }, rateLimits: { rateLimitReachedType: "rate_limit_reached" } } } }); + expect(rewriteAppServerLine(otherResult)).toBeNull(); + + const blocks = JSON.stringify({ id: 10, result: { blockedFeatures: [{ name: "send", blockReason: "usage_limit" }] } }); + expect(rewriteAppServerLine(blocks)).toBeNull(); + }); + + test("lines without gate fields, unparseable lines and already-open gates are not touched", () => { + expect(rewriteAppServerLine(JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "hello" } }))).toBeNull(); + expect(rewriteAppServerLine('{"rateLimits": broken')).toBeNull(); + expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: true, rateLimits: { rateLimitReachedType: null } }))).toBeNull(); + }); + + test("a message that only quotes a field name inside text is not modified", () => { + const line = JSON.stringify({ method: "item/completed", params: { text: "the rateLimitReachedType field is rate_limit_reached" } }); + expect(rewriteAppServerLine(line)).toBeNull(); + }); +}); + +describe("app-server line filter", () => { + const collect = (chunks: Uint8Array[], filter = createRpcLineFilter()) => { + const parts: Uint8Array[] = []; + for (const chunk of chunks) parts.push(...filter.push(chunk)); + parts.push(...filter.flush()); + return new TextDecoder().decode(Buffer.concat(parts)); + }; + const enc = (s: string) => new TextEncoder().encode(s); + + test("untouched lines come back byte for byte, including multibyte text and odd line endings", () => { + const text = `${JSON.stringify({ method: "x", params: { t: "你好 🌏 é" } })}\n\r\n${JSON.stringify({ a: 1 })}\r\n`; + expect(collect([enc(text)])).toBe(text); + }); + + test("a gate line is rewritten wherever the chunk boundaries fall", () => { + const gate = rpcResult(EXHAUSTED_RATE_LIMITS); + const stream = `${JSON.stringify({ method: "a", params: { t: "你好" } })}\n${gate}\n${JSON.stringify({ method: "b" })}\n`; + const bytes = enc(stream); + for (const size of [1, 2, 3, 7, 64, bytes.length]) { + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += size) chunks.push(bytes.slice(i, i + size)); + const out = collect(chunks).split("\n"); + expect(out[0]).toBe(JSON.stringify({ method: "a", params: { t: "你好" } })); + expect(JSON.parse(out[1]!).result.rateLimits.rateLimitReachedType).toBeNull(); + expect(out[2]).toBe(JSON.stringify({ method: "b" })); + expect(out[3]).toBe(""); + } + }); + + test("a final line without a newline is flushed, and rewritten when it needs it", () => { + expect(collect([enc('{"method":"tail"}')])).toBe('{"method":"tail"}'); + const out = collect([enc(rpcResult(EXHAUSTED_RATE_LIMITS))]); + expect(out.endsWith("\n")).toBe(false); + expect(JSON.parse(out).result.ordinaryUsageAllowed).toBe(true); + }); +}); + +describe("app-server stdout filter", () => { + const chunksOf = async function* (parts: string[]) { + for (const part of parts) yield new TextEncoder().encode(part); + }; + const run = async (parts: string[], rewrite?: (line: string) => string | null) => { + const written: Uint8Array[] = []; + await runStdoutFilter(chunksOf(parts), bytes => void written.push(bytes), rewrite); + return new TextDecoder().decode(Buffer.concat(written)); + }; + + test("copies the server's stdout through, rewriting only the gate lines", async () => { + const out = await run([`${JSON.stringify({ method: "a" })}\n${rpcResult(EXHAUSTED_RATE_LIMITS)}\n`, '{"method":"done"}']); + const lines = out.split("\n"); + expect(lines[0]).toBe('{"method":"a"}'); + expect(JSON.parse(lines[1]!).result.rateLimits.rateLimitReachedType).toBeNull(); + expect(lines[2]).toBe('{"method":"done"}'); + }); + + test("a rewrite that throws passes its line through unchanged instead of breaking the stream", async () => { + const text = `${rpcResult(EXHAUSTED_RATE_LIMITS)}\n{"method":"after"}\n`; + const out = await run([text], () => { + throw new Error("boom"); + }); + expect(out).toBe(text); + }); +}); + + +test("hidden filter self-test succeeds without streaming stdin", async () => { + expect(await runChatgptAppServerFilter({ selfTest: true })).toBe(0); +}); + +test("machinery failure preserves buffered bytes and switches the remainder to raw passthrough", () => { + const enc = new TextEncoder(); + const filter = createRpcLineFilter(() => Symbol("encoding failure") as unknown as string); + expect(filter.push(enc.encode("partial"))).toEqual([]); + const out = [...filter.push(enc.encode(" tail\nnext\n")), ...filter.push(enc.encode("raw tail")), ...filter.flush()]; + expect(Buffer.concat(out).toString()).toBe("partial tail\nnext\nraw tail"); +}); diff --git a/tests/clients/desktop-chatgpt-config.test.ts b/tests/clients/desktop-chatgpt-config.test.ts new file mode 100644 index 00000000000..96df45f5e6a --- /dev/null +++ b/tests/clients/desktop-chatgpt-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { validateConfigCandidate, configDiagnosticsFromRaw } from "../../src/config/diagnostics"; +import { handleChatgptCommand } from "../../src/cli/chatgpt-command"; +import { handleInternalCommand } from "../../src/cli/internal-command"; + +const base = { providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" } }, defaultProvider: "xai" }; +describe("experimental ChatGPT desktop config", () => { + test("absent and false are off; true is opt-in", () => { + expect(configSchema.parse(base).chatgptDesktop).toBeUndefined(); + for (const appServerShim of [undefined, false, true]) { + const parsed = configSchema.parse({ ...base, chatgptDesktop: { appServerShim } }); + expect(parsed.chatgptDesktop?.appServerShim === true).toBe(appServerShim === true); + expect(validateConfigCandidate({ ...base, chatgptDesktop: { appServerShim } }).ok).toBe(true); + } + }); + test("malformed reads disable only the experimental feature; writes reject unknown and malformed fields", () => { + for (const chatgptDesktop of [null, true, [], "yes", { appServerShim: "true" }, { appServerShim: true, unblockSend: true }, { port: 1234 }]) { + const candidate = { ...base, port: 12345, chatgptDesktop }; + const parsed = configSchema.parse(candidate); + expect(parsed.chatgptDesktop).toBeUndefined(); + expect(parsed.port).toBe(12345); + const write = validateConfigCandidate(candidate); + expect(write.ok).toBe(false); + if (!write.ok) expect(write.error).toContain("chatgptDesktop"); + } + }); + test("enabled shim warns on other platforms", () => { + const diagnostics = configDiagnosticsFromRaw(JSON.stringify({ ...base, chatgptDesktop: { appServerShim: true } })); + expect(diagnostics.error).toBeNull(); + if (process.platform !== "darwin") expect(diagnostics.warnings?.join(" ")).toContain("macOS only"); + }); + test("all operations reject non-macOS without app side effects", async () => { + for (const sub of ["launch", "restore", "status"]) expect(await handleChatgptCommand([sub], "linux")).toBe(1); + }); + test("hidden self-test works and rejects extra options", async () => { + expect(await handleInternalCommand(["chatgpt-app-server-filter", "--self-test"])).toBe(0); + expect(await handleInternalCommand(["chatgpt-app-server-filter", "--invalid"])).toBe(2); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 83170e64b6b..236a542357f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,7 @@ { + "desktop-app-server-shim.test.ts": "clients", + "desktop-app-server-shim-launcher.test.ts": "clients", + "desktop-chatgpt-config.test.ts": "clients", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", From 506fb7503e1af98b79ee147ae750bd81ad7c9f35 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 22:50:45 +0900 Subject: [PATCH 2/4] fix(chatgpt): key the app-server shim on the verified bundle and tighten the gate Review fixes for #6361: - Discover ChatGPT by bundle identifier through the desktop restart adapter and derive the app-server binary from that root, so installs outside /Applications work and launch refuses when the bundle has no binary. - Quit by bundle id and reopen the same bundle path, so an unrelated app named ChatGPT is never quit or launched. - Hold a partial line as a list of chunks and join once at its newline, removing quadratic copying on long lines. - Open rate-limit flags only with plain-quota evidence, the same condition ordinaryUsageAllowed already uses. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- .../content/docs/guides/chatgpt-desktop.md | 13 ++-- src/chatgpt/app-server-shim/filter.ts | 53 +++++++++++----- src/chatgpt/app-server-shim/gate-rewrite.ts | 11 ++-- src/chatgpt/app-server-shim/launcher.ts | 38 +++++++++++- src/cli/chatgpt-command.ts | 61 ++++++++++++++----- structure/clients/chatgpt-desktop.md | 15 +++-- .../desktop-app-server-shim-launcher.test.ts | 24 +++++++- tests/clients/desktop-app-server-shim.test.ts | 22 +++++++ 8 files changed, 191 insertions(+), 46 deletions(-) diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index a8fdc61c87b..6071600c9d9 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -24,7 +24,10 @@ ocx chatgpt status `launch` creates an executable launcher under the OpenCodex config directory, quits ChatGPT if it is running, and relaunches it with -`open -a ChatGPT --env CODEX_CLI_PATH=`. Save ongoing work first: this +`open -a --env CODEX_CLI_PATH=`. The app is found by its bundle +identifier, `com.openai.codex`, so an install in `~/Applications` or on another +volume works, and another app that shares the "ChatGPT" name is never quit or +opened. Save ongoing work first: this restarts the app. It does not require a running OpenCodex proxy. To remove the launcher and relaunch without the override: @@ -43,8 +46,9 @@ Only `account/rateLimits/updated` notifications and responses whose top-level result contains `rateLimits`, `rateLimitsByLimitId`, or `ordinaryUsageAllowed` are eligible. Plain `rate_limit_reached` markers are cleared; known quota gate flags (`allowed`, `limit_reached` / `limitReached`, `ordinaryUsageAllowed`) are -opened where the payload permits it. `ordinaryUsageAllowed` requires plain-quota -exhaustion evidence. Workspace, credit, unknown reached-type and spend-control +opened only with plain-quota evidence (a cleared plain reached type or a window at +100%). A flag closed for a reason the payload does not show stays closed. Workspace, +credit, unknown reached-type and spend-control restrictions keep the usage gate closed. Displayed usage stays honest: percentages, reset times, window durations, plan @@ -61,7 +65,8 @@ and, for source installs, the CLI entry path. `CODEX_CLI_PATH` tells ChatGPT to execute this launcher instead of its bundled binary directly. Keep the launcher, its config directory, and the OpenCodex installation under your control: changing these executable paths changes code the app runs. The launcher still `exec`s the -bundled binary at its standard `/Applications/ChatGPT.app` location. +bundled binary of the discovered bundle; if that bundle has no app-server binary, +`launch` refuses instead of writing a launcher. This integration installs no certificate, network listener, PAC, or background watcher. It does not log the app's messages or environment. Status reports whether diff --git a/src/chatgpt/app-server-shim/filter.ts b/src/chatgpt/app-server-shim/filter.ts index 6a9312da816..de3b06ac605 100644 --- a/src/chatgpt/app-server-shim/filter.ts +++ b/src/chatgpt/app-server-shim/filter.ts @@ -3,16 +3,30 @@ import { rewriteAppServerLine } from "./app-server-rewrite"; /** Byte-preserving stdout filter; unexpected rewrite machinery failures disable filtering. */ const NEWLINE = 0x0a; +function concatParts(parts: readonly Uint8Array[], length: number): Uint8Array { + if (parts.length === 1) return parts[0]!; + const out = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + /** * Splits a byte stream into lines and rewrites the ones that need it. Returns the bytes to write * for each chunk; a partial trailing line is held back until its newline arrives (or `flush`). + * The held-back pieces are kept as a list and joined once when the line ends, so a long line + * spread over many pipe chunks costs linear copying rather than recopying its prefix per chunk. */ export function createRpcLineFilter( rewrite: (line: string) => string | null = rewriteAppServerLine, ): { push(chunk: Uint8Array): Uint8Array[]; flush(): Uint8Array[] } { const decoder = new TextDecoder(); const encoder = new TextEncoder(); - let pending: Uint8Array = new Uint8Array(0); + let pending: Uint8Array[] = []; + let pendingLength = 0; let passthrough = false; // `line` excludes the newline, `whole` includes it when there is one. An untouched line is @@ -37,30 +51,41 @@ export function createRpcLineFilter( push(chunk) { if (passthrough) return [chunk]; const previous = pending; + const previousLength = pendingLength; try { - const joined = new Uint8Array(previous.length + chunk.length); - joined.set(previous, 0); - joined.set(chunk, previous.length); const out: Uint8Array[] = []; + let held = previous; + let heldLength = previousLength; let start = 0; - for (let i = 0; i < joined.length; i++) { - if (joined[i] !== NEWLINE) continue; - out.push(emit(joined.subarray(start, i), joined.subarray(start, i + 1), true)); + for (let i = chunk.indexOf(NEWLINE); i !== -1; i = chunk.indexOf(NEWLINE, start)) { + const tail = chunk.subarray(start, i + 1); + const whole = heldLength === 0 ? tail : concatParts([...held, tail], heldLength + tail.length); + out.push(emit(whole.subarray(0, whole.length - 1), whole, true)); + held = []; + heldLength = 0; start = i + 1; } - pending = joined.slice(start); + if (start < chunk.length) { + held = [...held, chunk.slice(start)]; + heldLength += chunk.length - start; + } + pending = held; + pendingLength = heldLength; return out; } catch { - pending = new Uint8Array(0); + pending = []; + pendingLength = 0; passthrough = true; - return previous.length ? [previous, chunk] : [chunk]; + return [...previous, chunk]; } }, flush() { - if (pending.length === 0) return []; - const last = pending; - pending = new Uint8Array(0); - if (passthrough) return [last]; + if (pendingLength === 0) return []; + const parts = pending; + const last = concatParts(parts, pendingLength); + pending = []; + pendingLength = 0; + if (passthrough) return parts; try { return [emit(last, last, false)]; } catch { return [last]; } }, }; diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index b1d393cc3fc..0ae8dcfe8c7 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -65,9 +65,11 @@ export function unlockRateLimitGate(value: unknown): boolean { exhausted ||= r.exhausted; blocked ||= r.blocked; } - // The rate-limit flags are opened only when no workspace, credit or spend-control reason - // stands anywhere in this payload; with one present the flags stay as the server sent them. - if (!blocked && !payloadBlocked) { + // The rate-limit flags are opened only when the plain quota is the visible reason in this + // subtree and no workspace, credit or spend-control reason stands anywhere in the payload. + // A flag closed for a reason the payload does not show stays as the server sent it. + const plainQuotaShown = (cleared || exhausted) && !blocked && !payloadBlocked; + if (plainQuotaShown) { for (const key of ["rate_limit", "rateLimit"] as const) { const rateLimit = node[key]; if (!isRecord(rateLimit)) continue; @@ -86,7 +88,7 @@ export function unlockRateLimitGate(value: unknown): boolean { // `ordinaryUsageAllowed: false` is the same quota gate seen from the RPC side, and the only // field the app reads for it. Open it only when the plain quota is the visible reason and // nothing else still explains the block. - if (node.ordinaryUsageAllowed === false && (cleared || exhausted) && !blocked && !payloadBlocked) { + if (node.ordinaryUsageAllowed === false && plainQuotaShown) { node.ordinaryUsageAllowed = true; changed = true; } @@ -95,4 +97,3 @@ export function unlockRateLimitGate(value: unknown): boolean { visit(value); return changed; } - diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index a8710174920..23e2127ce2a 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -1,10 +1,42 @@ -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, constants, mkdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../../config/paths"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; +/** + * Where the app-server binary sits inside a ChatGPT bundle, newest layout first. The bundle root + * is discovered by identity (com.openai.codex), so an install in ~/Applications or on another + * volume resolves to its own binary instead of the conventional /Applications path. + */ +const BUNDLED_CODEX_LAYOUTS: readonly (readonly string[])[] = [ + ["Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"], + ["Contents", "Resources", "codex"], +]; + +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** The bundled app-server binary of a verified bundle root, or null when the bundle has none. */ +export function resolveChatgptCodexBinary( + bundleRoot: string, + isExecutable: (path: string) => boolean = isExecutableFile, +): string | null { + for (const layout of BUNDLED_CODEX_LAYOUTS) { + const candidate = join(bundleRoot, ...layout); + if (isExecutable(candidate)) return candidate; + } + return null; +} + export function chatgptShimLauncherPath(configDir = getConfigDir()): string { return join(configDir, "chatgpt-codex-shim.sh"); } @@ -32,11 +64,11 @@ exec "$REAL" "$@" `; } -export function writeChatgptShimLauncher(configDir = getConfigDir()): string { +export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string { mkdirSync(configDir, { recursive: true }); const path = chatgptShimLauncherPath(configDir); const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; - writeFileSync(path, buildChatgptShimLauncher(argv), { mode: 0o755 }); + writeFileSync(path, buildChatgptShimLauncher(argv, real), { mode: 0o755 }); chmodSync(path, 0o755); return path; } diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 06f1c5914ed..5bad3d8f278 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -1,7 +1,14 @@ import { spawnSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; import { loadConfig } from "../config"; -import { chatgptShimLauncherPath, writeChatgptShimLauncher } from "../chatgpt/app-server-shim/launcher"; +import { + chatgptShimLauncherPath, + resolveChatgptCodexBinary, + writeChatgptShimLauncher, +} from "../chatgpt/app-server-shim/launcher"; +import { darwinDefaultExec, darwinDesktopAppAdapter } from "../codex/desktop-app/darwin"; +import type { DesktopAppInstall } from "../codex/desktop-app/types"; const USAGE = `Usage (experimental, macOS only): ocx chatgpt launch Relaunch ChatGPT with the experimental app-server shim (requires appServerShim: true) @@ -14,16 +21,30 @@ function run(command: string, args: string[]) { } /** - * Only inspect the named bundle process; never print the environment being inspected. + * The installed app, found and confirmed by bundle identifier (com.openai.codex) the same way the + * desktop restart adapter does. "ChatGPT" is a display name another app can share, so quitting, + * relaunching and the binary path all key on this verified bundle rather than on the name. + */ +function discoverApp(): DesktopAppInstall | null { + try { + return darwinDesktopAppAdapter.discover(darwinDefaultExec); + } catch { + return null; + } +} + +/** + * Only inspect the verified bundle's own process; never print the environment being inspected. * `-a` keeps ancestors in the match: when ocx runs inside a ChatGPT/Codex session the app is * one of this process's ancestors, and plain `pgrep -x` would report it as not running. */ -function appState(launcher: string): { running: boolean; shim: boolean } { +function appState(install: DesktopAppInstall, launcher: string): { running: boolean; shim: boolean } { + const shell = join(install.root, "Contents", "MacOS", "ChatGPT"); const pids = run("pgrep", ["-a", "-x", "ChatGPT"]); if (!pids.ok) return { running: false, shim: false }; for (const pid of pids.output.split(/\s+/).filter(value => /^\d+$/.test(value))) { const command = run("ps", ["eww", "-o", "command=", "-p", pid]); - if (command.ok && command.output.includes("ChatGPT.app/Contents/MacOS/ChatGPT")) { + if (command.ok && (command.output === shell || command.output.startsWith(`${shell} `))) { const marker = `CODEX_CLI_PATH=${launcher}`; const start = command.output.indexOf(marker); return { running: true, shim: start >= 0 && (start === 0 || command.output[start - 1] === " ") @@ -34,16 +55,16 @@ function appState(launcher: string): { running: boolean; shim: boolean } { } /** Adapted from #5947: open ignores new launch settings until the previous app exits. */ -async function quitApp(launcher: string): Promise { - if (!appState(launcher).running) return true; +async function quitApp(install: DesktopAppInstall, launcher: string): Promise { + if (!appState(install, launcher).running) return true; for (let attempt = 0; attempt < 3; attempt++) { - run("osascript", ["-e", 'quit app "ChatGPT"']); + run("/usr/bin/osascript", ["-e", `quit app id "${install.id}"`]); for (let poll = 0; poll < 20; poll++) { - if (!appState(launcher).running) return true; + if (!appState(install, launcher).running) return true; await Bun.sleep(250); } } - return !appState(launcher).running; + return !appState(install, launcher).running; } export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise { @@ -63,29 +84,41 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat try { const config = loadConfig(); const launcher = chatgptShimLauncherPath(); + const install = discoverApp(); if (sub === "status") { - const app = appState(launcher); + const app = install ? appState(install, launcher) : { running: false, shim: false }; console.log(`app-server shim (experimental): ${config.chatgptDesktop?.appServerShim === true ? "on" : "off"} launcher: ${existsSync(launcher) ? "present" : "absent"} -app: ${app.running ? "running" : "not running"} +app: ${install ? (app.running ? "running" : "not running") : "not installed"} CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); return 0; } + if (!install) { + if (sub === "restore") rmSync(launcher, { force: true }); + console.error("ChatGPT (com.openai.codex) was not found; install or open it once, then retry."); + return 1; + } if (sub === "launch") { if (config.chatgptDesktop?.appServerShim !== true) { console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.'); return 1; } - writeChatgptShimLauncher(); + const binary = resolveChatgptCodexBinary(install.root); + if (!binary) { + console.error(`No bundled app-server binary was found in ${install.root}; the shim cannot launch this build.`); + return 1; + } + writeChatgptShimLauncher(undefined, binary); } - if (!(await quitApp(launcher))) { + if (!(await quitApp(install, launcher))) { console.error("ChatGPT did not quit; quit it manually and retry."); return 1; } // Remove an inherited override too: restore must launch without CODEX_CLI_PATH. const env = { ...process.env }; delete env.CODEX_CLI_PATH; - const result = spawnSync("open", ["-a", "ChatGPT", ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { + // Open the verified bundle by path, so the relaunch is the same app that was quit. + const result = spawnSync("/usr/bin/open", ["-a", install.root, ...(sub === "launch" ? ["--env", `CODEX_CLI_PATH=${launcher}`] : [])], { encoding: "utf8", env, timeout: 10000, }); if (result.status !== 0) throw new Error(result.error?.message ?? (result.stderr?.trim() || "open failed")); diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 3fd7a7ee34a..d26a723f72f 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -25,16 +25,23 @@ The filter's passthrough mode limits this to an exit/crash case. The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC rate-limit notifications and top-level rate-limit results. Workspace, credit, unknown reached-type and spend-control restrictions preserve closed gate flags. +Both the rate-limit flags and `ordinaryUsageAllowed` open only where the subtree shows +plain-quota evidence: a cleared plain reached type or a usage window at 100%. Usage percentages, resets and window durations remain accurate. Unrelated messages and malformed lines remain byte-identical; changed lines are reserialized. A per-line rewrite exception preserves that line. A failure in the framing/rewrite machinery preserves buffered bytes and switches the rest of the stream to raw passthrough. Output-write failures propagate; they are not rewrite failures. - -Launch writes a mode-0755 executable, waits for the old ChatGPT instance to quit, -then invokes open with the launcher in CODEX_CLI_PATH. Restore relaunches without +A partial line is held as a list of chunks and joined once at its newline, so a long +line split across many pipe reads costs linear copying. + +The app is discovered and confirmed by bundle identifier through +`darwinDesktopAppAdapter.discover` (`src/codex/desktop-app/darwin.ts`). Launch derives +the bundled app-server binary from that root (`resolveChatgptCodexBinary`) and refuses +when none exists, writes a mode-0755 executable, quits the bundle by id, waits for it +to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH. Restore relaunches without that override and removes the launcher only after open succeeds. Status reports -the experimental flag, launcher presence, and the named bundle process's override +the experimental flag, launcher presence, and the verified bundle process's override without printing its environment. Other platforms reject all three operations. The launcher and its executable paths are local code-execution inputs. This diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index a7379016f0a..5c2425a711e 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { buildChatgptShimLauncher, writeChatgptShimLauncher, chatgptShimLauncherPath } from "../../src/chatgpt/app-server-shim/launcher"; +import { + buildChatgptShimLauncher, + chatgptShimLauncherPath, + resolveChatgptCodexBinary, + writeChatgptShimLauncher, +} from "../../src/chatgpt/app-server-shim/launcher"; import { selfLaunchArgv } from "../../src/lib/self-launch-argv"; import { repoPath } from "../helpers/repo-root"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -47,6 +52,21 @@ describe("experimental app-server launcher", () => { if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o755); })); + test("the bundled binary is derived from the discovered bundle root, not the conventional path", () => { + const root = "/Users/example/Applications/ChatGPT.app"; + const current = `${root}/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`; + const legacy = `${root}/Contents/Resources/codex`; + expect(resolveChatgptCodexBinary(root, path => path === current || path === legacy)).toBe(current); + expect(resolveChatgptCodexBinary(root, path => path === legacy)).toBe(legacy); + expect(resolveChatgptCodexBinary(root, () => false)).toBeNull(); + }); + + test("the writer embeds the binary it is given", () => withDir(dir => { + const real = "/Volumes/Apps/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"; + const path = writeChatgptShimLauncher(dir, real); + expect(readFileSync(path, "utf8")).toContain(`REAL='${real}'`); + })); + // Stub uname activates the macOS precondition on Linux without requiring the actual app. const environment = (dir: string) => { executable(dir, "uname", 'echo Darwin'); diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index c2b8b850340..47046f1d093 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -83,6 +83,18 @@ describe("app-server line rewrite", () => { expect(rewriteAppServerLine(rpcResult({ ordinaryUsageAllowed: false, rateLimits: { primary: { usedPercent: 100 }, spendControlReached: { used: 5, limit: 5 } } }))).toBeNull(); }); + test("rate-limit flags closed without plain-quota evidence stay closed", () => { + expect(rewriteAppServerLine(rpcResult({ rateLimits: { rateLimit: { allowed: false }, primary: { usedPercent: 12 } } }))).toBeNull(); + const notification = JSON.stringify({ method: "account/rateLimits/updated", params: { rateLimits: { rateLimit: { allowed: false, limitReached: true } } } }); + expect(rewriteAppServerLine(notification)).toBeNull(); + }); + + test("rate-limit flags open when the plain quota is the visible reason", () => { + const out = JSON.parse(rewriteAppServerLine(rpcResult({ rateLimits: { rateLimit: { allowed: false, limitReached: true }, primary: { usedPercent: 100 } } }))!); + expect(out.result.rateLimits.rateLimit).toEqual({ allowed: true, limitReached: false }); + expect(out.result.rateLimits.primary.usedPercent).toBe(100); + }); + test("notifications carrying the same fields are rewritten too", () => { const line = JSON.stringify({ method: "account/rateLimits/updated", params: { rateLimits: { rateLimitReachedType: "rate_limit_reached" } } }); expect(JSON.parse(rewriteAppServerLine(line)!).params.rateLimits.rateLimitReachedType).toBeNull(); @@ -149,6 +161,16 @@ describe("app-server line filter", () => { expect(out.endsWith("\n")).toBe(false); expect(JSON.parse(out).result.ordinaryUsageAllowed).toBe(true); }); + + test("a long line spread over many chunks comes back whole, and a gate line after it is still rewritten", () => { + const long = JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "x".repeat(200_000) } }); + const bytes = enc(`${long}\n${rpcResult(EXHAUSTED_RATE_LIMITS)}\n`); + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += 1024) chunks.push(bytes.slice(i, i + 1024)); + const out = collect(chunks).split("\n"); + expect(out[0]).toBe(long); + expect(JSON.parse(out[1]!).result.ordinaryUsageAllowed).toBe(true); + }); }); describe("app-server stdout filter", () => { From 8006c0f52f3bc1bc32f462af85a0d9fb1077ff0d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 22:55:44 +0900 Subject: [PATCH 3/4] fix(chatgpt): trust only an OpenAI-signed bundle and harden the launcher Security review of #6361 at 506fb7503e: - Refuse a bundle or app-server binary owned by another user, writable by group or others, failing strict codesign, or signed by a team other than OpenAI (2DC432GLL2); match only this user's processes. - Write the launcher through an exclusive temp file and a rename, so a symbolic link at that path is replaced rather than followed and a respawn never sees a half-written script. - The launcher exits 127 with a stderr hint when the recorded binary is gone. - Stream lines over 8 MiB through raw instead of buffering them, and append held chunks in place. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- .../content/docs/guides/chatgpt-desktop.md | 13 ++++ src/chatgpt/app-server-shim/bundle-trust.ts | 77 +++++++++++++++++++ src/chatgpt/app-server-shim/filter.ts | 34 +++++++- src/chatgpt/app-server-shim/launcher.ts | 23 +++++- src/cli/chatgpt-command.ts | 10 ++- structure/clients/chatgpt-desktop.md | 13 +++- .../desktop-app-server-shim-launcher.test.ts | 54 ++++++++++++- tests/clients/desktop-app-server-shim.test.ts | 16 ++++ 8 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 src/chatgpt/app-server-shim/bundle-trust.ts diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 6071600c9d9..2a1b0a9d438 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -68,6 +68,14 @@ these executable paths changes code the app runs. The launcher still `exec`s the bundled binary of the discovered bundle; if that bundle has no app-server binary, `launch` refuses instead of writing a launcher. +Before writing the launcher, `launch` also checks that the bundle and its +app-server binary are owned by you or root, are not writable by group or others, +and pass strict code-signature verification under OpenAI's team ID +(`2DC432GLL2`). A bundle that fails any of these is refused, so a copy placed by +another account cannot be made to run inside your session. The launcher file is +written to a temporary file and renamed into place; an existing symbolic link at +that path is replaced, not followed. + This integration installs no certificate, network listener, PAC, or background watcher. It does not log the app's messages or environment. Status reports whether the running ChatGPT bundle process carries the expected launcher override. @@ -85,6 +93,11 @@ The experiment depends on the bundled binary path, the app honoring `CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved or removed OpenCodex installation fails the launcher preflight and runs the original binary. Run `ocx chatgpt launch` again after relocating the installation. +If an app update moves or removes the bundled app-server binary itself, the +launcher cannot start it: it prints a message naming `ocx chatgpt launch` and +`ocx chatgpt restore` on stderr and exits, and Desktop cannot start its +app-server until you run one of them. A single output line longer than 8 MiB is +passed through unparsed rather than buffered. This standalone shim does not rewrite conversation metadata or route model calls. Other app gates or upstream refusals can still prevent sending. Evidence reported diff --git a/src/chatgpt/app-server-shim/bundle-trust.ts b/src/chatgpt/app-server-shim/bundle-trust.ts new file mode 100644 index 00000000000..f00fce71e4c --- /dev/null +++ b/src/chatgpt/app-server-shim/bundle-trust.ts @@ -0,0 +1,77 @@ +import { spawnSync } from "node:child_process"; +import { lstatSync } from "node:fs"; +import { dirname, relative, sep } from "node:path"; + +/** + * The launcher persistently execs a binary taken from a discovered bundle, so the bundle must be + * one this user can trust: OpenAI-signed, and not replaceable by another local account. Bundle + * identifier alone (what discovery checks) is plain text in Info.plist that anyone can copy. + */ +export const OPENAI_TEAM_ID = "2DC432GLL2"; + +const CODESIGN = "/usr/bin/codesign"; +const CODESIGN_TIMEOUT_MS = 30_000; + +export interface BundleTrustDeps { + readonly uid: number; + stat(path: string): { uid: number; mode: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } | null; + codesign(args: readonly string[]): { status: number | null; output: string }; +} + +export function defaultBundleTrustDeps(): BundleTrustDeps { + return { + uid: typeof process.getuid === "function" ? process.getuid() : -1, + stat(path) { + try { + const s = lstatSync(path); + return { uid: s.uid, mode: s.mode, isFile: s.isFile(), isDirectory: s.isDirectory(), isSymbolicLink: s.isSymbolicLink() }; + } catch { + return null; + } + }, + codesign(args) { + const result = spawnSync(CODESIGN, [...args], { encoding: "utf8", timeout: CODESIGN_TIMEOUT_MS }); + return { status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` }; + }, + }; +} + +function ownedSafely(deps: BundleTrustDeps, path: string, kind: "file" | "directory"): string | null { + const s = deps.stat(path); + if (!s) return "is missing"; + if (s.isSymbolicLink) return "is a symbolic link"; + if (kind === "file" ? !s.isFile : !s.isDirectory) return `is not a ${kind}`; + if (s.uid !== 0 && s.uid !== deps.uid) return "is owned by another user"; + if ((s.mode & 0o022) !== 0) return "is writable by group or others"; + return null; +} + +/** + * Null when the bundle root and the app-server binary inside it may be exec'd by this user; + * otherwise a short reason. Every directory from the binary up to the bundle root, and the + * binary itself, must be owned by root or this user and not group/other-writable, and both the + * bundle and the binary must pass strict code-signature verification under OpenAI's team ID. + */ +export function untrustedChatgptBundleReason( + bundleRoot: string, + binary: string, + deps: BundleTrustDeps = defaultBundleTrustDeps(), +): string | null { + const inside = relative(bundleRoot, binary); + if (!inside || inside.startsWith("..") || inside.startsWith(sep)) return "the app-server binary is outside the bundle"; + const fileProblem = ownedSafely(deps, binary, "file"); + if (fileProblem) return `the app-server binary ${fileProblem}`; + for (let dir = dirname(binary); ; dir = dirname(dir)) { + const dirProblem = ownedSafely(deps, dir, "directory"); + if (dirProblem) return `${dir} ${dirProblem}`; + if (dir === bundleRoot || dirname(dir) === dir) break; + } + for (const target of [bundleRoot, binary]) { + if (deps.codesign(["--verify", "--strict", target]).status !== 0) return `${target} failed code-signature verification`; + const details = deps.codesign(["-dv", "--verbose=2", target]); + if (details.status !== 0 || !new RegExp(`^TeamIdentifier=${OPENAI_TEAM_ID}$`, "m").test(details.output)) { + return `${target} is not signed by OpenAI (team ${OPENAI_TEAM_ID})`; + } + } + return null; +} diff --git a/src/chatgpt/app-server-shim/filter.ts b/src/chatgpt/app-server-shim/filter.ts index de3b06ac605..f806c028d33 100644 --- a/src/chatgpt/app-server-shim/filter.ts +++ b/src/chatgpt/app-server-shim/filter.ts @@ -2,6 +2,11 @@ import { rewriteAppServerLine } from "./app-server-rewrite"; /** Byte-preserving stdout filter; unexpected rewrite machinery failures disable filtering. */ const NEWLINE = 0x0a; +/** + * Gate messages are a few kilobytes. A line longer than this is passed through raw instead of + * being buffered, so a runaway line without a newline cannot grow the filter's memory unbounded. + */ +export const MAX_FILTERED_LINE_BYTES = 8 * 1024 * 1024; function concatParts(parts: readonly Uint8Array[], length: number): Uint8Array { if (parts.length === 1) return parts[0]!; @@ -19,15 +24,19 @@ function concatParts(parts: readonly Uint8Array[], length: number): Uint8Array { * for each chunk; a partial trailing line is held back until its newline arrives (or `flush`). * The held-back pieces are kept as a list and joined once when the line ends, so a long line * spread over many pipe chunks costs linear copying rather than recopying its prefix per chunk. + * A line over `maxLineBytes` is emitted raw as it arrives and never parsed. */ export function createRpcLineFilter( rewrite: (line: string) => string | null = rewriteAppServerLine, + maxLineBytes = MAX_FILTERED_LINE_BYTES, ): { push(chunk: Uint8Array): Uint8Array[]; flush(): Uint8Array[] } { const decoder = new TextDecoder(); const encoder = new TextEncoder(); let pending: Uint8Array[] = []; let pendingLength = 0; let passthrough = false; + // Inside an oversized line: bytes pass through raw until its newline. + let skippingLine = false; // `line` excludes the newline, `whole` includes it when there is one. An untouched line is // returned as the very bytes that arrived; only a rewritten one is re-encoded. @@ -54,10 +63,17 @@ export function createRpcLineFilter( const previousLength = pendingLength; try { const out: Uint8Array[] = []; + let start = 0; + if (skippingLine) { + const end = chunk.indexOf(NEWLINE); + if (end === -1) return [chunk]; + out.push(chunk.subarray(0, end + 1)); + skippingLine = false; + start = end + 1; + } let held = previous; let heldLength = previousLength; - let start = 0; - for (let i = chunk.indexOf(NEWLINE); i !== -1; i = chunk.indexOf(NEWLINE, start)) { + for (let i = chunk.indexOf(NEWLINE, start); i !== -1; i = chunk.indexOf(NEWLINE, start)) { const tail = chunk.subarray(start, i + 1); const whole = heldLength === 0 ? tail : concatParts([...held, tail], heldLength + tail.length); out.push(emit(whole.subarray(0, whole.length - 1), whole, true)); @@ -66,8 +82,18 @@ export function createRpcLineFilter( start = i + 1; } if (start < chunk.length) { - held = [...held, chunk.slice(start)]; - heldLength += chunk.length - start; + const rest = chunk.slice(start); + if (heldLength + rest.length > maxLineBytes) { + // Too long to be a gate message: release what is held and stream the rest raw. + out.push(...held, rest); + held = []; + heldLength = 0; + skippingLine = true; + } else { + // Appending in place is safe: nothing after this point can throw back to `previous`. + held.push(rest); + heldLength += rest.length; + } } pending = held; pendingLength = heldLength; diff --git a/src/chatgpt/app-server-shim/launcher.ts b/src/chatgpt/app-server-shim/launcher.ts index 23e2127ce2a..629633373ef 100644 --- a/src/chatgpt/app-server-shim/launcher.ts +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -1,4 +1,5 @@ -import { accessSync, chmodSync, constants, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, constants, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; import { join } from "node:path"; import { getConfigDir } from "../../config/paths"; import { selfLaunchArgv } from "../../lib/self-launch-argv"; @@ -56,6 +57,10 @@ export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter. REAL=${shellQuote(real)} FILTER=(${argv.map(shellQuote).join(" ")}) +if [ ! -x "$REAL" ]; then + echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2 + exit 127 +fi if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\ && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then exec "$REAL" "$@" > >(exec "\${FILTER[@]}") @@ -64,11 +69,23 @@ exec "$REAL" "$@" `; } +/** + * Written to a fresh temp file (exclusive create, never through a link) and renamed over the + * launcher, so an existing symbolic link is replaced rather than followed and a respawn during + * the write never runs a half-written script. + */ export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string { mkdirSync(configDir, { recursive: true }); const path = chatgptShimLauncherPath(configDir); const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])]; - writeFileSync(path, buildChatgptShimLauncher(argv, real), { mode: 0o755 }); - chmodSync(path, 0o755); + const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" }); + chmodSync(temp, 0o755); + renameSync(temp, path); + } catch (error) { + try { unlinkSync(temp); } catch { /* the temp file was never created */ } + throw error; + } return path; } diff --git a/src/cli/chatgpt-command.ts b/src/cli/chatgpt-command.ts index 5bad3d8f278..de73d6b8f99 100644 --- a/src/cli/chatgpt-command.ts +++ b/src/cli/chatgpt-command.ts @@ -7,6 +7,7 @@ import { resolveChatgptCodexBinary, writeChatgptShimLauncher, } from "../chatgpt/app-server-shim/launcher"; +import { untrustedChatgptBundleReason } from "../chatgpt/app-server-shim/bundle-trust"; import { darwinDefaultExec, darwinDesktopAppAdapter } from "../codex/desktop-app/darwin"; import type { DesktopAppInstall } from "../codex/desktop-app/types"; @@ -40,7 +41,9 @@ function discoverApp(): DesktopAppInstall | null { */ function appState(install: DesktopAppInstall, launcher: string): { running: boolean; shim: boolean } { const shell = join(install.root, "Contents", "MacOS", "ChatGPT"); - const pids = run("pgrep", ["-a", "-x", "ChatGPT"]); + // Only this user's processes: another account's ChatGPT can neither be quit nor relaunched here. + const uid = typeof process.getuid === "function" ? process.getuid() : undefined; + const pids = run("pgrep", [...(uid === undefined ? [] : ["-U", String(uid)]), "-a", "-x", "ChatGPT"]); if (!pids.ok) return { running: false, shim: false }; for (const pid of pids.output.split(/\s+/).filter(value => /^\d+$/.test(value))) { const command = run("ps", ["eww", "-o", "command=", "-p", pid]); @@ -108,6 +111,11 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`); console.error(`No bundled app-server binary was found in ${install.root}; the shim cannot launch this build.`); return 1; } + const untrusted = untrustedChatgptBundleReason(install.root, binary); + if (untrusted) { + console.error(`Refusing to launch the shim: ${untrusted}.`); + return 1; + } writeChatgptShimLauncher(undefined, binary); } if (!(await quitApp(install, launcher))) { diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index d26a723f72f..32305d6d4dd 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -34,12 +34,21 @@ machinery preserves buffered bytes and switches the rest of the stream to raw passthrough. Output-write failures propagate; they are not rewrite failures. A partial line is held as a list of chunks and joined once at its newline, so a long line split across many pipe reads costs linear copying. +A line longer than `MAX_FILTERED_LINE_BYTES` (8 MiB) is never buffered or parsed: the +held bytes and the rest of that line stream through raw, and filtering resumes after +its newline. The app is discovered and confirmed by bundle identifier through `darwinDesktopAppAdapter.discover` (`src/codex/desktop-app/darwin.ts`). Launch derives the bundled app-server binary from that root (`resolveChatgptCodexBinary`) and refuses -when none exists, writes a mode-0755 executable, quits the bundle by id, waits for it -to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH. Restore relaunches without +when none exists or when `untrustedChatgptBundleReason` +(`src/chatgpt/app-server-shim/bundle-trust.ts`) reports the bundle or binary as owned +by another user, group/other-writable, unsigned, or signed by a team other than +OpenAI's. It writes the mode-0755 executable through an exclusive temp file and a +rename (never through a symbolic link), quits the bundle by id, waits for this user's +instance to exit, then opens the same bundle path with the launcher in CODEX_CLI_PATH. +The launcher itself exits 127 with a stderr hint when the recorded binary is gone. +Restore relaunches without that override and removes the launcher only after open succeeds. Status reports the experimental flag, launcher presence, and the verified bundle process's override without printing its environment. Other platforms reject all three operations. diff --git a/tests/clients/desktop-app-server-shim-launcher.test.ts b/tests/clients/desktop-app-server-shim-launcher.test.ts index 5c2425a711e..4baaaa1d9fe 100644 --- a/tests/clients/desktop-app-server-shim-launcher.test.ts +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { OPENAI_TEAM_ID, untrustedChatgptBundleReason, type BundleTrustDeps } from "../../src/chatgpt/app-server-shim/bundle-trust"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -67,6 +68,26 @@ describe("experimental app-server launcher", () => { expect(readFileSync(path, "utf8")).toContain(`REAL='${real}'`); })); + const linkTest = process.platform === "win32" ? test.skip : test; + linkTest("the writer replaces a symbolic link at the launcher path instead of following it", () => withDir(dir => { + const victim = join(dir, "victim.txt"); + writeFileSync(victim, "keep", { mode: 0o600 }); + symlinkSync(victim, chatgptShimLauncherPath(dir)); + const path = writeChatgptShimLauncher(dir); + expect(lstatSync(path).isSymbolicLink()).toBe(false); + expect(readFileSync(victim, "utf8")).toBe("keep"); + expect(statSync(victim).mode & 0o777).toBe(0o600); + expect(readdirSync(dir).filter(name => name.endsWith(".tmp"))).toEqual([]); + })); + + test("a launcher whose bundled binary disappeared says so and exits", () => withDir(dir => { + if (!bashAvailable) return; + const launcher = executable(dir, "launcher.sh", buildChatgptShimLauncher([join(dir, "filter")], join(dir, "gone"))); + const out = spawnSync("/bin/bash", [launcher], { encoding: "utf8", timeout: 5000 }); + expect(out.status).toBe(127); + expect(out.stderr).toContain("ocx chatgpt restore"); + })); + // Stub uname activates the macOS precondition on Linux without requiring the actual app. const environment = (dir: string) => { executable(dir, "uname", 'echo Darwin'); @@ -110,3 +131,34 @@ describe("experimental app-server launcher", () => { expect(out.status).toBe(23); })); }); + +describe("bundle trust before the launcher is written", () => { + const root = "/Applications/ChatGPT.app"; + const binary = `${root}/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex`; + const deps = (over: Partial<{ owner: number; mode: number; team: string; verify: number }> = {}): BundleTrustDeps => ({ + uid: 501, + stat: path => ({ + uid: over.owner ?? 501, + mode: over.mode ?? 0o755, + isFile: path === binary, + isDirectory: path !== binary, + isSymbolicLink: false, + }), + codesign: args => args[0] === "--verify" + ? { status: over.verify ?? 0, output: "" } + : { status: 0, output: `Identifier=codex\nTeamIdentifier=${over.team ?? OPENAI_TEAM_ID}\n` }, + }); + + test("an OpenAI-signed bundle owned by this user is trusted", () => { + expect(untrustedChatgptBundleReason(root, binary, deps())).toBeNull(); + expect(untrustedChatgptBundleReason(root, binary, deps({ owner: 0 }))).toBeNull(); + }); + + test("another user's, writable, unsigned or foreign-signed bundles are refused", () => { + expect(untrustedChatgptBundleReason(root, binary, deps({ owner: 502 }))).toContain("another user"); + expect(untrustedChatgptBundleReason(root, binary, deps({ mode: 0o777 }))).toContain("writable"); + expect(untrustedChatgptBundleReason(root, binary, deps({ verify: 1 }))).toContain("code-signature"); + expect(untrustedChatgptBundleReason(root, binary, deps({ team: "ABCDE12345" }))).toContain("not signed by OpenAI"); + expect(untrustedChatgptBundleReason(root, "/tmp/codex", deps())).toContain("outside the bundle"); + }); +}); diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index 47046f1d093..1c50bcfa120 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -171,6 +171,22 @@ describe("app-server line filter", () => { expect(out[0]).toBe(long); expect(JSON.parse(out[1]!).result.ordinaryUsageAllowed).toBe(true); }); + + test("a line over the cap streams through raw, never parsed, and filtering resumes after its newline", () => { + const seen: string[] = []; + const rewrite = (line: string) => { + seen.push(line); + return rewriteAppServerLine(line); + }; + const oversized = rpcResult({ ...EXHAUSTED_RATE_LIMITS, padding: "y".repeat(500) }); + const bytes = enc(`${oversized}\n${rpcResult(EXHAUSTED_RATE_LIMITS)}\n`); + const chunks: Uint8Array[] = []; + for (let i = 0; i < bytes.length; i += 64) chunks.push(bytes.slice(i, i + 64)); + const out = collect(chunks, createRpcLineFilter(rewrite, 256)).split("\n"); + expect(out[0]).toBe(oversized); + expect(JSON.parse(out[1]!).result.ordinaryUsageAllowed).toBe(true); + expect(seen.some(line => line.includes("padding"))).toBe(false); + }); }); describe("app-server stdout filter", () => { From 4abf376a68c7bd8c73bc34ee8be19bf35ffda9a2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 2 Oct 2026 00:16:18 +0900 Subject: [PATCH 4/4] test(chatgpt): set the line-cap fixture between the gate line and the padded one --- tests/clients/desktop-app-server-shim.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index 1c50bcfa120..7d2f554c8c7 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -172,17 +172,18 @@ describe("app-server line filter", () => { expect(JSON.parse(out[1]!).result.ordinaryUsageAllowed).toBe(true); }); + // The cap sits between the ordinary gate line (~320 bytes) and the padded one (~2.3 KB). test("a line over the cap streams through raw, never parsed, and filtering resumes after its newline", () => { const seen: string[] = []; const rewrite = (line: string) => { seen.push(line); return rewriteAppServerLine(line); }; - const oversized = rpcResult({ ...EXHAUSTED_RATE_LIMITS, padding: "y".repeat(500) }); + const oversized = rpcResult({ ...EXHAUSTED_RATE_LIMITS, padding: "y".repeat(2000) }); const bytes = enc(`${oversized}\n${rpcResult(EXHAUSTED_RATE_LIMITS)}\n`); const chunks: Uint8Array[] = []; for (let i = 0; i < bytes.length; i += 64) chunks.push(bytes.slice(i, i + 64)); - const out = collect(chunks, createRpcLineFilter(rewrite, 256)).split("\n"); + const out = collect(chunks, createRpcLineFilter(rewrite, 1024)).split("\n"); expect(out[0]).toBe(oversized); expect(JSON.parse(out[1]!).result.ordinaryUsageAllowed).toBe(true); expect(seen.some(line => line.includes("padding"))).toBe(false);