Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ 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.

If app discovery or process inspection fails, `status` reports `unknown` rather
than `not running`. `launch` and `restore` refuse to reopen an app whose exit
cannot be confirmed; restore keeps the launcher. Resolve the inspection error
and retry. Launch checks before writing its launcher, but a later inspection can
still fail after that atomic update; the commands do not guarantee an all-or-nothing
restart transaction.

## Failure behavior and known limits

When the platform is not macOS, the OpenCodex runtime is missing, or the filter
Expand Down
18 changes: 18 additions & 0 deletions docs-site/src/content/docs/reference/codex-retry-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ waiting visible: those items can contaminate history or imply work happened.
Do not shorten the provider delay or provoke an extra request to advance the
client's retry counter.

## Triage: `exceeded retry limit, last status: 429 Too Many Requests`

The message is the client's retry budget summary, not a diagnosis. One refusal
that the client does not retransmit can end a turn in the same words as a
fully exhausted budget, so treat the text as a question. Three cases look
identical at this layer and are checked differently.

| Check | Distinguishing evidence | If the check is positive |
| --- | --- | --- |
| Main-account hard lock (local admission policy) | The dashboard Settings or `GET /api/settings` reports the policy state, such as `mainAccountHardLock.state` being `blocked`, `ready`, or `off`, with `thresholds` and an optional `resetAt`. A non-streaming request may also carry an explicit main-policy body instead of a bare upstream 429. | Refresh usage with `ocx account refresh openai`; fresh usage below every blocking threshold or validated window absence releases the lock. If it remains blocked, wait for fresh lower usage or explicitly turn `codexMainAccountHardLock` off in Settings. Builds before [#6287](https://github.com/lidge-jun/opencodex/pull/6287) could keep an obsolete short-window block across restarts when no fresh observation ever replaced it; the release semantics are described in the [closing note on #6244](https://github.com/lidge-jun/opencodex/issues/6244). |
| Upstream ChatGPT plan window | The account quota section shows a 5-hour or weekly window at or near its limit while the lock reports `ready` or `off`. | Wait for the window to reset, inspect any reset option ChatGPT offers ([banked reset rules](https://help.openai.com/en/articles/20001498-how-banked-codex-resets-work), [plan windows](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan)), or serve the request on another account with `ocx account use openai <id>`. For explicitly authorized paid-credit use after the limit, see [account credit policy](/reference/cli/providers-accounts/#pool-policy-account-thresholds-and-paid-credit-intent). The credit-balance rediscovery defect in [#6845](https://github.com/lidge-jun/opencodex/issues/6845) was fixed by [#6886](https://github.com/lidge-jun/opencodex/pull/6886). |
| Provider incident or transport failure | Quota stays low, the lock stays released, and `ocx doctor` reports transport or ChatGPT reachability problems. | Retry after the incident clears or reroute through an existing combo. Changing lock or threshold policy is not a cure for a transport failure. |

A locked main credential closes request admission; it does not reserve the
account's remaining quota, and a predicted reset never releases the block on
its own. The thresholds (by default `short` 90, `long` 98) are visible in the
status payload and configurable through `codexMainAccountHardLockThresholds`.

## Why the first reconnect row can be absent

The public Codex `rust-v0.158.0-alpha.2.1` source gates the ordinary stream-retry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ SOCKS5 (Clash mixed-port listeners included) belongs on `ALL_PROXY`, not `HTTP_P

`ocx start --socks5` writes that value; `ocx start --socks5-off` clears it.

HTTPS requests through SOCKS5 have a separate 30-second TLS handshake deadline.
A stalled handshake fails without disabling certificate verification; caller
cancellation can stop it sooner.

If an older development build changed resume-history metadata before backup support existed, run
`ocx recover-history --legacy-openai --yes` to force native-provider recovery.
It force-relabels every user-message `opencodex` row, including legitimate dedicated-provider
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1728,7 +1728,7 @@
"sidecar-tracker.test.ts": "vision",
"skill-ocx.test.ts": "ci-workflows",
"slug-codec.test.ts": "codex-integration",
"socks5-fetch.test.ts": "lib",
"socks5-fetch.test.ts": "lib", "socks5-tls-lifecycle.test.ts": "lib",
"socks5-upload-lifecycle.test.ts": "lib",
"spend-ceiling-enforcement.test.ts": "lib",
"spend-instrumentation-log.test.ts": "server",
Expand Down
49 changes: 35 additions & 14 deletions src/cli/chatgpt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const USAGE = `Usage (experimental, macOS only):

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() };
return { ok: result.status === 0, status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
}

/**
Expand All @@ -42,11 +42,12 @@ function chatgptDesktopIssueInFile(): string | null {
* 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 {
function discoverApp(): DesktopAppInstall | null | undefined {
try {
return darwinDesktopAppAdapter.discover(darwinDefaultExec);
} catch {
return null;
// An inspection error is not evidence that the app is uninstalled.
return undefined;
}
}

Expand All @@ -55,35 +56,45 @@ function discoverApp(): DesktopAppInstall | null {
* `-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 } {
function appState(install: DesktopAppInstall, launcher: string): { running: boolean | null; shim: boolean | null } {
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))) {
// pgrep exit 1 means no match; timeout, signal, or command errors are unknown.
if (!pids.ok) return pids.status === 1
? { running: false, shim: false } : { running: null, shim: null };
const candidates = pids.output.split(/\s+/).filter(value => /^\d+$/.test(value));
if (candidates.length === 0) return { running: null, shim: null };
let unreadable = false;
for (const pid of candidates) {
const command = run("ps", ["eww", "-o", "command=", "-p", pid]);
if (!command.ok || !command.output) unreadable = true;
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 };
return unreadable ? { running: null, shim: null } : { 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<boolean> {
if (!appState(install, launcher).running) return true;
const initial = appState(install, launcher).running;
if (initial === false) return true;
if (initial === null) return false;
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;
const running = appState(install, launcher).running;
if (running === false) return true;
if (running === null) return false;
await Bun.sleep(250);
}
}
return !appState(install, launcher).running;
return appState(install, launcher).running === false;
}

export async function handleChatgptCommand(args: string[], platform: NodeJS.Platform = process.platform): Promise<number> {
Expand All @@ -105,16 +116,21 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat
const launcher = chatgptShimLauncherPath();
const install = discoverApp();
if (sub === "status") {
const app = install ? appState(install, launcher) : { running: false, shim: false };
const app = install ? appState(install, launcher)
: install === undefined ? { running: null, shim: null } : { running: false, shim: false };
const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile();
const flag = config.chatgptDesktop?.appServerShim === true ? "on"
: configIssue ? `off (config.json ${configIssue}; the whole chatgptDesktop block is ignored)` : "off";
console.log(`app-server shim (experimental): ${flag}
launcher: ${existsSync(launcher) ? "present" : "absent"}
app: ${install ? (app.running ? "running" : "not running") : "not installed"}
CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
app: ${install === undefined ? "unknown (app discovery failed)" : install ? (app.running === null ? "unknown (process inspection failed)" : app.running ? "running" : "not running") : "not installed"}
CODEX_CLI_PATH launcher: ${app.shim === null ? "unknown" : app.shim ? "yes" : "no"}`);
return 0;
}
if (install === undefined) {
console.error("ChatGPT app discovery failed; no launcher was changed and no app was opened. Retry after resolving discovery.");
return 1;
}
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.");
Expand Down Expand Up @@ -143,9 +159,14 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
console.error(`Refusing to ${sub === "launch" ? "launch the shim" : "restore ChatGPT"}: ${untrusted}.`);
return 1;
}
// Preflight before changing the launcher. quitApp rechecks at the action boundary.
if (appState(install, launcher).running === null) {
console.error("ChatGPT process inspection failed; no launcher was changed and no app was opened. Retry after resolving inspection.");
return 1;
}
if (binary) writeChatgptShimLauncher(undefined, binary);
if (!(await quitApp(install, launcher))) {
console.error("ChatGPT did not quit; quit it manually and retry.");
console.error("ChatGPT exit could not be confirmed; no new app was opened. Check the running app and retry.");
return 1;
}
// Remove an inherited override too: restore must launch without CODEX_CLI_PATH.
Expand Down
39 changes: 32 additions & 7 deletions src/lib/socks5-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,25 +341,50 @@ function requestHeaders(request: Request, target: URL): { text: string; chunked:

async function secureSocket(socket: Socket, target: URL, signal: AbortSignal): Promise<TLSSocket> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
socket.destroy();
reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
return;
}
const tlsSocket = tls.connect({
socket,
servername: target.hostname,
rejectUnauthorized: true,
});
const onAbort = () => {
let settled = false;
// Absolute deadline: a peer sending occasional TLS bytes must not extend the handshake.
const timer = setTimeout(() => {
fail(new Socks5FetchError("SOCKS5 TLS handshake timed out"));
}, SOCKS5_CONNECT_TIMEOUT_MS);
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
tlsSocket.removeListener("secureConnect", onSecureConnect);
tlsSocket.removeListener("error", onError);
tlsSocket.removeListener("close", onClose);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
// No error argument: pending event observers must not receive a second error.
tlsSocket.destroy();
reject(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
reject(error);
};
const onAbort = () => fail(signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"));
const onError = (error: Error) => fail(error);
const onClose = () => fail(new Socks5FetchError("SOCKS5 upstream closed during TLS handshake"));
const onSecureConnect = () => {
signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
cleanup();
resolve(tlsSocket);
};
tlsSocket.once("secureConnect", onSecureConnect);
tlsSocket.once("error", error => {
signal.removeEventListener("abort", onAbort);
reject(error);
});
tlsSocket.once("error", onError);
tlsSocket.once("close", onClose);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
});
}

Expand Down
7 changes: 6 additions & 1 deletion structure/clients/chatgpt-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ app-server binary. It relaunches without that override and removes the launcher
`com.openai.codex` bundle is found it removes the launcher, relaunches nothing and
exits 1. Status reports
the experimental flag, launcher presence, and the verified bundle process's override
without printing its environment. Other platforms reject all three operations.
without printing its environment. Discovery exceptions and failed process inspection
are unknown rather than evidence of an absent app. Launch preflights process inspection
before writing the launcher; both operations refuse to open when exit cannot be confirmed.
Restore keeps the launcher on those failures. A later probe may fail after launch has
atomically updated it; this is not a transaction spanning the launcher and app restart.
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
Expand Down
6 changes: 6 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,12 @@ admission or account-snapshot pairing. The forwarding contract is covered in

## SOCKS5 dispatch boundary

HTTPS tunnels in `src/lib/socks5-fetch.ts` give the TLS handshake its own absolute
30-second deadline after SOCKS CONNECT. Successful TLS clears that deadline before
the response timer takes over; timeout, caller cancellation, error and premature
close release the owned socket and listeners. Certificate verification stays enabled.
`tests/lib/socks5-tls-lifecycle.test.ts` exercises stalled and interrupted handshakes.

`src/config/proxy-env.ts` activates configured SOCKS5 through `src/lib/proxy-env.ts`;
the compatibility config facade does not own a second activation path.
The library captures `startupOutboundProxyConfigured` at module evaluation, before config
Expand Down
33 changes: 32 additions & 1 deletion tests/clients/desktop-app-server-shim-launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ describe("restore validates the discovered app before any quit or open", () => {
expect(result.status).toBe(0);
return { ...JSON.parse(result.stdout.trim().split("\n").at(-1)!), stdout: result.stdout, stderr: result.stderr } as {
code: number; calls: Array<{ command: string; args: string[]; override?: string }>;
launcherExists: boolean; root: string; shell: string; binary: string; stdout: string; stderr: string;
launcherExists: boolean; launcherContent: string | null; root: string; shell: string; binary: string; stdout: string; stderr: string;
};
} finally { removeTreeWithRetry(dir); }
};
Expand All @@ -199,6 +199,37 @@ describe("restore validates the discovered app before any quit or open", () => {
expect(result.calls.filter(call => ["pgrep", "ps", "/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toEqual([]);
});
}
for (const sub of ["launch", "restore"]) {
for (const scenario of [
{ pgrepFails: true }, { pgrepFails: true, pgrepTimeout: true },
{ psFails: true, running: true }, { invalidPgrep: true }, { discoveryFails: true },
]) {
test(`${sub} preserves the launcher and refuses unknown app state ${JSON.stringify(scenario)}`, () => {
const result = run({ sub, flag: true, ...scenario });
expect(result.code).toBe(1);
expect(result.launcherContent).toBe("preserve until restore succeeds");
expect(result.calls.some(call => ["/usr/bin/osascript", "/usr/bin/open"].includes(call.command))).toBe(false);
expect(result.stderr).not.toContain("private");
});
}
}
test("an inspection failure after quit does not authorize open or launcher removal", () => {
const result = run({ running: true, probeFailsAfterQuit: true });
expect(result.code).toBe(1);
expect(result.launcherContent).toBe("preserve until restore succeeds");
expect(result.calls.some(call => call.command === "/usr/bin/osascript")).toBe(true);
expect(result.calls.some(call => call.command === "/usr/bin/open")).toBe(false);
});
for (const scenario of [{ pgrepFails: true }, { discoveryFails: true }, { psFails: true, running: true }]) {
test(`status reports unknown without mutation ${JSON.stringify(scenario)}`, () => {
const result = run({ sub: "status", ...scenario });
expect(result.code).toBe(0);
expect(result.stdout).toContain("app: unknown");
expect(result.stdout).toContain("CODEX_CLI_PATH launcher: unknown");
expect(result.launcherContent).toBe("preserve until restore succeeds");
expect(result.stdout).not.toContain("private");
});
}
test("valid restore works with the flag off and no bundled app-server", () => {
const result = run({ flag: false, missingBinary: true, running: true });
expect(result.code).toBe(0);
Expand Down
3 changes: 2 additions & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,8 @@
"sidecar-settings-web-search-gate.test.ts": "vision", "sidecar-settings-web-search-off.test.ts": "vision",
"sidecar-settings-web-search-stream.test.ts": "vision", "sidecar-tracker.test.ts": "vision",
"skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration",
"socks5-fetch.test.ts": "lib", "socks5-upload-lifecycle.test.ts": "lib",
"socks5-fetch.test.ts": "lib", "socks5-tls-lifecycle.test.ts": "lib",
"socks5-upload-lifecycle.test.ts": "lib",
"spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server",
"spend-ledger-file-journal.test.ts": "lib", "spend-ledger-lifecycle.test.ts": "server",
"spend-ledger-owner-startup.test.ts": "server", "spend-ledger-owner.test.ts": "lib",
Expand Down
Loading
Loading