From ad8cefd4adcd0ae7773aa732f8f9a7e1e1e2bbf1 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 22 Sep 2026 20:05:27 -0700 Subject: [PATCH 1/2] fix(codex): read multi-line TOML args and flag non-executable hosts Codex (toml_edit) and `aqe platform setup codex` write MCP `args` as multi-line arrays. Three readers accepted only single-line arrays, so: - rufloCodexMcpStatus saw args=null, reported the canonical `ak x ruflo-mcp` entry as the legacy launcher, and sync re-ran the migration on every invocation; - the AQE embedding TOML editor rejected the project .codex/config.toml as "unsupported AQE arguments encoding", leaving a permanent projection conflict after sync; - codexMcpTopology missed args for recursive-codex / legacy-ruflo tables, hiding duplicate-transport repairs. A shared string-array reader in codex-toml-safety.mjs now handles both forms; comments or non-string elements inside the array stay unsupported. `ak status` also reported an npm-installed host as healthy from its package.json alone. A launcher that fails `--version` (e.g. codex missing @openai/codex-darwin-arm64) now shows as installed but not executable with a reinstall fix, which also explains downstream `codex mcp` failures. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/TROUBLESHOOTING.md | 1 + src/commands/status/sections/hosts.mjs | 52 +++++++++++++++----------- src/lib/aqe-embedding-toml.mjs | 14 +++---- src/lib/codex-toml-safety.mjs | 27 +++++++++++++ src/lib/mcp.mjs | 21 ++++------- src/lib/providers.mjs | 12 ++++++ tests/kit/aqe-embedding-toml.test.mjs | 12 ++++++ tests/kit/host-executable.test.mjs | 44 ++++++++++++++++++++++ tests/kit/reverse-bridge.test.mjs | 21 ++++++++++- 9 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 tests/kit/host-executable.test.mjs diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64013ced..a33ba2cd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -36,6 +36,7 @@ ak sync # apply it | Codex receives automatic deja-vu recall while Agentic Kit says MCP mode | A user-owned Codex deja-vu plugin can contribute session/per-prompt/precompaction hooks independently of Agentic Kit's mode | disable/remove that plugin through Codex if MCP-only behavior is required. `ak sync` preserves external plugins and reports the effective auto surface without claiming a fix | | `--purge-deja-vu-data` refuses the index path | The observed path is broad, relative, outside an approved data root, overlaps config/transcript sources, or crosses a symlink | move/reconfigure the derived index safely, run `deja doctor --offline`, then retry. Never bypass the guard by deleting a host transcript root | | Just upgraded ruflo/agentic-qe (`npm i -g …`) and things feel off | Upgrades re-resolve dependencies: native SQLite bindings and the aidefence package get dropped, and ruflo's helper auto-refresh regenerates the statusline without the footer | `ak sync` (this is its main job) | +| `status` shows a host `installed but not executable` | The npm package is recorded but its launcher fails `--version` — for Codex, usually a missing platform package such as `@openai/codex-darwin-arm64` after an interrupted or cross-platform install. Codex MCP migrations and repairs cannot run until it starts | reinstall the host as the row's fix says (for example `npm install -g @openai/codex@latest`), then `ak sync` | | `status` shows `natives … WASM fallback` | agentdb resolved a non-native better-sqlite3 — on this path **memory writes can silently vanish**. Common causes are npm ≥11.17 blocking install scripts during upgrades, or a stale better-sqlite3 ≤12.9 pin on Node 26 | `ak sync` selects a Node-compatible release and installs the native binding | | `status` shows `aidefence missing` | ruflo ≥3.28 stopped shipping `@claude-flow/aidefence` but `ruflo security defend` still imports it — injection defense is silently non-functional ([ruvnet/ruflo#2670](https://github.com/ruvnet/ruflo/issues/2670)) | `ak sync` reinstalls it; `ak x verify security` proves defend works (exit 1=threat / 0=clean) | | `status` shows oversized RVF store(s) | A runaway append after a hard exit grew a `.rvf` past the 2 GB cap (seen at ~277 GB once) | `ak sync` quarantines the oversized store; agentic-qe rebuilds it | diff --git a/src/commands/status/sections/hosts.mjs b/src/commands/status/sections/hosts.mjs index 4f1a286a..17feb196 100644 --- a/src/commands/status/sections/hosts.mjs +++ b/src/commands/status/sections/hosts.mjs @@ -1,38 +1,48 @@ // hosts (install-if-missing) — cheap: file read + `which`, no network. // An enabled host that is entirely absent is installable by sync; an external -// install (mise/native/brew) is reported but never touched. -import { HOSTS, hostInstallState, hostAuthState } from '../../../lib/providers.mjs'; +// install (mise/native/brew) is reported but never touched. An npm install is +// also launched once (`--version`): its package.json can outlive the binary. +import { HOSTS, hostInstallState, hostAuthState, hostExecutable } from '../../../lib/providers.mjs'; import { row } from '../row.mjs'; +const DEFAULT_DEPS = { installState: hostInstallState, executable: hostExecutable, authState: hostAuthState }; + +// Install row + auth row for a host that is on disk. +async function installedHostRows(h, st, primary, deps) { + const label = `${h.id} ${st.version ?? ''} (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`; + const launch = st.method === 'npm' ? await deps.executable(h) : { ok: true, detail: null }; + const install = launch.ok ? row('hosts', 'ok', label) + : row('hosts', primary ? 'fail' : 'warn', `${label} installed but not executable: ${launch.detail}`, + `reinstall: npm install -g ${h.pkg}@latest`); + // auth mode (billing axis): oauth/subscription ($0) vs metered api-key. + // A distinct row so `ak status --json` (and the dashboard) can badge it. + const auth = deps.authState(h.id, { present: true }); + const billing = auth.billing === 'subscription' ? 'subscription, $0' + : auth.billing === 'metered' ? 'metered' : auth.billing; + return [install, row('hosts', auth.mode === 'none' ? 'warn' : 'ok', + `${h.id} auth: ${auth.mode} (${billing})${auth.source ? ` · ${auth.source}` : ''}${auth.note ? ` — ${auth.note}` : ''}`, + auth.mode === 'none' ? `${h.id} login` : null)]; +} + export default { id: 'hosts', - async collect({ cfg, integrationFacts }) { + /** @param {{ cfg: any, integrationFacts: any, hostDeps?: Partial }} ctx */ + async collect({ cfg, integrationFacts, hostDeps = {} }) { + const deps = { ...DEFAULT_DEPS, ...hostDeps }; const rows = []; try { // primary host absent = fail (nothing can drive); alternate absent = warn. const primaryHost = cfg.routing?.primaryHost ?? 'claude'; for (const h of HOSTS) { if (!cfg.integrations.hosts[h.id]) continue; - const detected = integrationFacts.hosts[h.id]; - if (detected?.present === false) { - rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', - `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); - continue; - } - const st = await hostInstallState(h); + const primary = h.id === primaryHost; + const st = integrationFacts.hosts[h.id]?.present === false + ? { method: 'absent', version: null } : await deps.installState(h); if (st.method === 'absent') { - rows.push(row('hosts', h.id === primaryHost ? 'fail' : 'warn', - `${h.id} enabled but not installed${h.id === primaryHost ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); + rows.push(row('hosts', primary ? 'fail' : 'warn', + `${h.id} enabled but not installed${primary ? ' (primary)' : ''}`, `sync installs ${h.pkg}`)); } else { - rows.push(row('hosts', 'ok', `${h.id} ${st.version ?? ''} (${st.method}${st.method === 'external' ? ' — self-managed' : ''})`)); - // auth mode (billing axis): oauth/subscription ($0) vs metered api-key. - // A distinct row so `ak status --json` (and the dashboard) can badge it. - const auth = hostAuthState(h.id, { present: true }); - const billing = auth.billing === 'subscription' ? 'subscription, $0' - : auth.billing === 'metered' ? 'metered' : auth.billing; - rows.push(row('hosts', auth.mode === 'none' ? 'warn' : 'ok', - `${h.id} auth: ${auth.mode} (${billing})${auth.source ? ` · ${auth.source}` : ''}${auth.note ? ` — ${auth.note}` : ''}`, - auth.mode === 'none' ? `${h.id} login` : null)); + rows.push(...await installedHostRows(h, st, primary, deps)); } } } catch (e) { diff --git a/src/lib/aqe-embedding-toml.mjs b/src/lib/aqe-embedding-toml.mjs index e12127a6..5af7a4bb 100644 --- a/src/lib/aqe-embedding-toml.mjs +++ b/src/lib/aqe-embedding-toml.mjs @@ -1,5 +1,5 @@ // Deliberately narrow TOML editor: unsupported encodings remain user-owned. -import { inspectCodexTomlStructure, isTomlTableLine } from './codex-toml-safety.mjs'; +import { inspectCodexTomlStructure, isTomlTableLine, tomlStringArrayAt } from './codex-toml-safety.mjs'; import { recognizedAqeTransport, parseEmbeddingJson } from './aqe-embedding-transport.mjs'; const BASE = 'mcp_servers.agentic-qe'; const ENV = `${BASE}.env`; @@ -32,7 +32,7 @@ function scalar(text, key) { return parseEmbeddingJson(match[1]); } -function transportAssignment(text, transport) { +function transportAssignment(text, transport, rest) { if (/^env\s*=/.test(text)) throw new Error('inline AQE environment requires manual embedding configuration'); if (/^command\s*=/.test(text)) { if (transport.command !== null) throw new Error('duplicate AQE command'); @@ -40,10 +40,10 @@ function transportAssignment(text, transport) { } if (/^args\s*=/.test(text)) { if (transport.args !== null) throw new Error('duplicate AQE arguments'); - const match = /^args\s*=\s*(\[[^\n]*\])\s*(?:#.*)?$/.exec(text); - if (!match) throw new Error('unsupported AQE arguments encoding'); - transport.args = parseEmbeddingJson(match[1]); - if (!Array.isArray(transport.args)) throw new Error('unsupported AQE arguments shape'); + // `rest` starts at this line so a multi-line array is read whole. + const args = tomlStringArrayAt(rest, 'args'); + if (!args?.value) throw new Error('unsupported AQE arguments encoding'); + transport.args = args.value; } } @@ -73,7 +73,7 @@ export function aqeTomlEnvironment(source) { if (table === 'unrelated') continue; // Dotted/quoted keys can alias a managed table: refuse rather than guessing. if (!/^[A-Za-z0-9_-]+\s*=/.test(text)) throw new Error('dotted or quoted TOML assignments require manual embedding configuration'); - if (table === BASE) transportAssignment(text, transport); + if (table === BASE) transportAssignment(text, transport, source.slice(line.start)); if (table === ENV && new RegExp(`^${KEY}\\s*=`).test(text)) { if (endpoint) throw new Error('duplicate AQE endpoint'); endpoint = { ...line, value: scalar(text, KEY) }; diff --git a/src/lib/codex-toml-safety.mjs b/src/lib/codex-toml-safety.mjs index 6fa8943a..ce63fa7e 100644 --- a/src/lib/codex-toml-safety.mjs +++ b/src/lib/codex-toml-safety.mjs @@ -197,3 +197,30 @@ export function inspectCodexTomlStructure(source) { } export const isTomlTableLine = (line) => TABLE.test(line) || ARRAY_TABLE.test(line); + +// Arrays of basic strings, single- or multi-line (toml_edit, which Codex and +// AQE write through, puts each element on its own line with a trailing comma). +// Comments and non-string elements inside the array stay unsupported. +const ARRAY_STRING = '"(?:[^"\\\\\\r\\n]|\\\\.)*"'; +const STRING_ARRAY = `\\[\\s*(?:${ARRAY_STRING}(?:\\s*,\\s*${ARRAY_STRING})*\\s*,?\\s*)?\\]`; + +function parseStringArray(value) { + try { + const parsed = JSON.parse(value.replace(/,\s*\]$/, ']')); + return Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string') ? parsed : null; + } catch { return null; } +} + +/** Read `key = [..strings..]` at the very start of `text`. Returns + * { value, text } where value is null for an unsupported encoding, or null when + * `text` does not start with that assignment. */ +export function tomlStringArrayAt(text, key) { + const match = new RegExp(`^[\\t ]*${key}[\\t ]*=[\\t ]*(${STRING_ARRAY})[\\t ]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`).exec(text); + return match ? { value: parseStringArray(match[1]), text: match[0] } : null; +} + +/** Find the first `key = [..strings..]` line in a table body (see tomlStringArrayAt). */ +export function findTomlStringArray(body, key) { + const match = new RegExp(`^[\\t ]*${key}[\\t ]*=[\\t ]*(${STRING_ARRAY})[\\t ]*(?:#[^\\r\\n]*)?$`, 'm').exec(body); + return match ? { value: parseStringArray(match[1]), text: match[0] } : null; +} diff --git a/src/lib/mcp.mjs b/src/lib/mcp.mjs index 422112d1..de2d1f86 100644 --- a/src/lib/mcp.mjs +++ b/src/lib/mcp.mjs @@ -13,6 +13,7 @@ import { writeFileWithBackup } from './file-write.mjs'; import { managedAgentBrowserEnv } from './agent-browser.mjs'; import { isRufloMcpTransport } from './ruflo-mcp-transport.mjs'; import { retiredCodexTransport } from './host-alignment.mjs'; +import { findTomlStringArray } from './codex-toml-safety.mjs'; /** Enumerate MCP tool names from the installed package's mcp-tools modules, * grouped by name prefix (family). Returns Map. */ @@ -166,14 +167,6 @@ function tomlString(value) { try { return JSON.parse(value); } catch { return null; } } -function tomlStringArray(value) { - if (!value) return null; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string') ? parsed : null; - } catch { return null; } -} - const sameArgs = (left, right) => JSON.stringify(left) === JSON.stringify(right); const fingerprint = (value) => createHash('sha256').update(value).digest('hex'); @@ -184,7 +177,7 @@ function mcpTableName(table) { /** Read the bounded base-table sections behind Codex MCP registrations. This * is deliberately not a general TOML parser: only a base - * `[mcp_servers.]` table with single-line string command/args facts is + * `[mcp_servers.]` table with string command and string-array args facts is * observed. Extra fields or child tables preserve ownership, except for the * exact kit-managed browser environment on the retired Ruflo transport. */ function codexMcpSections(file, scope) { @@ -203,10 +196,13 @@ function codexMcpSections(file, scope) { const bodyEnd = headers[index + 1]?.index ?? source.length; const body = source.slice(bodyStart, bodyEnd); const command = tomlString(/^\s*command\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m.exec(body)?.[1]); - const args = tomlStringArray(/^\s*args\s*=\s*(\[[^\n]*\])\s*$/m.exec(body)?.[1]); + const argsAssignment = findTomlStringArray(body, 'args'); + const args = argsAssignment?.value ?? null; const enabledValue = /^\s*enabled\s*=\s*(true|false)\s*(?:#.*)?$/m.exec(body)?.[1]; const enabled = enabledValue == null ? undefined : enabledValue === 'true'; - const meaningful = body.split(/\r?\n/).map((line) => line.trim()) + // Count a multi-line args array as the single field it is. + const fieldBody = argsAssignment ? body.replace(argsAssignment.text, 'args = []') : body; + const meaningful = fieldBody.split(/\r?\n/).map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); const exactFields = meaningful.length === 2 && meaningful.some((line) => /^command\s*=/.test(line)) @@ -445,9 +441,8 @@ export function rufloCodexMcpStatus(cfg, { home = os.homedir() } = {}) { const next = rest.search(/^\s*\[/m); const body = rest.slice(0, next < 0 ? rest.length : next); const commandMatch = /^\s*command\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m.exec(body); - const argsMatch = /^\s*args\s*=\s*(\[[^\n]*\])\s*$/m.exec(body); try { if (commandMatch) command = JSON.parse(commandMatch[1]); } catch { /* non-canonical TOML */ } - try { if (argsMatch) args = JSON.parse(argsMatch[1]); } catch { /* non-canonical TOML */ } + args = findTomlStringArray(body, 'args')?.value ?? null; } } catch { /* config absent → not registered */ } return { diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index fa3a48fc..ceced519 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -242,6 +242,18 @@ export async function hostInstallState(host) { return { method: 'absent', version: null }; } +/** Does the installed launcher actually start? An npm install is recorded by + * its package.json alone, which survives a missing platform binary (e.g. + * @openai/codex without @openai/codex-darwin-arm64). Local spawn, no network. + * Returns { ok, detail } where detail is the most telling error line. */ +export async function hostExecutable(host, { runner = run } = {}) { + const r = await runner(host.bin, ['--version'], { timeout: 15_000 }); + if (r.code === 0) return { ok: true, detail: null }; + const lines = `${r.stderr || ''}\n${r.stdout || ''}`.split('\n').map((line) => line.trim()).filter(Boolean); + const detail = lines.find((line) => /^[A-Za-z]*Error\b/.test(line)) ?? lines[0] ?? `exit ${r.code}`; + return { ok: false, detail: detail.slice(0, 160) }; +} + /** How a host is AUTHENTICATED (distinct from how it's installed) — the axis that * drives billing. Grounded, evidence-based (no over-claiming): * - api key env present → 'api-key' (metered). For codex, an api key OVERRIDES a diff --git a/tests/kit/aqe-embedding-toml.test.mjs b/tests/kit/aqe-embedding-toml.test.mjs index 43b907a2..4d69e9e7 100644 --- a/tests/kit/aqe-embedding-toml.test.mjs +++ b/tests/kit/aqe-embedding-toml.test.mjs @@ -14,3 +14,15 @@ test('quoted aliases of AQE tables are recognized without confusing dots within assert.equal(aqeTomlEnvironment(source).current.value, 'http://localhost:11434'); assert.equal(aqeTomlEnvironment('[mcp_servers."agentic-qe.env"]\ncommand = "other"\n').missing, true); }); +test('multi-line AQE args written by aqe/codex are recognized and edited in place', () => { + const base = '[mcp_servers.agentic-qe]\ntype = "stdio"\ncommand = "npx"\nargs = [\n "-y",\n "agentic-qe@latest",\n "mcp",\n]\n\n[mcp_servers.agentic-qe.env]\nAQE_V3_MODE = "true"\n'; + const editor = aqeTomlEnvironment(base); + assert.deepEqual(editor.current, { present: false }); + const next = editor.replace({ present: true, value: 'http://127.0.0.1:11434' }); + assert.ok(next.startsWith(base.slice(0, base.indexOf('[mcp_servers.agentic-qe.env]')))); + assert.equal(aqeTomlEnvironment(next).current.value, 'http://127.0.0.1:11434'); +}); +test('multi-line AQE args with non-string entries or comments stay unsupported', () => { + assert.throws(() => aqeTomlEnvironment('[mcp_servers.agentic-qe]\ncommand = "npx"\nargs = [\n 1,\n]\n'), /arguments encoding/); + assert.throws(() => aqeTomlEnvironment('[mcp_servers.agentic-qe]\ncommand = "npx"\nargs = [\n "-y", # pin\n "agentic-qe",\n]\n'), /arguments encoding/); +}); diff --git a/tests/kit/host-executable.test.mjs b/tests/kit/host-executable.test.mjs new file mode 100644 index 00000000..23c9f4c9 --- /dev/null +++ b/tests/kit/host-executable.test.mjs @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hostExecutable } from '../../src/lib/providers.mjs'; +import hostsSection from '../../src/commands/status/sections/hosts.mjs'; + +const codex = { id: 'codex', bin: 'codex', pkg: '@openai/codex' }; +const brokenStderr = 'file:///x/codex.js:107\n throw new Error(\n\nError: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest\n at findCodexExecutable'; + +test('hostExecutable reports a launcher that cannot start and surfaces the Error line', async () => { + const r = await hostExecutable(codex, { runner: async () => ({ code: 1, stdout: '', stderr: brokenStderr }) }); + assert.equal(r.ok, false); + assert.match(r.detail, /^Error: Missing optional dependency @openai\/codex-darwin-arm64/); +}); + +test('hostExecutable accepts a launcher that answers --version', async () => { + const calls = []; + const r = await hostExecutable(codex, { runner: async (bin, args) => { calls.push([bin, ...args]); return { code: 0, stdout: 'codex-cli 0.156.1\n', stderr: '' }; } }); + assert.deepEqual(r, { ok: true, detail: null }); + assert.deepEqual(calls, [['codex', '--version']]); +}); + +const cfg = { routing: { primaryHost: 'claude' }, integrations: { hosts: { claude: false, codex: true, opencode: false } } }; +const facts = { hosts: { codex: { present: true } } }; + +test('hosts status fails a recorded npm install whose binary cannot start', async () => { + const rows = await hostsSection.collect({ cfg, integrationFacts: facts, hostDeps: { + installState: async () => ({ method: 'npm', version: '0.156.1' }), + executable: async () => ({ ok: false, detail: 'Error: Missing optional dependency @openai/codex-darwin-arm64.' }), + authState: () => ({ mode: 'oauth', billing: 'subscription', source: '~/.codex/auth.json', note: null }), + } }); + const install = rows.find((r) => r.message.startsWith('codex 0.156.1')); + assert.equal(install.level, 'warn'); + assert.match(install.message, /not executable.*Missing optional dependency/); + assert.match(install.fix, /npm install -g @openai\/codex@latest/); +}); + +test('hosts status stays ok when the npm install starts', async () => { + const rows = await hostsSection.collect({ cfg, integrationFacts: facts, hostDeps: { + installState: async () => ({ method: 'npm', version: '0.156.1' }), + executable: async () => ({ ok: true, detail: null }), + authState: () => ({ mode: 'oauth', billing: 'subscription', source: null, note: null }), + } }); + assert.equal(rows.find((r) => r.message.startsWith('codex 0.156.1')).level, 'ok'); +}); diff --git a/tests/kit/reverse-bridge.test.mjs b/tests/kit/reverse-bridge.test.mjs index be8df35c..2e3c962f 100644 --- a/tests/kit/reverse-bridge.test.mjs +++ b/tests/kit/reverse-bridge.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { rufloCodexMcpStatus } from '../../src/lib/mcp.mjs'; +import { rufloCodexMcpStatus, codexMcpTopology } from '../../src/lib/mcp.mjs'; /** Build a temp $HOME containing (or not) ~/.codex/config.toml with given body. */ function tempHome(configBody) { @@ -53,3 +53,22 @@ test('rufloCodexMcpStatus does not match a similarly-named table', () => { const s = rufloCodexMcpStatus({}, { home }); assert.equal(s.registered, false); }); + +// Codex (toml_edit) writes `codex mcp add` arguments as a multi-line array. +test('rufloCodexMcpStatus reads the multi-line args array codex writes', () => { + const home = tempHome('[mcp_servers.ruflo]\ncommand = "ak"\nargs = [\n "x",\n "ruflo-mcp",\n]\n\n[mcp_servers.other]\ncommand = "y"\n'); + const s = rufloCodexMcpStatus({}, { home }); + assert.equal(s.command, 'ak'); + assert.deepEqual(s.args, ['x', 'ruflo-mcp']); +}); + +test('codexMcpTopology reads multi-line args and still classifies exact repair tables', () => { + const home = tempHome('[mcp_servers.claude-flow]\ncommand = "ruflo"\nargs = [\n "mcp",\n "start",\n]\n\n[mcp_servers.codex]\ncommand = "codex"\nargs = [\n "mcp-server",\n]\n'); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-rev-cwd-')); + const topology = codexMcpTopology({ cwd, home }); + const byName = Object.fromEntries(topology.registrations.map((entry) => [entry.name, entry])); + assert.deepEqual(byName['claude-flow'].args, ['mcp', 'start']); + assert.equal(byName['claude-flow'].repairKind, 'legacy-ruflo'); + assert.equal(byName.codex.repairKind, 'recursive-codex'); + assert.equal(topology.selfRegistrations.length, 1); +}); From 1d11f778a36c55b03d689db2de9382c3843b0482 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 22 Sep 2026 20:13:36 -0700 Subject: [PATCH 2/2] fix(sync): verify host CLIs after upgrade and repair broken npm hosts `ak sync`'s versions step upgraded @openai/codex 0.156.0 -> 0.156.1 about eight minutes after OpenAI published it. npm resolved the platform alias (@openai/codex-darwin-arm64 -> @openai/codex@0.156.1-darwin-arm64, published ~5 minutes after the main version), failed to install it, and exited 0: npm silently drops a failed optional dependency. heal.upgradePackage trusted the exit code, reported "upgraded", and left `codex` unable to start, which then broke every later `codex mcp` operation in sync. - installGlobalCli (npm-global-install.mjs): install, prove `bin --version`, and retry once with --prefer-online when the CLI cannot start. installHost and heal.upgradePackage (for host packages, via sync's hostUpgradeOptions) share it, so a broken upgrade is reported and usually repaired in place. - sync `hosts` step: also reinstalls an npm-owned host whose CLI cannot start (external installs untouched), and now runs before the Codex MCP and provider steps that shell out to the host CLIs. - status: the not-executable host row's fix is now `sync reinstalls `. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/TROUBLESHOOTING.md | 2 +- src/commands/status/sections/hosts.mjs | 2 +- src/commands/sync.mjs | 43 ++++++++++++------- src/lib/heal.mjs | 15 +++++-- src/lib/npm-global-install.mjs | 37 ++++++++++++++++- src/lib/providers.mjs | 32 +++++---------- tests/kit/host-executable.test.mjs | 2 +- tests/kit/npm-global-install.test.mjs | 52 +++++++++++++++++++++-- tests/kit/sync-host-repair.test.mjs | 57 ++++++++++++++++++++++++++ 9 files changed, 193 insertions(+), 49 deletions(-) create mode 100644 tests/kit/sync-host-repair.test.mjs diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a33ba2cd..5a4525a6 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -36,7 +36,7 @@ ak sync # apply it | Codex receives automatic deja-vu recall while Agentic Kit says MCP mode | A user-owned Codex deja-vu plugin can contribute session/per-prompt/precompaction hooks independently of Agentic Kit's mode | disable/remove that plugin through Codex if MCP-only behavior is required. `ak sync` preserves external plugins and reports the effective auto surface without claiming a fix | | `--purge-deja-vu-data` refuses the index path | The observed path is broad, relative, outside an approved data root, overlaps config/transcript sources, or crosses a symlink | move/reconfigure the derived index safely, run `deja doctor --offline`, then retry. Never bypass the guard by deleting a host transcript root | | Just upgraded ruflo/agentic-qe (`npm i -g …`) and things feel off | Upgrades re-resolve dependencies: native SQLite bindings and the aidefence package get dropped, and ruflo's helper auto-refresh regenerates the statusline without the footer | `ak sync` (this is its main job) | -| `status` shows a host `installed but not executable` | The npm package is recorded but its launcher fails `--version` — for Codex, usually a missing platform package such as `@openai/codex-darwin-arm64` after an interrupted or cross-platform install. Codex MCP migrations and repairs cannot run until it starts | reinstall the host as the row's fix says (for example `npm install -g @openai/codex@latest`), then `ak sync` | +| `status` shows a host `installed but not executable` | npm exits 0 even when an optional dependency fails, so a package can be recorded without its platform binary. Codex ships its binary as per-platform versions (for example `@openai/codex-darwin-arm64`) published minutes after the main version, so an upgrade in that window can leave `codex` unable to start | `ak sync` reinstalls an npm-owned host and verifies it starts; upgrades and installs already retry once with `--prefer-online`. An external (mise/native/brew) install is reinstalled with its own tool | | `status` shows `natives … WASM fallback` | agentdb resolved a non-native better-sqlite3 — on this path **memory writes can silently vanish**. Common causes are npm ≥11.17 blocking install scripts during upgrades, or a stale better-sqlite3 ≤12.9 pin on Node 26 | `ak sync` selects a Node-compatible release and installs the native binding | | `status` shows `aidefence missing` | ruflo ≥3.28 stopped shipping `@claude-flow/aidefence` but `ruflo security defend` still imports it — injection defense is silently non-functional ([ruvnet/ruflo#2670](https://github.com/ruvnet/ruflo/issues/2670)) | `ak sync` reinstalls it; `ak x verify security` proves defend works (exit 1=threat / 0=clean) | | `status` shows oversized RVF store(s) | A runaway append after a hard exit grew a `.rvf` past the 2 GB cap (seen at ~277 GB once) | `ak sync` quarantines the oversized store; agentic-qe rebuilds it | diff --git a/src/commands/status/sections/hosts.mjs b/src/commands/status/sections/hosts.mjs index 17feb196..96283e4b 100644 --- a/src/commands/status/sections/hosts.mjs +++ b/src/commands/status/sections/hosts.mjs @@ -13,7 +13,7 @@ async function installedHostRows(h, st, primary, deps) { const launch = st.method === 'npm' ? await deps.executable(h) : { ok: true, detail: null }; const install = launch.ok ? row('hosts', 'ok', label) : row('hosts', primary ? 'fail' : 'warn', `${label} installed but not executable: ${launch.detail}`, - `reinstall: npm install -g ${h.pkg}@latest`); + `sync reinstalls ${h.pkg}`); // auth mode (billing axis): oauth/subscription ($0) vs metered api-key. // A distinct row so `ak status --json` (and the dashboard) can badge it. const auth = deps.authState(h.id, { present: true }); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index cdecfc60..98f7aa98 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -19,7 +19,7 @@ import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-regis import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { commandHosts, hostInstallState, installHost, convergeProviderStack, guidanceContext, reportRetiredRouteChanges } from '../lib/providers.mjs'; +import { HOSTS, commandHosts, hostInstallState, hostExecutable, installHost, convergeProviderStack, guidanceContext, reportRetiredRouteChanges } from '../lib/providers.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs'; import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs'; @@ -126,6 +126,14 @@ Examples: // report, step, state}. `state` carries the two cross-step signals // (`dejaVuApplyFailed`, `aqeRouterApplyFailure`) the final convergence check // needs — the only state that survives past its own step. +const HOST_LIFECYCLE = { installState: hostInstallState, executable: hostExecutable, install: installHost }; + +/** A host package's CLI must start after an upgrade, not just extract. */ +export function hostUpgradeOptions(pkg) { + const host = HOSTS.find((h) => h.pkg === pkg); + return host ? { bin: host.bin } : {}; +} + export const SYNC_STEPS = [ { id: 'agent-browser', @@ -141,6 +149,24 @@ export const SYNC_STEPS = [ return result; }, }, + // hosts: install any ENABLED host that is entirely absent, and reinstall an + // npm-owned host whose CLI cannot start (npm exits 0 after dropping a failed + // optional platform binary). External installs are never touched; updates + // ride the `versions` step via driftReport. Runs before the Codex MCP and + // provider steps, which shell out to the host CLIs. + { + id: 'hosts', + when: (subs) => subs.has('hosts'), + run: async (ctx) => { + const { installState, executable, install } = { ...HOST_LIFECYCLE, ...ctx.hostLifecycle }; + for (const h of commandHosts()) { + if (!ctx.cfg.integrations.hosts[h.id]) continue; + const { method } = await installState(h); + if (method === 'absent') await ctx.step(`install ${h.id}`, () => install(h.id)); + else if (method === 'npm' && !(await executable(h)).ok) await ctx.step(`repair ${h.id}`, () => install(h.id)); + } + }, + }, { id: 'codex-mcp-repair', when: (subs) => subs.has('codex-mcp'), @@ -170,7 +196,7 @@ export const SYNC_STEPS = [ // No force here: the pre-plan refresh in run() already ran for every // non-dry-run, non-no-upgrade sync, so this read hits that fresh cache. for (const d of await driftReport()) { - if (d.outdated || !d.installed) await ctx.step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg)); + if (d.outdated || !d.installed) await ctx.step(`upgrade ${d.pkg}`, () => heal.upgradePackage(d.pkg, hostUpgradeOptions(d.pkg))); } }, }, @@ -283,19 +309,6 @@ export const SYNC_STEPS = [ } }, }, - // hosts: install any ENABLED host that is entirely absent (updates to - // npm-managed hosts ride the `versions` step above via driftReport). - { - id: 'hosts', - when: (subs) => subs.has('hosts'), - run: async (ctx) => { - for (const h of commandHosts()) { - if (!ctx.cfg.integrations.hosts[h.id]) continue; - if ((await hostInstallState(h)).method !== 'absent') continue; - await ctx.step(`install ${h.id}`, () => installHost(h.id)); - } - }, - }, // Managed companion convergence is independent from host lifecycle // adapters. The adapter owns exact package/target/index ordering and mutates // only its in-memory ownership ledger; this command owns persistence. Save a diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index c310508e..167714da 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -14,7 +14,7 @@ import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; import { INSTALL_SPEC, INSTALL_ARGS, RELEASE_ASSET as RB_RELEASE_ASSET, NIGHTLY_LABEL as RB_NIGHTLY_LABEL, nightlyAgentPlist as rbNightlyPlist, present as rbPresent, latestRelease as rbLatestRelease, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; import { PKG as ADB_PKG, present as adbPresent, coherence as adbCoherence } from './agentdb.mjs'; -import { globalInstallArgs } from './npm-global-install.mjs'; +import { globalInstallArgs, installGlobalCli } from './npm-global-install.mjs'; // NB: `--allow-scripts` is rejected for project-scoped installs (EALLOWSCRIPTS, // npm >=11.17) — it is a global-install flag only. Plain installs still get @@ -153,9 +153,16 @@ export function healRvf(projectAqeDir) { return { ok: true, detail: removed.length ? `quarantined: ${removed.join(', ')}` : 'healthy' }; } -/** Upgrade a global package to latest (with allow-scripts). */ -export async function upgradePackage(pkg) { - const r = await run('npm', globalInstallArgs(`${pkg}@latest`), +/** Upgrade a global package to latest (with allow-scripts). With `bin`, the + * package's CLI must also start afterwards (see installGlobalCli). + * @param {string} pkg + * @param {{ bin?: string|null, runner?: typeof run, sleep?: (ms: number) => Promise }} [opts] */ +export async function upgradePackage(pkg, { bin = null, runner = run, sleep } = {}) { + if (bin) { + const r = await installGlobalCli(`${pkg}@latest`, bin, { runner, sleep }); + return { ok: r.ok, detail: r.ok ? (r.retried ? 'upgraded (missing platform files repaired on retry)' : 'upgraded') : r.detail }; + } + const r = await runner('npm', globalInstallArgs(`${pkg}@latest`), { timeout: 600_000 }); return { ok: r.code === 0, detail: r.code === 0 ? 'upgraded' : r.stderr.split('\n').slice(-3).join(' ') }; } diff --git a/src/lib/npm-global-install.mjs b/src/lib/npm-global-install.mjs index cf47c9d2..c9c254a1 100644 --- a/src/lib/npm-global-install.mjs +++ b/src/lib/npm-global-install.mjs @@ -3,6 +3,8 @@ // the npm release and local policy, an unlisted lifecycle may be warned about // or denied. Passing the reviewed list keeps installation behavior explicit and // prevents the initial-host path from drifting from the upgrade/heal path. +import { run } from './exec.mjs'; + export const REVIEWED_GLOBAL_INSTALL_SCRIPTS = Object.freeze([ 'ruflo', 'agentic-qe', @@ -35,7 +37,38 @@ export const REVIEWED_GLOBAL_INSTALL_SCRIPTS = Object.freeze([ export const reviewedGlobalInstallScripts = () => REVIEWED_GLOBAL_INSTALL_SCRIPTS.join(','); -export function globalInstallArgs(spec) { +export function globalInstallArgs(spec, { preferOnline = false } = {}) { if (typeof spec !== 'string' || !spec.trim()) throw new TypeError('global npm install spec is required'); - return ['install', '-g', `--allow-scripts=${reviewedGlobalInstallScripts()}`, spec]; + return ['install', '-g', ...(preferOnline ? ['--prefer-online'] : []), + `--allow-scripts=${reviewedGlobalInstallScripts()}`, spec]; +} + +const firstLine = (r) => (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n') + .map((line) => line.trim()).find((line) => /^[A-Za-z]*Error\b/.test(line)) + ?? (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n')[0]; +const failure = (r) => (r.stderr || `exit ${r.code}`).split('\n').slice(-2).join(' ').slice(0, 200); +const pause = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +/** Install a global package that provides a CLI, then prove `bin --version`. + * npm exit 0 is not viability evidence: npm silently drops an optional + * dependency that fails to fetch or build. Platform-binary packages (Codex's + * `@openai/codex--` aliases) are published minutes after the main + * version, so an upgrade inside that window can leave a launcher with no + * binary. One `--prefer-online` retry revalidates cached registry metadata + * and restores the missing optional dependency. + * @returns {Promise<{ok: boolean, changed: boolean, retried: boolean, detail: string}>} */ +export async function installGlobalCli(spec, bin, { runner = run, sleep = pause, timeout = 600_000 } = {}) { + let retried = false; + let verify = null; + for (const preferOnline of [false, true]) { + if (preferOnline) { retried = true; await sleep(5_000); } + const r = await runner('npm', globalInstallArgs(spec, { preferOnline }), { timeout }); + if (r.code !== 0) return { ok: false, changed: retried, retried, detail: failure(r) }; + verify = await runner(bin, ['--version'], { timeout: 15_000 }); + if (verify.code === 0) { + return { ok: true, changed: true, retried, detail: retried ? `installed ${spec} (repaired on retry)` : `installed ${spec}` }; + } + } + return { ok: false, changed: true, retried, + detail: `installed package but ${bin} --version failed: ${firstLine(verify).slice(0, 160)}` }; } diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index ceced519..8e0d1708 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -45,7 +45,7 @@ import { opencodeMcpStatus } from './opencode.mjs'; import { codexMcpStatus, rufloCodexMcpStatus } from './mcp.mjs'; import { projectedAqeExternalProviders } from './adapters/aqe-provider.mjs'; import { applyAqeRouter, aqeRouterDrift, undoAqeRouter } from './aqe-router.mjs'; -import { globalInstallArgs } from './npm-global-install.mjs'; +import { installGlobalCli } from './npm-global-install.mjs'; // The AQE-router convergence pipeline itself lives in aqe-router.mjs // (ADR-0037); re-exported here so every existing `./providers.mjs` import @@ -284,29 +284,17 @@ export function hostAuthState(id, { env = process.env, present = true, home = os return { mode: 'none', billing: 'unknown', source: null, note: null }; } -/** Install a missing host globally via npm. Intended for the 'absent' case only — - * callers check hostInstallState first so an external install is never shadowed. */ -export async function installHost(id, { runner = run } = {}) { +/** Install a missing host globally via npm, or reinstall an npm-owned host whose + * CLI cannot start. Never for an external install — callers check + * hostInstallState first so a mise/native/brew install is never shadowed. + * @param {string} id + * @param {{ runner?: typeof run, sleep?: (ms: number) => Promise }} [opts] */ +export async function installHost(id, { runner = run, sleep } = {}) { const host = HOSTS.find((h) => h.id === id); if (!host) return { ok: false, detail: `unknown host: ${id}` }; - const r = await runner('npm', globalInstallArgs(`${host.pkg}@latest`), { timeout: 600_000 }); - if (r.code !== 0) { - return { - ok: false, changed: false, - detail: (r.stderr || `exit ${r.code}`).split('\n').slice(-2).join(' ').slice(0, 200), - }; - } - // npm exit 0 proves package extraction, not that a lifecycle-created CLI is - // usable. This catches the Claude Code stub state that motivated issue #189 - // and benefits every managed host without executing a session or network call. - const verify = await runner(host.bin, ['--version'], { timeout: 15_000 }); - if (verify.code !== 0) { - return { - ok: false, changed: true, - detail: `installed package but ${host.bin} --version failed: ${(verify.stderr || verify.stdout || `exit ${verify.code}`).trim().split('\n')[0].slice(0, 160)}`, - }; - } - return { ok: true, changed: true, detail: `installed ${host.pkg}` }; + // npm exit 0 proves package extraction, not that a lifecycle-created CLI or + // an optional platform binary is usable (issue #189; codex platform alias). + return installGlobalCli(`${host.pkg}@latest`, host.bin, { runner, sleep }); } // NOTE: host UPDATES ride versions.mjs `driftReport` (which lists the host diff --git a/tests/kit/host-executable.test.mjs b/tests/kit/host-executable.test.mjs index 23c9f4c9..8547f35c 100644 --- a/tests/kit/host-executable.test.mjs +++ b/tests/kit/host-executable.test.mjs @@ -31,7 +31,7 @@ test('hosts status fails a recorded npm install whose binary cannot start', asyn const install = rows.find((r) => r.message.startsWith('codex 0.156.1')); assert.equal(install.level, 'warn'); assert.match(install.message, /not executable.*Missing optional dependency/); - assert.match(install.fix, /npm install -g @openai\/codex@latest/); + assert.equal(install.fix, 'sync reinstalls @openai/codex'); }); test('hosts status stays ok when the npm install starts', async () => { diff --git a/tests/kit/npm-global-install.test.mjs b/tests/kit/npm-global-install.test.mjs index ec96b7fb..27c48d82 100644 --- a/tests/kit/npm-global-install.test.mjs +++ b/tests/kit/npm-global-install.test.mjs @@ -2,10 +2,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - REVIEWED_GLOBAL_INSTALL_SCRIPTS, globalInstallArgs, + REVIEWED_GLOBAL_INSTALL_SCRIPTS, globalInstallArgs, installGlobalCli, } from '../../src/lib/npm-global-install.mjs'; import { installHost } from '../../src/lib/providers.mjs'; -import { selfUpdate } from '../../src/lib/heal.mjs'; +import { selfUpdate, upgradePackage } from '../../src/lib/heal.mjs'; test('reviewed global lifecycle policy includes Claude Code postinstall', () => { assert.ok(REVIEWED_GLOBAL_INSTALL_SCRIPTS.includes('@anthropic-ai/claude-code')); @@ -36,7 +36,7 @@ test('initial host install uses the same reviewed lifecycle policy and verifies }); test('npm success is not reported as a usable host when the installed CLI cannot start', async () => { - const result = await installHost('claude', { + const result = await installHost('claude', { sleep: async () => {}, runner: async (_bin, args) => args[0] === 'install' ? { code: 0, stdout: '', stderr: '' } : { code: 1, stdout: '', stderr: 'postinstall did not materialize the executable' }, @@ -58,3 +58,49 @@ test('the kit self-update uses the same reviewed lifecycle policy', async () => assert.equal(result.ok, true); assert.deepEqual(calls[0].args, globalInstallArgs('@pacphi/agentic-kit@4.2.0')); }); + +// npm exits 0 after silently dropping a failed optional dependency — how codex +// lost @openai/codex-darwin-arm64 when a sync upgrade landed minutes after +// OpenAI's staggered per-platform publish. One revalidating retry repairs it. +const missingPlatform = { code: 1, stdout: '', stderr: 'Error: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex' }; +const noSleep = async () => {}; + +test('a CLI left non-executable is reinstalled once with --prefer-online', async () => { + const calls = []; + let versionChecks = 0; + const result = await installGlobalCli('@openai/codex@latest', 'codex', { sleep: noSleep, + runner: async (bin, args) => { + calls.push([bin, ...args]); + if (bin === 'npm') return { code: 0, stdout: '', stderr: '' }; + return ++versionChecks === 1 ? missingPlatform : { code: 0, stdout: 'codex-cli 0.156.1\n', stderr: '' }; + } }); + assert.equal(result.ok, true); + assert.equal(result.retried, true); + assert.deepEqual(calls.map((c) => c[0]), ['npm', 'codex', 'npm', 'codex']); + assert.deepEqual(calls[2].slice(1), globalInstallArgs('@openai/codex@latest', { preferOnline: true })); + assert.ok(calls[2].includes('--prefer-online')); +}); + +test('a CLI that still cannot start after the retry is a failure with the real error line', async () => { + const result = await installGlobalCli('@openai/codex@latest', 'codex', { sleep: noSleep, + runner: async (bin) => (bin === 'npm' ? { code: 0, stdout: '', stderr: '' } : missingPlatform) }); + assert.equal(result.ok, false); + assert.equal(result.changed, true); + assert.match(result.detail, /codex --version failed.*Missing optional dependency @openai\/codex-darwin-arm64/); +}); + +test('an executable CLI is verified once, without a retry', async () => { + const calls = []; + const result = await installGlobalCli('@openai/codex@latest', 'codex', { sleep: noSleep, + runner: async (bin, args) => { calls.push([bin, ...args]); return { code: 0, stdout: '0.156.1', stderr: '' }; } }); + assert.equal(result.ok, true); + assert.equal(result.retried, false); + assert.equal(calls.length, 2); +}); + +test('upgradePackage verifies a host CLI instead of trusting npm exit 0', async () => { + const result = await upgradePackage('@openai/codex', { bin: 'codex', sleep: noSleep, + runner: async (bin) => (bin === 'npm' ? { code: 0, stdout: '', stderr: '' } : missingPlatform) }); + assert.equal(result.ok, false); + assert.match(result.detail, /Missing optional dependency/); +}); diff --git a/tests/kit/sync-host-repair.test.mjs b/tests/kit/sync-host-repair.test.mjs new file mode 100644 index 00000000..faf81391 --- /dev/null +++ b/tests/kit/sync-host-repair.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sandboxHome } from './helpers/home-sandbox.mjs'; + +sandboxHome('ak-sync-host-repair'); +const sync = await import('../../src/commands/sync.mjs'); + +const hostsStep = sync.SYNC_STEPS.find((s) => s.id === 'hosts'); +const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; + +async function runHosts(lifecycle) { + const steps = []; + const installs = []; + await hostsStep.run({ cfg, hostLifecycle: { + ...lifecycle, + install: async (id) => { installs.push(id); return { ok: true, detail: 'installed' }; }, + }, step: async (label, fn) => { steps.push(label); return fn(); } }); + return { steps, installs }; +} + +test('sync reinstalls an npm-owned host whose CLI cannot start', async () => { + const r = await runHosts({ + installState: async () => ({ method: 'npm', version: '0.156.1' }), + executable: async () => ({ ok: false, detail: 'Error: Missing optional dependency @openai/codex-darwin-arm64.' }), + }); + assert.deepEqual(r.steps, ['repair codex']); + assert.deepEqual(r.installs, ['codex']); +}); + +test('sync leaves an executable npm host and any external host alone', async () => { + for (const method of ['npm', 'external']) { + const r = await runHosts({ + installState: async () => ({ method, version: '0.156.1' }), + executable: async () => { if (method === 'external') throw new Error('external hosts are not probed'); return { ok: true }; }, + }); + assert.deepEqual(r.installs, [], method); + } +}); + +test('sync still installs an absent enabled host', async () => { + const r = await runHosts({ + installState: async () => ({ method: 'absent', version: null }), + executable: async () => { throw new Error('absent hosts are not probed'); }, + }); + assert.deepEqual(r.steps, ['install codex']); +}); + +test('the hosts step runs before Codex MCP repair and the provider stack that use the CLI', () => { + const order = sync.SYNC_STEPS.map((s) => s.id); + assert.ok(order.indexOf('hosts') < order.indexOf('codex-mcp-repair')); + assert.ok(order.indexOf('hosts') < order.indexOf('providers')); +}); + +test('the versions step verifies host CLIs it upgrades', () => { + assert.equal(sync.hostUpgradeOptions('@openai/codex').bin, 'codex'); + assert.deepEqual(sync.hostUpgradeOptions('ruflo'), {}); +});