Skip to content

Commit 03f49e9

Browse files
jahoomaclaude
andauthored
feat(desktop): add Codex (GPT-5.5) as a local-CLI harness (#492)
## What Adds **Codex (GPT-5.5)** as a third pluggable agent harness in Freebuff Desktop, alongside Claude Code and the hosted Freebuff agent. It drives the user's **local, ChatGPT-authenticated Codex CLI** via `@openai/codex-sdk` — reusing `~/.codex` login with no key plumbing, exactly like the Claude Code harness reuses the Anthropic subscription. ## How it works - **Model picker** gains a CODEX group (GPT-5.5). The per-thread pick persists (schema **v16** `codex_model`). The `-codex` model variants are rejected because they 400 on ChatGPT-account auth. - **Rendering**: Codex's JSONL events fold into the shared `parts` model — reasoning, assistant messages, and tool calls (`command_execution` / `file_change` / `mcp_tool_call` / `web_search`) render in stream order with friendly labels (`formatTool`). - **Auth**: reuses `~/.codex/auth.json` (strips `OPENAI_API_KEY`/`CODEX_API_KEY` so the subscription login wins). Signed-out → a `codex-auth` recovery card (Open Terminal + copy `codex login`), mirroring the Claude Code card. - **Freebuff custom tools** (`suggest_prompts` / `write_doc` / `browser_check`): Codex has no in-process tool transport, so each turn hosts a tiny **dependency-free localhost streamable-HTTP MCP server** bound to that turn's `toolDeps`, wired via `config.mcp_servers`. (The MCP SDK is deliberately avoided — the monorepo pins zod v4 and the SDK needs v3.) - **Not bundled**: the ~240 MB platform binary is too heavy to ship, so we use the user's **installed** codex. When none is available, the picker shows the Codex agent **disabled** ("Not installed", with an install tooltip) and the agent route rejects the pick. `FREEBUFF_CODEX_DISABLED=1` is an ops/test kill-switch. ## Autonomy Runs with `sandbox = danger-full-access` + `approval = never` — the analog of Claude Code's `bypassPermissions` (each thread is an isolated worktree). `workspace-write` invokes Codex's own macOS seatbelt, which SIGKILLs under nested sandboxing. ## Verification Driven end-to-end against a real, isolated orchestrator (fresh `HOME`, scratch repo): - ✅ CODEX picker group renders; GPT-5.5 selectable & persists; invalid model rejected (400). - ✅ A real turn ran on Codex — tool calls + assistant messages render in order; file created. - ✅ `suggest_prompts` MCP tool round-trips → suggestion parked in the thread queue. - ✅ Resume carries context across turns. - ✅ Signed-out CLI → `codex-auth` recovery card. - ✅ Unavailable → Codex option `disabled=True` + route 400; available → `disabled=False` + 200. - ✅ **226 desktop tests pass**; both tsconfigs typecheck; UI builds. ## Notes / follow-ups - Adds `@openai/codex-sdk` (which pulls the platform `@openai/codex` binary in dev). - In dev, `@openai/codex` is in `node_modules` so the SDK self-resolves; a packaged build with no installed codex → the disabled flow applies. - `scripts/codex-smoke.ts` added as a dev harness (mirrors `claude-smoke.ts`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e7e906c commit 03f49e9

23 files changed

Lines changed: 1726 additions & 42 deletions

bun.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

freebuff-desktop/docs/codex.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Codex in Freebuff Desktop
2+
3+
Codex is one of the three pluggable agent **harnesses** a thread can run on
4+
(alongside the hosted Freebuff agent and Claude Code). It drives the user's
5+
**local, ChatGPT-authenticated Codex CLI** via `@openai/codex-sdk`, reusing their
6+
`~/.codex` login with no API-key plumbing — exactly how the Claude Code harness
7+
reuses the Anthropic subscription.
8+
9+
Code: `src/app/agents/codex-harness.ts` (+ `freebuff-mcp-server.ts`,
10+
`src/core/codex-models.ts`). This doc is the "why", not a line-by-line tour.
11+
12+
## How it works
13+
14+
- **Auth** — the SDK spawns `codex exec`, which reads the credentials
15+
`codex login` stored in `~/.codex/auth.json`. This only works when **no**
16+
`OPENAI_API_KEY` / `CODEX_API_KEY` is set (a key overrides the subscription
17+
login), so `codexEnv()` strips both before spawning. `CODEX_HOME` relocates the
18+
config dir (used in tests: fresh `HOME` for isolation + real `CODEX_HOME`).
19+
- **Model** — GPT-5.5 (`src/core/codex-models.ts`), persisted per-thread
20+
(`schema v16`, `codex_model`). The `-codex` variants (e.g. `gpt-5.5-codex`) are
21+
deliberately **not** offered: they `400` on a ChatGPT-account login, which is
22+
this harness's only auth path.
23+
- **Streaming** — the SDK surfaces Codex's JSONL as typed `ThreadEvent`s. Codex
24+
buffers a whole reasoning block / assistant message and emits it once at
25+
`item.completed` (no token-level deltas like Claude Code). `consumeCodexStream`
26+
folds these into the shared parts model: reasoning → `onReasoning`,
27+
agent_message → `onText`, and command/file/mcp/web/todo items → `tool_call`
28+
(command & mcp emit on `item.started` so the call shows as it's dispatched; the
29+
rest on `item.completed`). MCP calls surface as `mcp__<server>__<tool>` so the
30+
UI's `formatTool` strips the prefix like it does for Claude Code.
31+
- **Resume** — the `thread.started` event's `thread_id` is carried back as the
32+
harness state, so the next turn `resumeThread(id)` keeps context/caching.
33+
- **Autonomy**`sandboxMode: 'danger-full-access'` + `approvalPolicy: 'never'`,
34+
the direct analog of the Claude Code harness's `bypassPermissions` (see the
35+
sandbox trade-off below).
36+
- **Signed out** — an unauthenticated CLI throws `CodexAuthError`, which the
37+
engine renders as a `codex-auth` recovery card ("Open Terminal" + copy
38+
`codex login`), mirroring the Claude Code auth card.
39+
40+
## Custom Freebuff tools over a local MCP server
41+
42+
The Codebuff/Claude harnesses expose `suggest_prompts` / `write_doc` /
43+
`browser_check` to the model. Codex has **no in-process tool transport**, so each
44+
turn stands up a tiny localhost **streamable-HTTP MCP server**
45+
(`freebuff-mcp-server.ts`) whose handlers close over that turn's `ThreadToolDeps`,
46+
and points Codex at it via `config.mcp_servers.freebuff.url`. The server is
47+
hand-rolled (a small JSON-RPC subset: `initialize` / `tools/list` / `tools/call`
48+
/ notifications) rather than using `@modelcontextprotocol/sdk` — see the zod
49+
trade-off below.
50+
51+
**Access control.** Even on 127.0.0.1 for a few seconds on a random port, the
52+
handlers have side effects (write files, launch a browser), so every request must
53+
carry a **per-turn bearer token** (minted per turn, handed to Codex via
54+
`mcp_servers.freebuff.http_headers`) **and** a loopback `Host` header. This blocks
55+
any stray local process or DNS-rebinding webpage from reaching the tools during
56+
the turn's window.
57+
58+
## Availability (we don't bundle Codex)
59+
60+
The `codex` binary is ~240 MB/platform — too heavy to ship in the installer. So
61+
the packaged app uses the user's **installed** `codex`:
62+
63+
- `resolveCodexExecutable()``FREEBUFF_CODEX_PATH` override → the SDK's own
64+
bundled binary when `@openai/codex` is on disk (dev) → the user's installed
65+
codex (packaged, no `node_modules`).
66+
- When no codex is available, `isCodexAvailable()` is false and the engine marks
67+
the Codex option **disabled** in the picker (greyed, "Not installed", with an
68+
install tooltip); the `/api/thread/{id}/agent` route also `400`s a Codex pick.
69+
- Env knobs: `FREEBUFF_CODEX_PATH` (force a specific binary),
70+
`FREEBUFF_CODEX_DISABLED=1` (force the agent off), `CODEX_HOME` (config dir).
71+
72+
## Trade-offs we hit (and how we resolved them)
73+
74+
| Decision | Options | Chose | Why / cost |
75+
|---|---|---|---|
76+
| **Binary distribution** | bundle (+240 MB) · use installed codex · download-on-demand | **installed codex** | Small installer; but only works if the user has codex (hence the disabled picker state) and runs *their* version (protocol-drift risk). Download-on-demand is the "best of both" follow-up. |
77+
| **Sandbox** | `workspace-write` (seatbelt, worktree-confined) · `danger-full-access` (no sandbox) | **`danger-full-access`** | Matches Claude Code's `bypassPermissions`, and `workspace-write`'s macOS seatbelt gets SIGKILLed when Freebuff runs inside a nested sandbox (CI/dev). Cost: no OS confinement — a prompt injection could act outside the worktree. Revisit: prefer `workspace-write` on real machines with a full-access fallback. |
78+
| **CLI wrapper** | `@openai/codex-sdk` · raw-spawn `codex exec --experimental-json` | **SDK** (for now) | Insulates us from the *experimental* JSON flag/schema churn and keeps parity with Claude Code (also SDK-wrapped). But the SDK is a thin wrapper and `consumeCodexStream` is already SDK-agnostic, so dropping it later is contained. Cost: a heavy dev-only dependency. |
79+
| **MCP server** | `@modelcontextprotocol/sdk` · hand-rolled JSON-RPC | **hand-rolled** | The monorepo pins zod to **v4** (root `overrides`); the MCP SDK needs zod **v3** and its internals throw under v4. Hand-rolling the small subset Codex uses also drops a dependency. |
80+
| **Model id** | `gpt-5.5` · `gpt-5.5-codex` | **`gpt-5.5`** | The `-codex` variants `400` on ChatGPT-account auth. |
81+
82+
## Future ideas
83+
84+
- **Download-on-demand binary** — fetch the SDK-pinned `@openai/codex-<platform>`
85+
into app-support on first Codex turn, cache it, and point `codexPathOverride`
86+
there. Gives version consistency (kills the protocol-drift risk) and works
87+
without an installed codex — all without a bigger installer. Caveats: a one-time
88+
~80–100 MB download, first-turn latency, and macOS quarantine/Gatekeeper on the
89+
fetched binary.
90+
- **Prefer `workspace-write`** on end-user machines (OS-confined to the worktree),
91+
falling back to `danger-full-access` only where the seatbelt can't run.
92+
- **Drop the SDK**, spawning `codex exec --experimental-json` directly — removes
93+
the heavy dependency and the dev-vs-packaged binary-resolution split, since the
94+
stream consumer is already decoupled. Weigh against owning the experimental CLI
95+
surface.
96+
- **Refresh availability** without an app restart (a "Recheck" affordance), so
97+
installing codex mid-session enables the picker.
98+
- **Token-level streaming** if/when Codex's JSON mode exposes deltas (today
99+
reasoning/messages arrive buffered at `item.completed`).
100+
- **Multimodal** — forward attached images as SDK content instead of relying on
101+
Codex reading the referenced path.
102+
- **Quieter auth failures** — suppress the item-level `401` transport note that
103+
currently prints just before the sign-in recovery card.
104+
105+
## Testing
106+
107+
- Unit: `codex-harness.test.ts` (stream mapping, error/auth classification, env,
108+
resolver, availability), `freebuff-mcp-server.test.ts` (JSON-RPC + the
109+
token/Host access control).
110+
- Manual: `scripts/codex-smoke.ts` runs one real Codex turn against the local
111+
login (mirrors `scripts/claude-smoke.ts`).
112+
- End-to-end via the HTTP/SSE API: see `docs/desktop/e2e-testing.md` — set
113+
`CODEX_HOME=~/.codex` alongside a fresh `HOME` so the harness reuses the real
114+
login while the orchestrator stays isolated.

