Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
feat(codex): add CODEX_CLI_PATH shim for newer Codex workspaceRouting…
… and rateLimit RPC
  • Loading branch information
guilhermemarketing committed Sep 18, 2026
commit 186218193d44471d908f44a4af162094ded0b564
12 changes: 12 additions & 0 deletions docs-site/src/content/docs/guides/desktop-unblocker.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,15 @@ The unblocker operates a loopback reverse proxy on `127.0.0.1:8000`:
- **Trusted destination:** `chatgpt.com` is an allowlist, not a default. `createDesktopUnblockerServer` refuses to start for any other upstream host, because every forwarded request carries the caller's Desktop credentials (`authorization` plus the account headers). A request target that does not resolve to the loopback origin is answered with `400` instead of being forwarded.
- **Usage Override:** For `GET /backend-api/wham/usage`, it patches `rate_limit.allowed: true` and `credits.has_credits: true`.
- **Result:** Desktop stops reading its own lockout state, so the upsell modal stays closed and the composer Send button stays enabled. The proxy rewrites the usage payload only: it adds no provider quota, every other Desktop request still goes to `https://chatgpt.com`, and it does not decide which models opencodex serves — that is the [Codex Integration](/guides/codex-integration/) and its [routed models during Codex reserve mode](/guides/codex-integration/#routed-models-during-codex-reserve-mode) section, not this listener.

### Newer Codex Versions (`>= 0.155.0-alpha.5`) & `CODEX_CLI_PATH` Shim

In versions `>= 0.155.0-alpha.5`, ChatGPT Desktop introduces `workspaceRouting` in the stdio JSON-RPC `account/read` response. Electron's internal routing resolver prioritizes `workspaceRouting.backendUrl` over `defaultRouting` (`CODEX_API_BASE_URL`), bypassing port 8000 and sending HTTP requests directly to `https://chatgpt.com`. Furthermore, the composer UI evaluates `account/rateLimits/read` via stdio JSON-RPC; if `ordinaryUsageAllowed` is `false`, the Send button remains locked.

To unblock newer builds:
1. ChatGPT Desktop reads `CODEX_CLI_PATH` (`launchctl setenv CODEX_CLI_PATH ...`).
2. A lightweight Node.js shim intercepts the stdio JSON-RPC stream between Electron and `codex app-server`:
- Strips `workspaceRouting` from `account/read` so Electron honors `CODEX_API_BASE_URL=http://localhost:8000/backend-api`.
- Sets `ordinaryUsageAllowed: true` and clears `rateLimitUpsell` in `account/rateLimits/read`.
- Spoofs `initialize` userAgent to `0.155.0-alpha.2.6` for legacy-compatible behavior.

108 changes: 106 additions & 2 deletions src/cli/desktop-unblocker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { execSync } from "node:child_process";
import { DEFAULT_DESKTOP_UNBLOCKER_PORT } from "../codex/desktop-unblocker";

export const CODEX_CLI_PATH_ENV = "CODEX_CLI_PATH";
export const DEFAULT_MACOS_CODEX_PATH = "/Applications/ChatGPT.app/Contents/Resources/codex";

export function getDesktopApiBaseUrlEnv(): string | null {
try {
const val = execSync("launchctl getenv CODEX_API_BASE_URL", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
Expand All @@ -23,8 +26,109 @@ export function setDesktopApiBaseUrlEnv(enable: boolean, port = DEFAULT_DESKTOP_
}
}

export function formatDesktopUnblockerStatus(isRunning: boolean, envUrl: string | null): string {
export function getDesktopCliPathEnv(): string | null {
try {
const val = execSync(`launchctl getenv ${CODEX_CLI_PATH_ENV}`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return val.length > 0 ? val : null;
} catch {
return process.env[CODEX_CLI_PATH_ENV] || null;
}
}

export function setDesktopCliPathEnv(enable: boolean, shimPath?: string): void {
try {
if (enable && shimPath) {
execSync(`launchctl setenv ${CODEX_CLI_PATH_ENV} "${shimPath}"`, { stdio: "ignore" });
} else {
execSync(`launchctl unsetenv ${CODEX_CLI_PATH_ENV}`, { stdio: "ignore" });
}
} catch {
/* non-macOS or restricted environment */
}
}

export function generateCodexShimScript(realCodexPath = DEFAULT_MACOS_CODEX_PATH): string {
return `#!/usr/bin/env node
import { spawn } from 'node:child_process';
import readline from 'node:readline';

const REAL_CODEX = '${realCodexPath}';
const args = process.argv.slice(2);

if (!args.includes('app-server')) {
const child = spawn(REAL_CODEX, args, { stdio: 'inherit' });
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});
} else {
const child = spawn(REAL_CODEX, args, {
stdio: ['pipe', 'pipe', 'inherit'],
env: process.env,
});

process.stdin.pipe(child.stdin);

const rl = readline.createInterface({
input: child.stdout,
crlfDelay: Infinity,
});

rl.on('line', (line) => {
if (!line.trim()) {
process.stdout.write(line + '\\n');
return;
}
try {
const msg = JSON.parse(line);
if (msg.result?.userAgent && typeof msg.result.userAgent === 'string') {
msg.result.userAgent = msg.result.userAgent.replace(/0\\.155\\.0-alpha\\.\\d+(\\.\\d+)?/, '0.155.0-alpha.2.6');
}
if (msg.result && typeof msg.result === 'object') {
if ('workspaceRouting' in msg.result) {
delete msg.result.workspaceRouting;
}
if ('ordinaryUsageAllowed' in msg.result) {
msg.result.ordinaryUsageAllowed = true;
if (msg.result.rateLimits?.primary) {
msg.result.rateLimits.primary.usedPercent = 0;
}
if (msg.result.rateLimits?.credits) {
msg.result.rateLimits.credits.hasCredits = true;
msg.result.rateLimits.credits.unlimited = true;
msg.result.rateLimits.credits.balance = '1000';
}
if (msg.result.rateLimitsByLimitId) {
for (const key of Object.keys(msg.result.rateLimitsByLimitId)) {
const item = msg.result.rateLimitsByLimitId[key];
if (item?.primary) item.primary.usedPercent = 0;
if (item?.rateLimitReachedType) item.rateLimitReachedType = null;
}
}
msg.result.rateLimitUpsell = null;
}
}
process.stdout.write(JSON.stringify(msg) + '\\n');
} catch {
process.stdout.write(line + '\\n');
}
});

child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});
}
`;
}

export function formatDesktopUnblockerStatus(
isRunning: boolean,
envUrl: string | null,
cliPath: string | null = null,
): string {
const statusIcon = isRunning ? "active (port 8000)" : "stopped";
const envStatus = envUrl ? `set (${envUrl})` : "unset";
return `ChatGPT Desktop Unblocker: ${statusIcon} | CODEX_API_BASE_URL: ${envStatus}`;
const cliStatus = cliPath ? `set (${cliPath})` : "unset";
return `ChatGPT Desktop Unblocker: ${statusIcon} | CODEX_API_BASE_URL: ${envStatus} | CODEX_CLI_PATH: ${cliStatus}`;
}
69 changes: 69 additions & 0 deletions src/codex/desktop-unblocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,75 @@ export function patchWhamUsagePayload(rawJson: string): string {
}
}

/**
* Patches the stdio JSON-RPC account/rateLimits/read response to prevent
* the Desktop composer Send button from being grayed out on newer Codex versions.
*/
export function patchRateLimitsRpcPayload(rawJson: string): string {
try {
const msg = JSON.parse(rawJson);
const target = msg.result ?? msg;
if (target && typeof target === "object") {
if ("ordinaryUsageAllowed" in target) {
target.ordinaryUsageAllowed = true;
}
if (target.rateLimits?.primary) {
target.rateLimits.primary.usedPercent = 0;
}
if (target.rateLimits?.credits) {
target.rateLimits.credits.hasCredits = true;
target.rateLimits.credits.unlimited = true;
target.rateLimits.credits.balance = "1000";
}
if (target.rateLimitsByLimitId) {
for (const key of Object.keys(target.rateLimitsByLimitId)) {
const item = target.rateLimitsByLimitId[key];
if (item?.primary) item.primary.usedPercent = 0;
if (item?.rateLimitReachedType) item.rateLimitReachedType = null;
}
}
target.rateLimitUpsell = null;
}
return JSON.stringify(msg);
} catch {
return rawJson;
}
}

/**
* Removes workspaceRouting from the stdio JSON-RPC account/read response.
* This prevents Electron's internal routing resolver from overriding CODEX_API_BASE_URL
* and bypassing the loopback proxy on versions >= 0.155.0-alpha.5.
*/
export function patchAccountReadRpcPayload(rawJson: string): string {
try {
const msg = JSON.parse(rawJson);
const target = msg.result ?? msg;
if (target && typeof target === "object" && "workspaceRouting" in target) {
delete target.workspaceRouting;
}
return JSON.stringify(msg);
} catch {
return rawJson;
}
}

/**
* Spoofs userAgent in initialize response to force legacy-compatible routing behavior.
*/
export function patchInitializeRpcPayload(rawJson: string, legacyVersion = "0.155.0-alpha.2.6"): string {
try {
const msg = JSON.parse(rawJson);
const target = msg.result ?? msg;
if (target?.userAgent && typeof target.userAgent === "string") {
target.userAgent = target.userAgent.replace(/0\.155\.0-alpha\.\d+(\.\d+)?/, legacyVersion);
}
return JSON.stringify(msg);
} catch {
return rawJson;
}
}

/**
* Resolve a client request target against the fixed loopback base. Returns `null` when the target
* cannot be parsed or does not resolve to the loopback origin — an absolute-form or
Expand Down
85 changes: 85 additions & 0 deletions tests/codex-integration/codex-desktop-unblocker.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { describe, expect, test } from "bun:test";
import {
createDesktopUnblockerServer,
patchAccountReadRpcPayload,
patchInitializeRpcPayload,
patchRateLimitsRpcPayload,
patchWhamUsagePayload,
resolveDesktopUnblockerTarget,
} from "../../src/codex/desktop-unblocker";
import {
formatDesktopUnblockerStatus,
generateCodexShimScript,
} from "../../src/cli/desktop-unblocker";

describe("desktop unblocker payload patching", () => {
test("overrides depleted rate limit and hardBlocked states to unblocked", () => {
Expand Down Expand Up @@ -47,6 +54,84 @@ describe("desktop unblocker payload patching", () => {
});
});

describe("desktop unblocker stdio JSON-RPC patching", () => {
test("patches account/rateLimits/read to force ordinaryUsageAllowed: true and clear upsell", () => {
const depletedRpc = JSON.stringify({
jsonrpc: "2.0",
id: 5,
result: {
ordinaryUsageAllowed: false,
rateLimits: {
primary: { usedPercent: 100 },
credits: { hasCredits: false, unlimited: false, balance: "0" },
},
rateLimitsByLimitId: {
"codex-default": {
primary: { usedPercent: 100 },
rateLimitReachedType: "hardBlocked",
},
},
rateLimitUpsell: { type: "hardBlocked" },
},
});

const patched = JSON.parse(patchRateLimitsRpcPayload(depletedRpc));
expect(patched.result.ordinaryUsageAllowed).toBe(true);
expect(patched.result.rateLimits.primary.usedPercent).toBe(0);
expect(patched.result.rateLimits.credits.hasCredits).toBe(true);
expect(patched.result.rateLimits.credits.unlimited).toBe(true);
expect(patched.result.rateLimits.credits.balance).toBe("1000");
expect(patched.result.rateLimitsByLimitId["codex-default"].primary.usedPercent).toBe(0);
expect(patched.result.rateLimitsByLimitId["codex-default"].rateLimitReachedType).toBeNull();
expect(patched.result.rateLimitUpsell).toBeNull();
});

test("strips workspaceRouting from account/read to keep loopback routing active", () => {
const accountReadRpc = JSON.stringify({
jsonrpc: "2.0",
id: 3,
result: {
account: { id: "acc-123" },
workspaceRouting: {
backendUrl: "https://chatgpt.com/backend-api",
},
},
});

const patched = JSON.parse(patchAccountReadRpcPayload(accountReadRpc));
expect(patched.result.account.id).toBe("acc-123");
expect(patched.result.workspaceRouting).toBeUndefined();
});

test("spoofs userAgent in initialize response to legacy compatible version", () => {
const initRpc = JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
userAgent: "codex/0.155.0-alpha.9.2 (darwin; arm64)",
},
});

const patched = JSON.parse(patchInitializeRpcPayload(initRpc, "0.155.0-alpha.2.6"));
expect(patched.result.userAgent).toBe("codex/0.155.0-alpha.2.6 (darwin; arm64)");
});

test("generateCodexShimScript outputs valid executable JS template", () => {
const script = generateCodexShimScript();
expect(script).toContain("#!/usr/bin/env node");
expect(script).toContain("REAL_CODEX = '/Applications/ChatGPT.app/Contents/Resources/codex'");
expect(script).toContain("delete msg.result.workspaceRouting;");
expect(script).toContain("msg.result.ordinaryUsageAllowed = true;");
});

test("formatDesktopUnblockerStatus includes CODEX_CLI_PATH", () => {
const status = formatDesktopUnblockerStatus(true, "http://localhost:8000/backend-api", "/path/to/shim");
expect(status).toContain("active (port 8000)");
expect(status).toContain("CODEX_API_BASE_URL: set (http://localhost:8000/backend-api)");
expect(status).toContain("CODEX_CLI_PATH: set (/path/to/shim)");
});
});

describe("desktop unblocker request target", () => {
test("resolves origin-form targets against the fixed loopback base", () => {
expect(resolveDesktopUnblockerTarget("/backend-api/wham/usage")?.pathname).toBe(
Expand Down
Loading