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
1 change: 1 addition & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 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 |
Expand Down
52 changes: 31 additions & 21 deletions src/commands/status/sections/hosts.mjs
Original file line number Diff line number Diff line change
@@ -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}`,
`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 });
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<typeof DEFAULT_DEPS> }} 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) {
Expand Down
43 changes: 28 additions & 15 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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';
Expand Down Expand Up @@ -126,6 +126,14 @@
// 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',
Expand All @@ -141,6 +149,24 @@
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'),
Expand Down Expand Up @@ -170,7 +196,7 @@
// 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)));
}
},
},
Expand Down Expand Up @@ -283,19 +309,6 @@
}
},
},
// 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
Expand Down Expand Up @@ -498,7 +511,7 @@
},
];

export async function run({

Check warning on line 514 in src/commands/sync.mjs

View workflow job for this annotation

GitHub Actions / quality (typecheck, lint, build, audit)

Async function 'run' has a complexity of 33. Maximum allowed is 25
flags,
pkgRoot,
fetchLatest,
Expand Down
14 changes: 7 additions & 7 deletions src/lib/aqe-embedding-toml.mjs
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down Expand Up @@ -32,18 +32,18 @@ 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');
transport.command = scalar(text, 'command');
}
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;
}
}

Expand Down Expand Up @@ -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) };
Expand Down
27 changes: 27 additions & 0 deletions src/lib/codex-toml-safety.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 11 additions & 4 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> }} [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(' ') };
}
Expand Down
21 changes: 8 additions & 13 deletions src/lib/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<family, string[]>. */
Expand Down Expand Up @@ -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');

Expand All @@ -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.<name>]` table with single-line string command/args facts is
* `[mcp_servers.<name>]` 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) {
Expand All @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading