Skip to content
Closed
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
35 changes: 35 additions & 0 deletions docs-site/src/content/docs/guides/desktop-unblocker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: ChatGPT Desktop Unblocker (Luna Reserve & Quota Lockout)
description: Bypassing the Desktop client hard composer lockout and model picker collapse when allowance is zero.
---

When the ChatGPT weekly allowance and Luna Reserve capacity are exhausted, the ChatGPT Desktop Electron app enters a `hardBlocked` client state by evaluating `/backend-api/wham/usage`:
- The model picker dropdown collapses, forcefully setting the conversation to `gpt-reserve`.
- The composer Send button (`aria-label="Enviar"` / `Send`) is disabled (`submitDisabled: true`).
- A blocking modal banner (*"You've run out of usage for Codex..."*) prevents message dispatch.

### Architecture

In ChatGPT Desktop's Electron main process (`main-*.js`):
1. The app reads `process.env.CODEX_API_BASE_URL` to route `/backend-api` calls.
2. `isDesktopAuthAllowedUrl` explicitly allows `localhost:8000` (and `localhost`) to attach native Bearer tokens and headers.

### Unblocker Service

The unblocker operates a loopback reverse proxy on `127.0.0.1:8000`:
- **Passthrough:** All conversations, streaming SSE tokens, and standard endpoints forward transparently to `https://chatgpt.com`.
- **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.

1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@
"codex-cooldown-recovery.test.ts": "codex-integration",
"codex-coordinator-doctor.test.ts": "codex-integration",
"codex-desired-state.test.ts": "codex-integration",
"codex-desktop-unblocker.test.ts": "codex-integration",
"codex-entitlement-identity-read-fence.test.ts": "codex-integration",
"codex-envkey-admission-substitution.test.ts": "codex-integration",
"codex-exec-invocation.test.ts": "codex-integration",
Expand Down
134 changes: 134 additions & 0 deletions src/cli/desktop-unblocker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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();
return val.length > 0 ? val : null;
} catch {
return process.env.CODEX_API_BASE_URL || null;
}
}

export function setDesktopApiBaseUrlEnv(enable: boolean, port = DEFAULT_DESKTOP_UNBLOCKER_PORT): void {
const target = `http://localhost:${port}/backend-api`;
try {
if (enable) {
execSync(`launchctl setenv CODEX_API_BASE_URL "${target}"`, { stdio: "ignore" });
} else {
execSync("launchctl unsetenv CODEX_API_BASE_URL", { stdio: "ignore" });
}
} catch {
/* non-macOS or restricted environment */
}
}

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";
const cliStatus = cliPath ? `set (${cliPath})` : "unset";
return `ChatGPT Desktop Unblocker: ${statusIcon} | CODEX_API_BASE_URL: ${envStatus} | CODEX_CLI_PATH: ${cliStatus}`;
}
Loading
Loading