From 4fa47b8d598476b16b28a89c8c134516a4949aec Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 16 Aug 2026 11:23:30 +0000 Subject: [PATCH 1/2] feat(desktop): codex attach-rung resolver (L4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L4's plan line specified a WebSocket attach to a `codex app-server` daemon, authenticated with a bearer scheme, with stdio spawn-per-session as "the fallback rung only". Measured against the installed codex-cli 0.147.0, all three parts are wrong, so this lands the corrected decision as a pure, tested resolver before any driver is written against it. - 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, `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. The attach rung therefore needs no WebSocket client and no token — it is the same JSON-RPC-over-stdio we already speak, pointed at another process. - "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 this machine's — has no daemon rung at all. - So the rung order inverts: per-session stdio is not a fallback, it is the only rung for the common install, and the daemon is an opportunistic upgrade. Also measured: 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", which nobody would attribute to path length — so the resolver disqualifies the daemon rung up front and returns a reason saying so. CODEX_HOME relocation is honoured for both the socket and the managed-binary probe, the same way F4 honours CLAUDE_CONFIG_DIR. The probe (does the managed binary exist?) is an input, so the whole decision table is unit-testable with no codex on the box. 7 tests, 5 mutations checked, all killed. Electron suite 634 pass / 0 fail, tsc and doc lints green. L4b — the app-server JSON-RPC driver over whichever argv this returns, delta throttling, R1 approval cards, turn/interrupt, config seeding — is still to do. Co-Authored-By: Claude Opus 5 --- .../src/localagent/codexattach.test.ts | 85 ++++++++++++ .../electron/src/localagent/codexattach.ts | 121 ++++++++++++++++++ docs/plans/desktop-companion-vision-parity.md | 58 +++++++-- 3 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 desktop/electron/src/localagent/codexattach.test.ts create mode 100644 desktop/electron/src/localagent/codexattach.ts 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..81fdcd0a 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,48 @@ 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. + + **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) From 0508aaf83a20beae6b88c9c3b4972634f8fba24b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 16 Aug 2026 11:54:44 +0000 Subject: [PATCH 2/2] docs(plan): confirm L4a's paths against a live codex daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev machine was moved from the npm codex to the installer-managed standalone, which makes the daemon rung reachable here for the first time. Exercising it confirms both paths the L4a resolver computes, exactly: `daemon start` reports managedCodexPath = /packages/standalone/current/codex and socketPath = /app-server-control/app-server-control.sock. The socket is created srw------- — filesystem permissions are the auth, the positive form of "there is no bearer scheme". Records two things L4b must not inherit as assumptions: - The proxy's DATA path is unproven. `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, stdin held open. Candidates: the daemon may need `enable-remote-control` (undocumented in its help, and the sibling `bootstrap` frames it as SSH-driven, so it is a security-posture call for the director, not a default), or the control socket may want a framing the bare JSON-RPC line does not supply. The resolver decides WHICH ARGV, never THAT IT SUCCEEDS. - Discoverability regressed: the standalone codex lives at ~/.local/bin/codex with its PATH entry in .bashrc, which a GUI-launched Electron app never sources — less discoverable than the /usr/bin/codex package was. L4b should resolve the binary the way kimiweb.ts already does for kimi, not trust PATH. Doc-only; no version bump. Co-Authored-By: Claude Opus 5 --- docs/plans/desktop-companion-vision-parity.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/plans/desktop-companion-vision-parity.md b/docs/plans/desktop-companion-vision-parity.md index 81fdcd0a..73403fb6 100644 --- a/docs/plans/desktop-companion-vision-parity.md +++ b/docs/plans/desktop-companion-vision-parity.md @@ -488,6 +488,37 @@ transport rung (lane T), never the renderer's ceiling. 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.