Repository navigation
feat(chatgpt): experimental macOS app-server quota-gate shim (split from #5947) #6361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
4e0e8f4
feat(chatgpt): add experimental macOS app-server quota-gate shim
lidge-jun 6c41639
Merge remote-tracking branch 'origin/dev' into codex/chatgpt-app-serv…
lidge-jun 506fb75
fix(chatgpt): key the app-server shim on the verified bundle and tigh…
lidge-jun 8006c0f
fix(chatgpt): trust only an OpenAI-signed bundle and harden the launcher
lidge-jun 8d222f6
Merge remote-tracking branch 'origin/dev' into codex/chatgpt-app-serv…
lidge-jun 4abf376
test(chatgpt): set the line-cap fixture between the gate line and the…
lidge-jun ccb52b6
Merge remote-tracking branch 'origin/dev' into codex/chatgpt-app-serv…
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <bundle> --env CODEX_CLI_PATH=<launcher>`. 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. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown> { | ||
| 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<string, unknown> | 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.