freebuff-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dependencies": {
3636
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
3737
"@codebuff/sdk": "workspace:*",
38+
"@openai/codex-sdk": "^0.142.5",
3839
"@dnd-kit/core": "^6.3.1",
3940
"@dnd-kit/sortable": "^10.0.0",
4041
"@dnd-kit/utilities": "^3.2.2",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Smoke-test the Codex harness against the user's LOCAL authenticated Codex CLI:
3+
* spin up a temp git repo, run one turn asking it to create a file, and verify
4+
* the file landed. Proves auth (ChatGPT/OpenAI login) + model (GPT-5.5) + real
5+
* edits work end-to-end before wiring through the full desktop UI.
6+
*
7+
* bun freebuff-desktop/scripts/codex-smoke.ts
8+
*/
9+
10+
import { execSync } from 'child_process'
11+
import { existsSync, mkdtempSync, readFileSync } from 'fs'
12+
import { tmpdir } from 'os'
13+
import { join } from 'path'
14+
15+
import { CodexHarness } from '../src/app/agents/codex-harness'
16+
17+
const root = mkdtempSync(join(tmpdir(), 'codex-smoke-'))
18+
execSync('git init -q && git commit -q --allow-empty -m init', { cwd: root, shell: '/bin/bash' })
19+
console.log('repo:', root)
20+
21+
const harness = new CodexHarness()
22+
let text = ''
23+
const tools: string[] = []
24+
25+
const t0 = Date.now()
26+
const result = await harness.runTurn(
27+
{
28+
prompt:
29+
'Create a file named hello.txt whose entire contents are exactly the line: hello world. Then stop.',
30+
cwd: root,
31+
toolDeps: {
32+
onSuggest: () => {},
33+
onWriteDoc: () => ({ ok: true }),
34+
onBrowserCheck: async () =>
35+
({ loaded: false, rendered: false, title: '', renderDetail: '', consoleErrors: [], pageErrors: [] }) as any,
36+
},
37+
previousState: undefined,
38+
abort: new AbortController(),
39+
},
40+
{
41+
onText: (c) => {
42+
text += c
43+
process.stdout.write(c)
44+
},
45+
onReasoning: () => {},
46+
onEvent: (ev) => {
47+
if (ev.type === 'tool_call') {
48+
tools.push(ev.toolName as string)
49+
console.log(`\n [tool] ${ev.toolName} ${JSON.stringify(ev.input).slice(0, 120)}`)
50+
}
51+
if (ev.type === 'finish') console.log('\n [finish]')
52+
},
53+
drainSteering: () => [],
54+
},
55+
)
56+
57+
const file = join(root, 'hello.txt')
58+
const ok = existsSync(file)
59+
console.log('\n\n— RESULT —')
60+
console.log('tools used:', tools.join(', ') || '(none)')
61+
console.log('thread id:', (result.state as any)?.threadId ?? '(none)')
62+
console.log('elapsed:', ((Date.now() - t0) / 1000).toFixed(1) + 's')
63+
console.log('hello.txt exists:', ok)
64+
if (ok) console.log('hello.txt contents:', JSON.stringify(readFileSync(file, 'utf8')))
65+
process.exit(ok ? 0 : 1)

0 commit comments

Comments
 (0)