diff --git a/desktop/electron/src/localagent/codexattach.test.ts b/desktop/electron/src/localagent/codexattach.test.ts new file mode 100644 index 00000000..a048e26a --- /dev/null +++ b/desktop/electron/src/localagent/codexattach.test.ts @@ -0,0 +1,85 @@ +/// L4a attach-rung checks. Every expectation here was measured against +/// codex-cli 0.147.0 on 2026-08-16 (`codex app-server --help`, `... daemon +/// --help`, `... proxy --help`, and a real `daemon start` refusal), not read +/// from the plan — whose L4 line described a WebSocket and a bearer scheme that +/// do not exist. Run with `node --test`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + codexHome, + controlSocketPath, + managedCodexPath, + MAX_UNIX_SOCKET_PATH, + planCodexAttach, +} from './codexattach.ts'; + +const HOME = '/home/u'; + +test('CODEX_HOME relocates the home, like CLAUDE_CONFIG_DIR does for claude', () => { + assert.equal(codexHome(HOME, {}), '/home/u/.codex'); + assert.equal(codexHome(HOME, { CODEX_HOME: '/srv/cx' }), '/srv/cx'); + // An empty value is not a relocation. + assert.equal(codexHome(HOME, { CODEX_HOME: '' }), '/home/u/.codex'); +}); + +test('the measured control-socket and managed-binary paths', () => { + assert.equal(controlSocketPath('/home/u/.codex'), '/home/u/.codex/app-server-control/app-server-control.sock'); + assert.equal(managedCodexPath('/home/u/.codex'), '/home/u/.codex/packages/standalone/current/codex'); +}); + +test('no installer-managed codex: spawn, because `daemon start` would refuse', () => { + // This is the COMMON case, not an edge one: an npm/homebrew/distro codex has + // no managed standalone install, and `daemon start` fails outright on it. + const p = planCodexAttach(HOME, { managedInstallPresent: false }, {}); + assert.equal(p.mode, 'spawn'); + assert.deepEqual(p.argv, ['codex', 'app-server']); + assert.equal(p.startArgv, undefined, 'spawn needs no preparation step'); + assert.match(p.reason, /packages\/standalone\/current\/codex/); +}); + +test('installer-managed codex: attach to the shared daemon via the stdio proxy', () => { + const p = planCodexAttach(HOME, { managedInstallPresent: true }, {}); + assert.equal(p.mode, 'daemon'); + // The transport is a Unix socket reached through `app-server proxy` — there + // is no WebSocket URL and no token anywhere in this argv. + assert.deepEqual(p.argv, [ + 'codex', + 'app-server', + 'proxy', + '--sock', + '/home/u/.codex/app-server-control/app-server-control.sock', + ]); + assert.deepEqual(p.startArgv, ['codex', 'app-server', 'daemon', 'start']); + assert.ok(!p.argv.some((a) => /ws:|wss:|token|bearer/i.test(a)), 'no WebSocket or bearer anywhere in the argv'); +}); + +test('a socket path over SUN_LEN disqualifies the daemon rung up front', () => { + // Measured: a deep CODEX_HOME yields `path must be shorter than SUN_LEN` at + // CONNECT time, with nothing in the message pointing at path length. Decide + // it here, where we can say so. + const deep = path.join('/tmp', 'x'.repeat(120)); + const p = planCodexAttach(HOME, { managedInstallPresent: true }, { CODEX_HOME: deep }); + assert.equal(p.mode, 'spawn'); + assert.match(p.reason, /Unix socket/); + // ...and a short relocation still gets the daemon. + const ok = planCodexAttach(HOME, { managedInstallPresent: true }, { CODEX_HOME: '/srv/cx' }); + assert.equal(ok.mode, 'daemon'); + assert.ok(controlSocketPath('/srv/cx').length <= MAX_UNIX_SOCKET_PATH); +}); + +test('every rung explains itself', () => { + // The user whose session is NOT shared with their TUI should be able to find + // out why without reading code. + for (const managed of [true, false]) { + const p = planCodexAttach(HOME, { managedInstallPresent: managed }, {}); + assert.ok(p.reason.length > 20, 'reason must be a sentence, not a label'); + } +}); + +test('the codex binary is overridable for a non-PATH install', () => { + // A GUI-launched Electron app does not inherit the login shell PATH — the + // problem kimiweb.ts already had to solve for kimi. + const p = planCodexAttach(HOME, { managedInstallPresent: false }, { TERMIPOD_CODEX_BIN: '/opt/bin/codex' }); + assert.equal(p.argv[0], '/opt/bin/codex'); +}); diff --git a/desktop/electron/src/localagent/codexattach.ts b/desktop/electron/src/localagent/codexattach.ts new file mode 100644 index 00000000..a076ba17 --- /dev/null +++ b/desktop/electron/src/localagent/codexattach.ts @@ -0,0 +1,121 @@ +/// How a local codex session reaches an app-server (vision-parity **L4a**). +/// +/// codex speaks the same JSON-RPC-over-stdio app-server protocol whichever way +/// we reach it, so the only real decision is WHICH PROCESS we spawn — and that +/// decision is a pure function of what is installed. This module is that +/// function; the driver that speaks the protocol is L4b. +/// +/// ## The two rungs, as they actually exist (codex-cli 0.147.0, measured) +/// +/// - **`daemon`** — a shared, long-lived app server. `codex app-server daemon +/// start` brings it up (idempotent by its own definition: "start ... if it +/// is not already running"), and `codex app-server proxy --sock ` +/// pipes our stdio to its **Unix domain control socket**. A session on the +/// daemon outlives our app and is shared with the vendor's own TUI. +/// - **`spawn`** — `codex app-server`, one child per session, dying with us. +/// +/// ## Three corrections to the plan's L4 line, all measured +/// +/// 1. **It is not a WebSocket and there is no bearer scheme.** The vendor's +/// shared-daemon transport is a Unix domain socket reached through a stdio +/// proxy. `--code-mode-host ` exists but is a different feature +/// (where *code mode* runs), not the session transport. Nothing here needs +/// a WebSocket client, which is why this module hands back argv instead. +/// 2. **We cannot "spawn it detached if absent".** `daemon start` refuses +/// unless codex was installed by the official installer script — it wants +/// the managed binary at `/packages/standalone/current/codex` +/// and says so: +/// *"managed standalone Codex install not found ... This command requires +/// the standalone install managed by the Codex installer, because the +/// daemon starts and updates app-server from that fixed path."* +/// An npm / homebrew / distro codex therefore has **no daemon rung at +/// all**. +/// 3. **So the rung order inverts.** The plan called stdio spawn "the fallback +/// rung only"; for the common install it is the ONLY rung. `daemon` is an +/// opportunistic upgrade we take when the managed install is present. +/// +/// Also measured: the control socket path is subject to the platform's +/// `SUN_LEN` cap (~108 bytes). A relocated `CODEX_HOME` nested deeply enough +/// makes the daemon unreachable with *"path must be shorter than SUN_LEN"* — +/// so a long path disqualifies the daemon rung rather than failing later, at +/// connect time, with a message no one would attribute to path length. +import path from 'node:path'; + +/// Conservative bound on a Unix domain socket path. The real cap is +/// `sizeof(sun_path)` — 108 on Linux, 104 on macOS — so we take the smaller and +/// leave room rather than probe per platform. +export const MAX_UNIX_SOCKET_PATH = 104; + +export type CodexAttachMode = 'daemon' | 'spawn'; + +export interface CodexAttachPlan { + mode: CodexAttachMode; + /// Argv for the process whose stdio carries the app-server protocol. + argv: string[]; + /// The command that must succeed FIRST for `mode: 'daemon'` — bringing the + /// shared daemon up. Absent for `spawn`, which needs no preparation. + startArgv?: string[]; + /// Why this rung, in a sentence, for the session record and the UI. A user + /// whose session is not shared with their TUI should be able to find out why + /// without reading code. + reason: string; +} + +/// The daemon's control socket, given a codex home. +export function controlSocketPath(codexHome: string): string { + return path.join(codexHome, 'app-server-control', 'app-server-control.sock'); +} + +/// The managed standalone binary `daemon start` insists on. +export function managedCodexPath(codexHome: string): string { + return path.join(codexHome, 'packages', 'standalone', 'current', 'codex'); +} + +/// `CODEX_HOME` relocates the whole codex home, exactly as `CLAUDE_CONFIG_DIR` +/// does for claude (usermcp.ts resolves the same pair) — so neither the socket +/// nor the managed-binary probe may assume `~/.codex`. +export function codexHome(home: string, env: NodeJS.ProcessEnv = process.env): string { + const dir = env['CODEX_HOME']; + return dir !== undefined && dir !== '' ? dir : path.join(home, '.codex'); +} + +export interface CodexAttachProbe { + /// Does `/packages/standalone/current/codex` exist? The caller + /// supplies this (an `fs.existsSync`) so the decision itself stays pure. + managedInstallPresent: boolean; +} + +/// Choose the rung. Pure: every input is a value, so the whole decision table +/// is unit-testable without a codex on the box. +export function planCodexAttach( + home: string, + probe: CodexAttachProbe, + env: NodeJS.ProcessEnv = process.env, +): CodexAttachPlan { + const codexBin = env['TERMIPOD_CODEX_BIN'] ?? 'codex'; + const chome = codexHome(home, env); + const sock = controlSocketPath(chome); + if (!probe.managedInstallPresent) { + return { + mode: 'spawn', + argv: [codexBin, 'app-server'], + reason: + 'no installer-managed codex at ' + + managedCodexPath(chome) + + ', so `app-server daemon start` would refuse; running a per-session app server instead', + }; + } + if (sock.length > MAX_UNIX_SOCKET_PATH) { + return { + mode: 'spawn', + argv: [codexBin, 'app-server'], + reason: `control socket path is ${String(sock.length)} bytes, over the ~${String(MAX_UNIX_SOCKET_PATH)}-byte limit for a Unix socket; running a per-session app server instead`, + }; + } + return { + mode: 'daemon', + argv: [codexBin, 'app-server', 'proxy', '--sock', sock], + startArgv: [codexBin, 'app-server', 'daemon', 'start'], + reason: 'attached to the shared codex app-server daemon; this session survives the app and is visible to the codex TUI', + }; +} diff --git a/docs/plans/desktop-companion-vision-parity.md b/docs/plans/desktop-companion-vision-parity.md index fe7c0b91..73403fb6 100644 --- a/docs/plans/desktop-companion-vision-parity.md +++ b/docs/plans/desktop-companion-vision-parity.md @@ -9,9 +9,10 @@ > catalog + loopback WS, split out of L3b). **E3 + E4 shipped > 2026-08-16** (streaming command output; relay result passthrough). > **R4 shipped 2026-08-16** (live output + agent-produced media) — -> **W3 complete**. **F4 shipped 2026-08-16** (user-level MCP reseed -> for claude + codex) — first W4 wedge. Remaining: L3c only, and it -> is deferrable +> **W3 complete**; L3c is deferrable. **W4 started 2026-08-16**: +> **F4 shipped** (user-level MCP reseed for claude + codex) and +> **L4a shipped** (codex attach-rung resolver; L4's WebSocket premise +> was wrong — see the wedge). Left in W4: L4b, R5, R6 > **Audience:** principal · contributors · maintainers > **Last verified vs code:** 2026.730.1231-alpha (`cea267fa`) — every > anchor below re-verified against that tip by the authoring audit @@ -448,15 +449,79 @@ transport rung (lane T), never the renderer's ceiling. meaningful, since a cursor could then outlive the service that issued it. - **L4 — codex via the vendor's service (D-8: use theirs when it - exists).** Prefer **WebSocket attach** to a `codex app-server` - daemon — spawn it detached if absent, authenticate with its bearer - scheme — so the session survives Companion and app restarts and can - be shared with the vendor TUI; stdio spawn-per-session is the - fallback rung only. Text-delta throttle port, parked - approvals/elicitations surface directly as R1 cards (no attention - table locally), `turn/interrupt` cancel, `.codex/config.toml` - seeding. The spawn-fallback rung takes its resume argv from the same - N1 table L3 reads. + exists).** Attach to a shared `codex app-server` where one is + available so the session survives Companion and app restarts and is + visible to the vendor TUI; otherwise run a per-session app server. + Text-delta throttle port, parked approvals/elicitations surface + directly as R1 cards (no attention table locally), `turn/interrupt` + cancel, `.codex/config.toml` seeding. The spawn rung takes its resume + argv from the same N1 table L3 reads. + + **L4a shipped 2026-08-16** — the attach-rung resolver + (`localagent/codexattach.ts`). **This line originally specified a + WebSocket attach with a bearer scheme; no such interface exists.** + Measured against codex-cli **0.147.0** (the installed CLI had also + moved on from the 0.133.0 that E3 was written against): + + 1. **The shared-daemon transport is a Unix domain socket, not a + WebSocket, and there is no bearer scheme.** `codex app-server + daemon start` brings the daemon up and `codex app-server proxy + --sock ` pipes stdio to its control socket at + `/app-server-control/app-server-control.sock`. The + `--code-mode-host ` flag is a different feature (where + *code mode* runs), not the session transport. So the attach rung + needs **no WebSocket client and no token** — it is the same + JSON-RPC-over-stdio we already speak, pointed at another process. + 2. **"Spawn it detached if absent" is not available.** `daemon start` + refuses unless codex was installed by the official installer + script, wanting the managed binary at + `/packages/standalone/current/codex`. An npm / + homebrew / distro codex — including the one on this machine — + therefore has **no daemon rung at all**. + 3. **The rung order inverts.** Per-session stdio is not "the fallback + rung only"; for the common install it is the only rung. The daemon + is an opportunistic upgrade taken when the managed install exists. + + One more measured constraint: the control socket is subject to the + platform `SUN_LEN` cap (~104–108 bytes). A deeply relocated + `CODEX_HOME` fails at connect time with *"path must be shorter than + SUN_LEN"* — a message nobody would attribute to path length — so the + resolver disqualifies the daemon rung up front and says why. + + **Confirmed against a live daemon (2026-08-16).** The dev machine was + moved to the installer-managed standalone codex, so the daemon rung — + previously unreachable here — could be exercised. `codex app-server + daemon start` reports both paths this resolver computes, **exactly**: + `managedCodexPath` = `/packages/standalone/current/codex` + and `socketPath` = + `/app-server-control/app-server-control.sock`. The socket + is created `srw-------` — **filesystem permissions are the auth**, + which is the positive form of "there is no bearer scheme". + + **Open for L4b — the proxy's data path is NOT yet proven.** `codex + app-server proxy --sock ` connects and exits 0 with no stdout + and no stderr for the same `initialize` handshake that works over a + plain `codex app-server` child (E3's probe shape), with stdin held + open. Two candidate causes, neither confirmed: the daemon may require + `codex app-server daemon enable-remote-control` (its help does not say + what that exposes, and the sibling `bootstrap` frames it as + SSH-driven — a security-posture question for the director, not a + default), or the control socket may expect a framing/handshake the + bare JSON-RPC line does not supply. **L4b must not assume the attach + rung works until a round trip is observed**; the resolver deliberately + decides *which argv*, not *that it succeeds*. + + **Discoverability regression to handle in L4b.** The standalone codex + lives at `~/.local/bin/codex` with its PATH entry written into + `.bashrc` — which a GUI-launched Electron app never sources. It is + therefore *less* discoverable than a `/usr/bin/codex` package was. + That is what `TERMIPOD_CODEX_BIN` exists for, and L4b should resolve + the binary the way `kimiweb.ts` already does for kimi + (`findKimiOnPath` + `mergePathDirs`) rather than trusting `PATH`. + + **L4b** (still to do) is the driver itself: the app-server JSON-RPC + client over whichever argv L4a returns, delta throttling, R1 approval + cards, `turn/interrupt`, config seeding. ### Lane E — event-vocabulary gaps (hub; verified per-driver)