diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 5355837f5e3..568c060ad93 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..2a1b0a9d438 --- /dev/null +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -0,0 +1,105 @@ +--- +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 --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: + +```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 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 +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 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. + +## 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. +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 +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 9a0eaea95ba..fcc5ecacb98 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", "owner-registry-acl.test.ts": "config", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index c1772b5f25d..b99396e33f8 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. @@ -1254,6 +1264,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 69 -- of those, state-changing: 39 +- declared capabilities: 70 +- of those, state-changing: 40 - 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/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 new file mode 100644 index 00000000000..f806c028d33 --- /dev/null +++ b/src/chatgpt/app-server-shim/filter.ts @@ -0,0 +1,152 @@ +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]!; + 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. + * 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. + 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; + 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; + 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)); + held = []; + heldLength = 0; + start = i + 1; + } + if (start < chunk.length) { + 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; + return out; + } catch { + pending = []; + pendingLength = 0; + passthrough = true; + return [...previous, chunk]; + } + }, + flush() { + 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]; } + }, + }; +} + +/** 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..0ae8dcfe8c7 --- /dev/null +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -0,0 +1,99 @@ +/** 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 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; + 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 && plainQuotaShown) { + 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..629633373ef --- /dev/null +++ b/src/chatgpt/app-server-shim/launcher.ts @@ -0,0 +1,91 @@ +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"; + +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"); +} + +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 [ ! -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[@]}") +fi +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"])]; + 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/capabilities.ts b/src/cli/capabilities.ts index 2062ba0523b..4f311f7d4c1 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..de73d6b8f99 --- /dev/null +++ b/src/cli/chatgpt-command.ts @@ -0,0 +1,140 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { loadConfig } from "../config"; +import { + chatgptShimLauncherPath, + 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"; + +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() }; +} + +/** + * 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(install: DesktopAppInstall, launcher: string): { running: boolean; shim: boolean } { + const shell = join(install.root, "Contents", "MacOS", "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]); + 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] === " ") + && (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(install: DesktopAppInstall, launcher: string): Promise { + if (!appState(install, launcher).running) return true; + for (let attempt = 0; attempt < 3; attempt++) { + run("/usr/bin/osascript", ["-e", `quit app id "${install.id}"`]); + for (let poll = 0; poll < 20; poll++) { + if (!appState(install, launcher).running) return true; + await Bun.sleep(250); + } + } + return !appState(install, 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(); + const install = discoverApp(); + if (sub === "status") { + 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: ${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; + } + 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; + } + 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))) { + 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; + // 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")); + 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 dd135d90360..0ab9897d829 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 97828712620..815e336a04a 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 5ddc23714dd..57565dce129 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -80,6 +80,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. | @@ -116,6 +117,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..32305d6d4dd --- /dev/null +++ b/structure/clients/chatgpt-desktop.md @@ -0,0 +1,63 @@ +# 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. +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. +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 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. + +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 50a533d6715..7f416405eed 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 6e2243ae8d8..74de2a50575 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -448,6 +448,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 4245fcad4a0..2dc508d1adb 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..4baaaa1d9fe --- /dev/null +++ b/tests/clients/desktop-app-server-shim-launcher.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +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"; +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"; + +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); + })); + + 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}'`); + })); + + 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'); + 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); + })); +}); + +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 new file mode 100644 index 00000000000..7d2f554c8c7 --- /dev/null +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -0,0 +1,231 @@ +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("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(); + }); + + 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); + }); + + 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); + }); + + // 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(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, 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); + }); +}); + +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 2f53a09d958..7f4e51e38b4 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", "owner-registry-acl.test.ts": "config", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration",