Skip to content
Merged
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
85 changes: 85 additions & 0 deletions desktop/electron/src/localagent/codexattach.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
121 changes: 121 additions & 0 deletions desktop/electron/src/localagent/codexattach.ts
Original file line number Diff line number Diff line change
@@ -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 <path>`
/// 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 <WS_URL>` 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 `<CODEX_HOME>/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 `<codexHome>/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',
};
}
89 changes: 77 additions & 12 deletions docs/plans/desktop-companion-vision-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <path>` pipes stdio to its control socket at
`<CODEX_HOME>/app-server-control/app-server-control.sock`. The
`--code-mode-host <WS_URL>` 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
`<CODEX_HOME>/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` = `<CODEX_HOME>/packages/standalone/current/codex`
and `socketPath` =
`<CODEX_HOME>/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 <path>` 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)

Expand Down
Loading