Repository navigation
feat(chatgpt): local-CA send-unblock intercept, stacked on #6361 (split from #5947) #6365
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
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
4e0e8f4
feat(chatgpt): add experimental macOS app-server quota-gate shim
lidge-jun 98194c3
feat(chatgpt): carry the local-CA send-unblock intercept (split from …
lidge-jun 51a92cf
docs(structure): keep runtime.md within its 600-line budget
lidge-jun f89bffc
perf(chatgpt): load the send-unblock intercept only when it is enabled
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,179 @@ | ||
| --- | ||
| title: ChatGPT Desktop integrations (experimental) | ||
| description: Opt-in macOS app-server shim and local-CA TLS intercept experiments for plain-quota send gates. | ||
| --- | ||
|
|
||
| This experiment is **macOS only and off by default**. It filters the bundled ChatGPT | ||
| app-server's JSON-RPC stdout to open known plain-quota gates. It does not increase | ||
| an account's quota or make an upstream service accept a request it refuses. | ||
|
|
||
| Enable it in your OpenCodex `config.json`: | ||
|
|
||
| ```json | ||
| { | ||
| "chatgptDesktop": { "appServerShim": true } | ||
| } | ||
| ``` | ||
|
|
||
| Then run: | ||
|
|
||
| ```bash | ||
| ocx chatgpt launch | ||
| ocx chatgpt status | ||
| ``` | ||
|
|
||
| `launch` creates an executable launcher under the OpenCodex config directory, | ||
| quits ChatGPT if it is running, and relaunches it with | ||
| `open -a ChatGPT --env CODEX_CLI_PATH=<launcher>`. Save ongoing work first: this | ||
| restarts the app. It does not require a running OpenCodex proxy. | ||
|
|
||
| To remove the launcher and relaunch without the override: | ||
|
|
||
| ```bash | ||
| ocx chatgpt restore | ||
| ``` | ||
|
|
||
| Restore leaves the config flag as configured. Set `chatgptDesktop.appServerShim` | ||
| to `false` or remove it to disable future explicit shim launches. Normal launches | ||
| from Dock or Spotlight do not apply the shim automatically. | ||
|
|
||
| ## Rewrite boundary | ||
|
|
||
| Only `account/rateLimits/updated` notifications and responses whose top-level | ||
| result contains `rateLimits`, `rateLimitsByLimitId`, or `ordinaryUsageAllowed` | ||
| are eligible. Plain `rate_limit_reached` markers are cleared; known quota gate | ||
| flags (`allowed`, `limit_reached` / `limitReached`, `ordinaryUsageAllowed`) are | ||
| opened where the payload permits it. `ordinaryUsageAllowed` requires plain-quota | ||
| exhaustion evidence. Workspace, credit, unknown reached-type and spend-control | ||
| restrictions keep the usage gate closed. | ||
|
|
||
| Displayed usage stays honest: percentages, reset times, window durations, plan | ||
| information and other display fields stay as received. Unrelated JSON-RPC | ||
| messages, nested tool output, conversation send-block metadata and malformed | ||
| lines pass through. Only changed lines are serialized again; other bytes retain | ||
| their original encoding and line endings. Stdin, stderr and the real binary's | ||
| exit status retain their direct connection to the app. | ||
|
|
||
| ## Executable and environment security | ||
|
|
||
| The generated launcher has mode `0755` and embeds the current OpenCodex executable | ||
| and, for source installs, the CLI entry path. `CODEX_CLI_PATH` tells ChatGPT to | ||
| execute this launcher instead of its bundled binary directly. Keep the launcher, | ||
| its config directory, and the OpenCodex installation under your control: changing | ||
| these executable paths changes code the app runs. The launcher still `exec`s the | ||
| bundled binary at its standard `/Applications/ChatGPT.app` location. | ||
|
|
||
| The app-server shim alone installs no certificate, network listener, or background | ||
| watcher. It does not log the app's messages or environment. Status reports whether | ||
| the running ChatGPT bundle process carries the expected launcher override. | ||
|
|
||
| ## Failure behavior and known limits | ||
|
|
||
| A failed precondition or a failed self-test runs the original binary with untouched stdout. | ||
| A filter that passes the self-test and then dies mid-session closes the pipe. | ||
| Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a write error and Desktop respawns it through the same launcher. | ||
| The filter's passthrough mode limits this to an exit/crash case: a rewrite exception | ||
| passes its line through, and an unexpected rewrite-machinery failure switches the | ||
| remaining stream to raw bytes. | ||
|
|
||
| The experiment depends on the bundled binary path, the app honoring | ||
| `CODEX_CLI_PATH`, and current RPC field shapes. Updates may change these. A moved | ||
| or removed OpenCodex installation fails the launcher preflight and runs the | ||
| original binary. Run `ocx chatgpt launch` again after relocating the installation. | ||
|
|
||
| This standalone shim does not rewrite conversation metadata or route model calls. | ||
| Other app gates or upstream refusals can still prevent sending. Evidence reported | ||
| on an exhausted Plus account also used an intercept, so it does not establish | ||
| that this shim alone resolves every desktop send lock. | ||
|
|
||
| ## Local-CA TLS intercept (experimental candidate) | ||
|
|
||
| The separate `chatgptDesktop.unblockSend` experiment terminates TLS for the | ||
| `chatgpt.com` apex host on loopback, relays the account's cookies and credentials, | ||
| and rewrites known quota send gates in conversation metadata and usage responses. | ||
| It is **off by default**, macOS only, and independent of `appServerShim`: | ||
|
|
||
| ```json | ||
| { | ||
| "chatgptDesktop": { | ||
| "unblockSend": true, | ||
| "port": 10300 | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `port` is optional; its default is the running proxy's public port plus 200 | ||
| (`10100` → `10300`). A derived port outside the TCP range requires an explicit | ||
| free port. Client-role processes do not start the intercept. A bind or certificate | ||
| failure warns without stopping the proxy's other services. | ||
|
|
||
| Start OpenCodex with this config, then run `ocx chatgpt status`. The listener | ||
| creates or reuses the local authority shared with the Claude intercept. **You | ||
| must trust this CA yourself** in the macOS login keychain before launching the | ||
| intercepted app. Status prints the exact command; with the default config path: | ||
|
|
||
| ```bash | ||
| security add-trusted-cert -r trustRoot -p ssl -k "$HOME/Library/Keychains/login.keychain-db" "$HOME/.opencodex/claude-intercept/ca.pem" | ||
| ocx chatgpt launch | ||
| ocx chatgpt status | ||
| ``` | ||
|
|
||
| Use the certificate path reported by status if your OpenCodex home differs. The | ||
| CLI prints the trust command and never runs it. Trusting a local CA changes the | ||
| login keychain's TLS trust: anyone controlling its private key can issue trusted | ||
| certificates. The listener sees the decrypted account traffic, including cookies, | ||
| authorization headers and message content that it relays. Protect the config | ||
| directory and CA key. The relay does not log request bodies or credentials. | ||
| `restore` removes launch overrides; it does **not** remove CA trust or delete the | ||
| shared authority. Remove trust manually through Keychain Access when you no | ||
| longer need it, accounting for other integrations using the same authority. | ||
|
|
||
| Launch restarts ChatGPT with | ||
| `--host-resolver-rules=MAP chatgpt.com 127.0.0.1:<port>`. Explicit system HTTP/SOCKS | ||
| proxies get an apex-host bypass while other hosts retain the proxy with a direct | ||
| fallback; TUN/direct networking needs only the resolver rule. An existing system | ||
| PAC cannot be combined with that bypass, so it may prevent the intercept from | ||
| seeing traffic. This candidate creates no PAC file or CONNECT entry proxy. | ||
|
|
||
| If both flags are true, `ocx chatgpt launch` applies the existing app-server shim | ||
| and the intercept together. The shim alone still works without a running proxy. | ||
| The intercept requires OpenCodex's identity-confirmed listener. When OpenCodex | ||
| stops, an app still carrying the resolver rule cannot reach `chatgpt.com`; run | ||
| `ocx chatgpt restore` to relaunch with native networking. | ||
|
|
||
| ### Intercept rewrite boundary | ||
|
|
||
| Only `/backend-api/conversation/init`, `/backend-api/conversation` and | ||
| `/backend-api/f/conversation` (including child paths), plus the exact | ||
| `/backend-api/wham/usage` and `/backend-api/wham/usage/stream` paths are rewritten. | ||
| Conversation metadata loses known quota `send` / `tpp_send` blocks and exhausted | ||
| send progress entries. Unknown and subscription/policy/workspace reasons remain; | ||
| status reports preserved reasons. Usage rewriting reuses the shim's gate helpers, | ||
| keeping workspace, credit and spend-control gates and usage display intact. Other | ||
| HTTP responses pass through; WebSocket upgrades, voice and dictation relay | ||
| without rewriting through direct, HTTP CONNECT or shared SOCKS5 transport. | ||
|
|
||
| ### Optional intercept launch watcher | ||
|
|
||
| ```bash | ||
| ocx chatgpt install-watcher --yes | ||
| ocx chatgpt uninstall-watcher | ||
| ocx chatgpt restore | ||
| ``` | ||
|
|
||
| Installation without `--yes` asks in an interactive terminal. The launchd agent | ||
| watches the app's Electron `SingletonLock` and restarts an app launched without | ||
| intercept switches while the listener answers as OpenCodex. This can interrupt | ||
| startup work; it does nothing while the listener is unavailable. It manages | ||
| **intercept launches only**; use explicit launch for the app-server shim. A loaded | ||
| watcher must be uninstalled before `restore`, so it cannot put the switches back. | ||
|
|
||
| ### Evidence and decision limits | ||
|
|
||
| [#6196](https://github.com/lidge-jun/opencodex/issues/6196) reported zero established | ||
| listener connections over about 20 hours on current Desktop builds: the bundled | ||
| app-server performs the gate reads and may bypass Chromium's resolver rule. | ||
| The later exhausted-Plus-account report used the shim, intercept and restart | ||
| together and does not isolate the intercept's effectiveness. This candidate | ||
| conflicts with the maintainer's provider-aware admission design, which rejects | ||
| local CA installation and quota-data rewriting; maintainers may close it. | ||
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,101 @@ | ||
| import { rewriteAppServerLine } from "./app-server-rewrite"; | ||
|
|
||
| /** Byte-preserving stdout filter; unexpected rewrite machinery failures disable filtering. */ | ||
| const NEWLINE = 0x0a; | ||
|
|
||
| /** | ||
| * Splits a byte stream into lines and rewrites the ones that need it. Returns the bytes to write | ||
| * for each chunk; a partial trailing line is held back until its newline arrives (or `flush`). | ||
| */ | ||
| export function createRpcLineFilter( | ||
| rewrite: (line: string) => string | null = rewriteAppServerLine, | ||
| ): { push(chunk: Uint8Array): Uint8Array[]; flush(): Uint8Array[] } { | ||
| const decoder = new TextDecoder(); | ||
| const encoder = new TextEncoder(); | ||
| let pending: Uint8Array = new Uint8Array(0); | ||
| let passthrough = false; | ||
|
|
||
| // `line` excludes the newline, `whole` includes it when there is one. An untouched line is | ||
| // returned as the very bytes that arrived; only a rewritten one is re-encoded. | ||
| const emit = (line: Uint8Array, whole: Uint8Array, terminated: boolean): Uint8Array => { | ||
| let rewritten: string | null; | ||
| try { | ||
| rewritten = rewrite(decoder.decode(line)); | ||
| } catch { | ||
| return whole; | ||
| } | ||
| if (rewritten === null) return whole; | ||
| const body = encoder.encode(rewritten); | ||
| if (!terminated) return body; | ||
| const out = new Uint8Array(body.length + 1); | ||
| out.set(body, 0); | ||
| out[body.length] = NEWLINE; | ||
| return out; | ||
| }; | ||
|
|
||
| return { | ||
| push(chunk) { | ||
| if (passthrough) return [chunk]; | ||
| const previous = pending; | ||
| try { | ||
| const joined = new Uint8Array(previous.length + chunk.length); | ||
| joined.set(previous, 0); | ||
| joined.set(chunk, previous.length); | ||
| const out: Uint8Array[] = []; | ||
| let start = 0; | ||
| for (let i = 0; i < joined.length; i++) { | ||
| if (joined[i] !== NEWLINE) continue; | ||
| out.push(emit(joined.subarray(start, i), joined.subarray(start, i + 1), true)); | ||
| start = i + 1; | ||
| } | ||
| pending = joined.slice(start); | ||
| return out; | ||
| } catch { | ||
| pending = new Uint8Array(0); | ||
| passthrough = true; | ||
| return previous.length ? [previous, chunk] : [chunk]; | ||
| } | ||
| }, | ||
| flush() { | ||
| if (pending.length === 0) return []; | ||
| const last = pending; | ||
| pending = new Uint8Array(0); | ||
| if (passthrough) return [last]; | ||
| try { return [emit(last, last, false)]; } catch { return [last]; } | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** Copy `input` to `write`, rewriting gate lines on the way. Resolves when `input` ends. */ | ||
| export async function runStdoutFilter( | ||
| input: AsyncIterable<Uint8Array>, | ||
| write: (bytes: Uint8Array) => Promise<unknown> | unknown, | ||
| rewrite?: (line: string) => string | null, | ||
| ): Promise<void> { | ||
| let filter: ReturnType<typeof createRpcLineFilter> | 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<number> { | ||
| 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; | ||
| } |
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the evidence paragraph so it does not present the 20-hour zero-connection result as current.
Lines 173-176 say that
#6196"reported zero established listener connections over about 20 hours on current Desktop builds". They also say the bundled app-server "may bypass Chromium's resolver rule".Later evidence on
#6196, quoted in the PR discussion, shows a different picture. A no-shim run on the same Desktop build had a usable Send button. It also showed Chromium network-service connections to the local intercept. The reviewer called the earlier zero-connection result historical. The reviewer also noted that port and GUI observations do not prove a routed turn was submitted, or that the intercept was necessary.The path instruction for
docs-site/**says user-facing docs must "stay in sync with actual CLI/API behavior". This guide is the only page a user reads before installing a trusted root CA. It currently gives an outdated and stronger negative claim than the evidence supports.Reword the paragraph to describe both observations and their limits:
Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions