Skip to content
1 change: 1 addition & 0 deletions docs-site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
105 changes: 105 additions & 0 deletions docs-site/src/content/docs/guides/chatgpt-desktop.md
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

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.
Comment thread
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.
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions src/chatgpt/app-server-shim/app-server-rewrite.ts
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;
}
77 changes: 77 additions & 0 deletions src/chatgpt/app-server-shim/bundle-trust.ts
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;
}
Loading
Loading