From 3d010f941a66c56b2451f105da1b1471c63c62dc Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 10 Sep 2026 11:34:51 -0700 Subject: [PATCH 1/4] fix(hosts): enforce supported transport alignment and recurring repairs --- src/commands/run.mjs | 9 + src/commands/setup.mjs | 26 +- src/commands/status/sections/codex-mcp.mjs | 2 +- .../status/sections/host-alignment.mjs | 12 + src/commands/status/sections/index.mjs | 3 +- src/commands/sync.mjs | 53 ++-- src/commands/x/host-align.mjs | 71 +++++ src/commands/x/host.mjs | 14 +- src/lib/codex-mcp-reconcile.mjs | 99 ++++++ src/lib/host-alignment.mjs | 254 ++++++++++++++++ src/lib/mcp.mjs | 14 +- src/lib/providers.mjs | 4 +- src/lib/ruflo-mcp-transport.mjs | 12 + src/lib/trust-manifest.mjs | 2 +- tests/kit/codex-mcp-convergence.test.mjs | 281 ++++++++++++++++++ tests/kit/codex-mcp.test.mjs | 12 +- tests/kit/host-alignment.test.mjs | 213 +++++++++++++ tests/kit/setup-command.test.mjs | 2 +- tests/kit/sync-command.test.mjs | 2 +- 19 files changed, 1050 insertions(+), 35 deletions(-) create mode 100644 src/commands/status/sections/host-alignment.mjs create mode 100644 src/commands/x/host-align.mjs create mode 100644 src/lib/codex-mcp-reconcile.mjs create mode 100644 src/lib/host-alignment.mjs create mode 100644 src/lib/ruflo-mcp-transport.mjs create mode 100644 tests/kit/codex-mcp-convergence.test.mjs create mode 100644 tests/kit/host-alignment.test.mjs diff --git a/src/commands/run.mjs b/src/commands/run.mjs index e53080e6..e17c4341 100644 --- a/src/commands/run.mjs +++ b/src/commands/run.mjs @@ -139,6 +139,15 @@ export async function run({ flags, positionals, executePlan = executeRunPlan, cf maxConcurrent = positiveInt(flags['max-concurrent'], 'max-concurrent'); timeoutMs = positiveInt(flags.timeout, 'timeout', { ceiling: 2_147_483_647 }); } catch (error) { fail(error.message); return 2; } + const { inspectHostAlignment, publicHostAlignment } = await import('../lib/host-alignment.mjs'); + const alignment = inspectHostAlignment(); + const usedHosts = new Set(plan.workers.flatMap(worker => [worker.host, ...(worker.escalate ?? []).map(rung => rung.host)])); + if (alignment.findings.some(finding => finding.level === 'fail' && usedHosts.has(finding.host))) { + const error = 'Host transport anomaly: run ak host align to review and offer correction before delegation'; + if (flags.json) console.log(JSON.stringify({ error, alignment: publicHostAlignment(alignment) }, null, 2)); + else fail(error); + return 1; + } if (!flags.json) printPlan(plan); const results = await executePlan(plan, { maxConcurrent, timeoutMs, escalate: !!flags.escalate }); if (flags.json) console.log(JSON.stringify({ plan, results }, null, 2)); diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 0fb23e55..2aa73801 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -17,6 +17,8 @@ import { register as mcpRegister, applyExclusions, registrationStatus, agentBrowserMcpConfigured, codexMcpTopology, codexMcpRepairPlan, repairCodexMcpTopology, } from '../lib/mcp.mjs'; +import { reconcileCodexMcp } from '../lib/codex-mcp-reconcile.mjs'; +import { alignHosts } from './x/host-align.mjs'; import { reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; @@ -821,10 +823,12 @@ const DEFAULT_SETUP_RUNTIME = Object.freeze({ function setupCodexRepairPlan(cfg, cwd, willConfigureProject, inspectTopology) { if (!cfg.integrations?.hosts?.codex) return []; - const plan = codexMcpRepairPlan(inspectTopology({ cwd })); + const topology = inspectTopology({ cwd }); + const plan = codexMcpRepairPlan(topology); if (willConfigureProject) return plan; return plan.filter((target) => - target.scope === 'user' && target.repairKind === 'recursive-codex'); + target.scope === 'user' && (target.repairKind === 'recursive-codex' + || topology.effectiveRufloRegistrations.some(entry => entry.name === 'ruflo'))); } async function applySetupCodexRepairs(flags, repairPlan, cwd, repairTopology) { @@ -874,7 +878,7 @@ export async function run({ if (!(await runtime.machineSetup({ flags, pkgRoot, cfg }))) return 1; if (!(await applySetupCodexRepairs( - flags, repairPlan, process.cwd(), runtime.repairCodexTopology, + flags, repairPlan.filter(entry => entry.repairKind === 'recursive-codex'), process.cwd(), runtime.repairCodexTopology, ))) return 1; // Companion execution is deliberately sequenced after every enabled host is // installed and its lifecycle wiring has converged. It never uses upstream @@ -897,6 +901,22 @@ export async function run({ info('not inside a project (no .git here) — run `ak setup` from a repo to set one up'); } if (!flags['dry-run']) await runtime.finalizeSetup(cfg, pkgRoot, flags); + if (!flags['dry-run'] && await alignHosts({ + flags: { apply: true, yes: flags.yes }, roots: willConfigureProject ? [process.cwd()] : [], cfg, + confirm: question => confirm(question, false, flags.yes), + }) !== 0) return 1; + + if (!flags['dry-run'] && (willConfigureProject + || runtime.inspectCodexTopology({ cwd: process.cwd() }).duplicateRuflo)) { + const finalMcp = await reconcileCodexMcp({ + cfg, cwd: process.cwd(), yes: flags.yes, + confirm: question => confirm(question, false, flags.yes), + inspect: runtime.inspectCodexTopology, repair: runtime.repairCodexTopology, + includeProject: willConfigureProject, + approvedTargets: repairPlan, + }); + if (!finalMcp.ok) { reportOutcome('Codex MCP convergence', finalMcp); return 1; } + } console.log(''); ok(bold('setup complete — `agentic-kit` anytime for status, `ak sync` after upgrades')); diff --git a/src/commands/status/sections/codex-mcp.mjs b/src/commands/status/sections/codex-mcp.mjs index c2cebbf4..dfd7e271 100644 --- a/src/commands/status/sections/codex-mcp.mjs +++ b/src/commands/status/sections/codex-mcp.mjs @@ -70,7 +70,7 @@ function topologyRows(cwd) { if (topology.duplicateRuflo) { rows.push(row('codex-mcp', 'warn', `duplicate Ruflo MCP registrations in Codex: ${topology.effectiveRufloRegistrations.map((entry) => entry.name).join(', ')}`, - 'keep the workspace-aware [mcp_servers.ruflo] entry and remove legacy duplicates after reviewing ownership')); + 'run: ak sync — offers a backed-up repair and remembers approved user-scope legacy corrections; custom entries require review')); } } catch (e) { rows.push(row('codex-mcp', 'warn', `Codex MCP topology check unavailable: ${e.message}`)); diff --git a/src/commands/status/sections/host-alignment.mjs b/src/commands/status/sections/host-alignment.mjs new file mode 100644 index 00000000..e23f0eeb --- /dev/null +++ b/src/commands/status/sections/host-alignment.mjs @@ -0,0 +1,12 @@ +import { inspectHostAlignment } from '../../../lib/host-alignment.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'host-alignment', + collect({ cwd }) { + const report = inspectHostAlignment({ projectRoots: [cwd] }); + return report.findings.map(finding => row('host-alignment', finding.level, + `${finding.host}/${finding.scope}: ${finding.message} (${finding.file})`, + finding.level === 'fail' ? 'run: ak host align --apply (or add --all-projects to review the project census)' : null)); + }, +}; diff --git a/src/commands/status/sections/index.mjs b/src/commands/status/sections/index.mjs index 46f0245b..d8d87aa9 100644 --- a/src/commands/status/sections/index.mjs +++ b/src/commands/status/sections/index.mjs @@ -27,6 +27,7 @@ import agentBrowser from './agent-browser.mjs'; import mcp from './mcp.mjs'; import codexMcp from './codex-mcp.mjs'; import codexPlugins from './codex-plugins.mjs'; +import hostAlignment from './host-alignment.mjs'; import hosts from './hosts.mjs'; import providersStatus from './providers-status.mjs'; @@ -45,7 +46,7 @@ import qeCourt from './qe-court.mjs'; export const SECTIONS_BEFORE_HOST_DETAIL = [ models, versions, ruvnetBrain, ruvector, self, natives, memoryPin, projectMemory, scaffoldAgents, npx, security, learning, aqe, agentdb, agentBrowser, mcp, - codexMcp, codexPlugins, + codexMcp, codexPlugins, hostAlignment, ]; // Everything from `hosts` onward — after those direct calls. diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 3190ad75..30dfcfd6 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -32,6 +32,18 @@ import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs'; import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs'; import { ensureAgentBrowser } from '../lib/agent-browser.mjs'; +import { confirmCodexMcpRepairs, reconcileCodexMcp } from '../lib/codex-mcp-reconcile.mjs'; +import { alignHosts } from './x/host-align.mjs'; + +async function askCodexRepair(question) { + if (!process.stdin.isTTY) { + fail('Codex repairs need confirmation; re-run with --yes in a non-interactive session'); + return false; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { return /^y(?:es)?$/i.test((await rl.question(`${question} [y/N] `)).trim()); } + finally { rl.close(); } +} /** Prints one lifecycle-render.mjs report line at its own level — 'fail' * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`, @@ -128,8 +140,9 @@ export const SYNC_STEPS = [ id: 'codex-mcp-repair', when: (subs) => subs.has('codex-mcp'), run: async (ctx) => { - if (!ctx.codexRepairPlan.length) return; - const result = await ctx.step('codex MCP repair', () => repairCodexMcpTopology(ctx.codexRepairPlan, ctx.cwd)); + const recursive = ctx.codexRepairPlan.filter(entry => entry.repairKind === 'recursive-codex'); + if (!recursive.length) return; + const result = await ctx.step('codex MCP repair', () => ctx.repairCodexTopology(recursive, ctx.cwd)); if (!result.ok) ctx.state.codexRepairFailure = result.detail; }, }, @@ -475,6 +488,9 @@ export async function run({ fetchLatest, dejaVuAdapter = companionLifecycleFor('deja-vu'), collectFn = collect, + confirmCodexRepair = askCodexRepair, + inspectCodexTopology = codexMcpTopology, + repairCodexTopology = repairCodexMcpTopology, }) { const cwd = process.cwd(); const dejaVuPlanOptions = { allowUpgrade: !flags['no-upgrade'] }; @@ -502,23 +518,11 @@ export async function run({ const cfg = loadKitConfig(); const subsystems = new Set(plan.map((p) => p.subsystem)); const codexRepairPlan = plan.some((p) => p.subsystem === 'codex-mcp') - ? codexMcpRepairPlan(codexMcpTopology({ cwd })) : []; + ? codexMcpRepairPlan(inspectCodexTopology({ cwd })) : []; if (codexRepairPlan.length) { - console.log(bold(`Codex repair plan (${codexRepairPlan.length} action(s)):`)); - for (const action of codexRepairPlan) { - console.log(` • remove ${action.file} → [mcp_servers.${action.name}] — ${action.reason}`); - } - let confirmed = flags.yes; - if (!confirmed) { - if (!process.stdin.isTTY) { - fail('Codex repairs need confirmation; re-run with --yes in a non-interactive session'); - return 1; - } - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const answer = await rl.question('Apply these Codex repairs? [y/N] '); - rl.close(); - confirmed = /^y(?:es)?$/i.test(answer.trim()); - } + const confirmed = await confirmCodexMcpRepairs(cfg, codexRepairPlan, inspectCodexTopology({ cwd }), { + yes: flags.yes, confirm: confirmCodexRepair, + }); if (!confirmed) { info('Codex configuration was left unchanged; no sync actions were applied'); return 1; @@ -542,10 +546,23 @@ export async function run({ }; const ctx = { cfg, cwd, pkgRoot, flags, dejaVuAdapter, codexRepairPlan, subsystems, report, step, state, + inspectCodexTopology, repairCodexTopology, }; for (const s of SYNC_STEPS) { if (s.when(subsystems, flags, cfg)) await s.run(ctx); + if (state.codexRepairFailure) return 1; + } + + // An initializer can restore a legacy alias after the initial repair. Keep + // the replacement's ownership check and the user's explicitly remembered + // choice, then verify the final topology before declaring convergence. + const finalMcp = await reconcileCodexMcp({ cfg, cwd, yes: flags.yes, confirm: confirmCodexRepair, + inspect: inspectCodexTopology, repair: repairCodexTopology, approvedTargets: codexRepairPlan }); + if (!finalMcp.ok) state.codexRepairFailure = finalMcp.detail; + if (await alignHosts({ flags: { apply: true, yes: flags.yes }, roots: [cwd], cfg, + confirm: confirmCodexRepair }) !== 0) { + state.applyFailures.push({ name: 'host-alignment', detail: 'transport anomalies remain; run ak host align for the exact scope and correction' }); } // converge proof diff --git a/src/commands/x/host-align.mjs b/src/commands/x/host-align.mjs new file mode 100644 index 00000000..f7bcdd56 --- /dev/null +++ b/src/commands/x/host-align.mjs @@ -0,0 +1,71 @@ +import readline from 'node:readline/promises'; +import os from 'node:os'; +import { projectRoots } from '../audit.mjs'; +import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; +import { inspectHostAlignment, publicHostAlignment, applyHostAlignment, configuredHostProjects } from '../../lib/host-alignment.mjs'; + +const key = finding => JSON.stringify(['retired-codex-bare-v1', finding.host, finding.file, finding.scope, finding.project ?? null, finding.name]); + +async function ask(question) { + if (!process.stdin.isTTY) return false; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { return /^y(?:es)?$/i.test((await rl.question(`${question} [y/N] `)).trim()); } + finally { rl.close(); } +} + +/** @param {{flags?:any,roots?:string[],cfg?:any,confirm?:(question:string)=>Promise,save?:typeof saveKitConfig,inspect?:typeof inspectHostAlignment,apply?:typeof applyHostAlignment}} [options] */ +export async function alignHosts({ + flags = {}, roots, cfg = loadKitConfig(), confirm = ask, save = saveKitConfig, + inspect = inspectHostAlignment, apply = applyHostAlignment, +} = {}) { + const selected = roots ?? projectRoots(flags); + if (flags['all-projects']) for (const root of configuredHostProjects()) { + if (!selected.includes(root)) selected.push(root); + } + if (flags['all-projects'] && !selected.includes(os.homedir())) selected.push(os.homedir()); + const report = inspect({ projectRoots: selected }); + if (!flags.json) { + console.log(`Host alignment: ${report.policy.id}; user scope + ${selected.length} project location(s)`); + for (const finding of report.findings) { + console.log(` [${finding.level}] ${finding.host}/${finding.scope}: ${finding.file} → ${finding.name ?? finding.code}`); + console.log(` ${finding.message}${finding.repairable ? ' — backed-up correction available' : ''}`); + if (!finding.repairable && finding.remedy) console.log(` ${finding.remedy}`); + } + } + if (!flags.apply || flags['dry-run']) { + if (flags.json) console.log(JSON.stringify(publicHostAlignment(report), null, 2)); + else if (!report.aligned) console.log('Preview only. Run ak host align with the same scope and --apply to approve realignment.'); + else console.log('Host transports aligned. Ruflo/AQE provider routing is preserved.'); + return report.aligned ? 0 : 1; + } + const repairable = report.findings.filter(f => f.repairable); + if (!repairable.length) { + if (flags.json) console.log(JSON.stringify(publicHostAlignment(report), null, 2)); + return report.aligned ? 0 : 1; + } + const consent = cfg.integrations?.hostAlignment; + const prior = consent?.policy === report.policy.id && Array.isArray(consent.corrections) ? consent.corrections : []; + const remembered = repairable.every(f => prior.includes(key(f))); + const question = 'Remove the listed retired transports with backups and remember these exact file/scope/name corrections for future alignment?'; + if (!flags.json) console.log(remembered ? 'Using previously approved transport realignment.' : question); + if (!remembered && !flags.yes && !await confirm(question)) { + if (flags.json) console.log(JSON.stringify({ status: 'approval-required', alignment: publicHostAlignment(report) })); + else console.log('No configuration changed; realignment needs explicit approval (--yes for noninteractive use).'); + return 1; + } + const result = await apply(report, { confirmed: true }); + if (result.ok) { + cfg.integrations ??= {}; + cfg.integrations.hostAlignment = { policy: report.policy.id, + corrections: [...new Set([...prior, ...repairable.map(key)])] }; + save(cfg); + } + if (flags.json) console.log(JSON.stringify(result, null, 2)); + else { + console.log(result.ok ? 'Host alignment verified.' : `Host alignment incomplete: ${result.reason ?? 'some entries need manual review'}`); + for (const backup of result.backups) console.log(` recovery copy: ${backup}`); + } + return result.ok ? 0 : 1; +} + +export async function run({ flags }) { return alignHosts({ flags }); } diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 1235f8dc..b0a50f26 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -58,6 +58,10 @@ export const options = { dev: { type: 'boolean', default: false }, // adapters conformance: run without persisting evidence/grants yes: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, + project: { type: 'string', multiple: true }, + 'all-projects': { type: 'boolean', default: false }, + apply: { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, }; /** Billing is the non-obvious axis of the aqe provider list. Three categories, @@ -82,6 +86,10 @@ Host model — three managed hosts, all eligible for explicit activity routing: Subcommands: status (default) detected CLIs, aqe provider, ruflo providers, what's wired + align audit user/current-project host transports; --all-projects adds the + bounded census, --project PATH adds a location. Preview by default; + --apply offers backed-up correction; --yes approves noninteractively. + Preserves Ruflo dual-mode workers and AQE native provider routing. pick choose hosts / aqe provider / ruflo providers → persist → apply refresh re-seed routes whose seeded pin diverges from the current defaults (per-activity, opt-in; user pins are never touched, and \`ak sync\` @@ -167,12 +175,16 @@ export async function run({ flags, positionals, pkgRoot }) { if (sub === 'off') return off({ cwd, pkgRoot }); if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); if (sub === 'refresh') return refresh({ flags, cwd }); + if (sub === 'align') { + const { run: align } = await import('./host-align.mjs'); + return align({ flags }); + } if (sub === 'adapters') { const { run: runHostAdapters } = await import('./host-adapters.mjs'); return runHostAdapters({ flags, positionals: positionals.slice(1) }); } - fail(`unknown host subcommand: ${sub} (status|pick|refresh|off|adapters)`); + fail(`unknown host subcommand: ${sub} (status|pick|refresh|off|adapters|align)`); return 2; } diff --git a/src/lib/codex-mcp-reconcile.mjs b/src/lib/codex-mcp-reconcile.mjs new file mode 100644 index 00000000..80930750 --- /dev/null +++ b/src/lib/codex-mcp-reconcile.mjs @@ -0,0 +1,99 @@ +// A remembered correction is deliberately narrower than general MCP ownership: +// one recognized user-scope alias in one absolute file, with the managed +// workspace-aware replacement still present. Never inherit historical --yes. +import { codexMcpTopology, codexMcpRepairPlan, repairCodexMcpTopology } from './mcp.mjs'; +import { codexConfigPath } from './paths.mjs'; +import { saveKitConfig } from './config.mjs'; + +function repairKey(entry) { + if (entry?.scope !== 'user' || entry.file !== codexConfigPath() + || entry.repairKind !== 'legacy-ruflo' || entry.name !== 'claude-flow' + || entry.command !== 'ruflo' || JSON.stringify(entry.args) !== '["mcp","start"]') return null; + return `${entry.file}\nclaude-flow\nruflo mcp start`; +} + +function managedReplacement(cfg, topology) { + return cfg.integrations?.hosts?.codex === true + && cfg.integrations?.ownership?.codex?.reverseMcp === 'ak' + && topology.effectiveRufloRegistrations.some(entry => entry.name === 'ruflo' + && entry.command === 'ak' && JSON.stringify(entry.args) === '["x","ruflo-mcp"]'); +} + +function rememberedKeys(cfg) { + const consent = cfg.integrations?.ownership?.codex?.mcpRepairConsent; + return consent?.version === 1 && Array.isArray(consent.targets) ? consent.targets : []; +} + +export function hasCodexMcpRepairConsent(cfg, entry, topology) { + const key = repairKey(entry); + return key !== null && managedReplacement(cfg, topology) && rememberedKeys(cfg).includes(key); +} + +export function rememberCodexMcpRepairs(cfg, targets, topology) { + if (!managedReplacement(cfg, topology)) return false; + const prior = rememberedKeys(cfg); + const keys = [...new Set([...prior, ...targets.map(repairKey).filter(Boolean)])]; + if (keys.length === prior.length) return false; + cfg.integrations.ownership.codex.mcpRepairConsent = { version: 1, targets: keys }; + return true; +} + +export async function confirmCodexMcpRepairs(cfg, targets, topology, { yes, confirm }) { + if (!targets.length) return true; + console.log(`Codex repair plan (${targets.length} action(s)):`); + for (const entry of targets) console.log(` • remove ${entry.file} → [mcp_servers.${entry.name}] — ${entry.reason}`); + const pending = targets.filter(entry => !hasCodexMcpRepairConsent(cfg, entry, topology)); + if (!pending.length) { + console.log('Using remembered consent for the recognized legacy Ruflo correction.'); + return true; + } + const remember = pending.some(entry => repairKey(entry) !== null); + const question = remember + ? 'Apply these Codex repairs and remember this recognized user-scope Ruflo correction for future setup/sync runs?' + : 'Apply these Codex repairs?'; + console.log(question); + return yes || await confirm(question); +} + +/** Reinspect after every initializer/upgrade has finished. A new target still + * needs approval; a changed/custom table is never eligible for remembered + * correction. Every removal retains mcp.mjs's live fingerprint and backup gate. */ +export async function reconcileCodexMcp({ + cfg, cwd, yes = false, confirm, inspect = codexMcpTopology, + repair = repairCodexMcpTopology, save = saveKitConfig, includeProject = true, + approvedTargets = [], +}) { + if (!cfg.integrations?.hosts?.codex) return { ok: true, changed: false, detail: 'Codex disabled' }; + let topology = inspect({ cwd }); + const targets = codexMcpRepairPlan(topology).filter(entry => includeProject || entry.scope === 'user'); + const replacingAlias = targets.some(entry => entry.repairKind === 'legacy-ruflo'); + const hasReplacement = () => topology.effectiveRufloRegistrations.some(entry => entry.name === 'ruflo'); + if (replacingAlias && !hasReplacement()) { + return { ok: false, changed: false, + detail: 'canonical Ruflo replacement is missing or disabled; existing alias preserved — run ak sync to provision the replacement' }; + } + if (targets.length) { + const pending = targets.filter(entry => !approvedTargets.some(prior => + prior.file === entry.file && prior.scope === entry.scope && prior.name === entry.name + && prior.fingerprint === entry.fingerprint && prior.repairKind === entry.repairKind)); + if (!await confirmCodexMcpRepairs(cfg, pending, topology, { yes, confirm })) { + return { ok: false, changed: false, detail: 'Codex repair declined; remaining registrations were preserved' }; + } + const result = await repair(targets, cwd); + if (!result.ok) return result; + topology = inspect({ cwd }); + if (replacingAlias && !hasReplacement()) { + return { ok: false, changed: true, detail: 'canonical Ruflo replacement disappeared during repair' }; + } + if (rememberCodexMcpRepairs(cfg, targets, topology)) save(cfg); + } + if (topology.duplicateRuflo) { + return { ok: false, changed: targets.length > 0, + detail: 'duplicate Ruflo registrations remain; custom or unrecognized entries require review before removal' }; + } + if (topology.selfRegistrations.some(entry => includeProject || entry.scope === 'user')) { + return { ok: false, changed: targets.length > 0, + detail: 'recursive Codex MCP registration remains; custom entries require review before removal' }; + } + return { ok: true, changed: targets.length > 0, detail: 'Codex MCP topology verified after provisioning' }; +} diff --git a/src/lib/host-alignment.mjs b/src/lib/host-alignment.mjs new file mode 100644 index 00000000..ab9867c9 --- /dev/null +++ b/src/lib/host-alignment.mjs @@ -0,0 +1,254 @@ +// Host transport alignment. MCP configuration is not provider routing: AQE's +// claude-code/codex providers and Ruflo's dual-mode CLI workers remain intact. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { writeFileWithBackup } from './file-write.mjs'; +import { inspectCodexTomlStructure, isTomlTableLine } from './codex-toml-safety.mjs'; +import { enabledPluginRefs } from './codex-plugins.mjs'; +import { repoRoot } from './paths.mjs'; + +export const HOST_ALIGNMENT_POLICY = Object.freeze({ + id: 'supported-peer-transports/v1', + managed: 'ak run: claude --print / codex exec', + preserved: ['Ruflo dual-mode CLI workers', 'AQE claude-code/codex providers', 'Claude Codex companion via App Server'], + sources: { + codex: 'https://learn.chatgpt.com/docs/mcp-server', + claude: 'https://code.claude.com/docs/en/headless', + claudeTools: 'https://code.claude.com/docs/en/mcp#use-claude-code-as-an-mcp-server', + }, +}); +const hash = value => createHash('sha256').update(value).digest('hex'); +const equalArgs = (a, b) => JSON.stringify(a) === JSON.stringify(b); +export const hostAlignmentFindingId = finding => `host-alignment-${hash(JSON.stringify([ + finding.host, finding.file, finding.scope, finding.project ?? null, finding.name ?? null, finding.code, +])).slice(0,32)}`; +const selectedFinding = (finding, ids) => !ids || ids.includes(hostAlignmentFindingId(finding)); +const executable = command => typeof command === 'string' + ? command.replaceAll('\\', '/').split('/').at(-1).replace(/\.(exe|cmd)$/i, '') : ''; + +export function retiredCodexTransport(entry) { + const cmd = executable(entry?.command); + const args = entry?.args; + if (!Array.isArray(args)) return false; + if (cmd === 'codex') return args[0] === 'mcp-server'; + if (cmd !== 'npx') return false; + const rest = ['-y', '--yes'].includes(args[0]) ? args.slice(1) : args; + return /^@openai\/codex(?:@[\w.-]+)?$/.test(rest[0]) && rest[1] === 'mcp-server'; +} + +// JSON.parse validates grammar; the token walk additionally rejects duplicate +// keys, which JSON.parse would silently collapse during a corrective rewrite. +function uniqueJson(source) { + const parsed = JSON.parse(source); + const stack = []; + for (const match of source.matchAll(/"(?:\\.|[^"\\])*"|[{}[\]]/g)) { + const token = match[0]; + if (token === '{' || token === '[') stack.push(token === '{' ? new Set() : null); + else if (token === '}' || token === ']') stack.pop(); + else if (/^\s*:/.test(source.slice(match.index + token.length))) { + const key = JSON.parse(token); + const keys = stack.at(-1); + if (keys?.has(key)) throw new Error('duplicate JSON key'); + keys?.add(key); + } + } + return parsed; +} + +function readSource(file) { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024) throw new Error('configuration is not a bounded regular file'); + const bytes = fs.readFileSync(file); + const source = bytes.toString('utf8'); + if (!bytes.equals(Buffer.from(source))) throw new Error('configuration is not UTF-8'); + return { file, source, digest: hash(bytes), mode: stat.mode & 0o777, + identity: { real: fs.realpathSync(file), device: stat.dev, inode: stat.ino, mode: stat.mode } }; +} + +function safeJsonTransport(entry) { + return Object.keys(entry).every(k => ['command', 'args', 'type', 'env'].includes(k)) + && (entry.type === undefined || entry.type === 'stdio') + && (entry.env === undefined || (entry.env && typeof entry.env === 'object' && !Array.isArray(entry.env) && Object.keys(entry.env).length === 0)) + && entry.command === 'codex' && equalArgs(entry.args, ['mcp-server']); +} + +function scanJson(snapshot, scope, roots, findings, selectedIds) { + const doc = uniqueJson(snapshot.source); + const object = value => value && typeof value === 'object' && !Array.isArray(value); + if (!object(doc) || (doc.mcpServers !== undefined && !object(doc.mcpServers)) + || (doc.projects !== undefined && !object(doc.projects))) throw new Error('invalid host configuration shape'); + let changed = false; + function inspect(servers, entryScope, project = null) { + if (servers !== undefined && !object(servers)) throw new Error('invalid server mapping'); + for (const [name, entry] of Object.entries(servers ?? {})) { + if (!object(entry)) throw new Error('invalid MCP definition'); + const base = { host: 'claude', file: snapshot.file, scope: entryScope, project, name }; + if (retiredCodexTransport(entry)) { + const repairable = safeJsonTransport(entry); + findings.push({ ...base, code: 'retired-codex-mcp', level: 'fail', repairable, + message: 'retired Codex MCP transport; use native CLI delegation or the Claude companion/App Server', + remedy: 'ak host align --apply' }); + if (repairable && selectedFinding(findings.at(-1), selectedIds)) { delete servers[name]; changed = true; } + } else if (executable(entry?.command) === 'claude' && equalArgs(entry.args, ['mcp', 'serve'])) { + findings.push({ ...base, code: 'claude-tools-only', level: 'info', repairable: false, + message: 'supported Claude tool server; agent delegation uses claude -p, Ruflo/AQE native providers, or ak run' }); + } + } + } + inspect(doc?.mcpServers, scope, scope === 'project' ? path.dirname(snapshot.file) : null); + if (scope === 'user') for (const [root, settings] of Object.entries(doc?.projects ?? {})) { + if (roots.has(path.resolve(root))) inspect(settings?.mcpServers, 'local', root); + } + if (changed) snapshot.candidate = JSON.stringify(doc, null, 2) + '\n'; +} + +function scanToml(snapshot, scope, findings, selectedIds) { + const structure = inspectCodexTomlStructure(snapshot.source); + if (!structure.valid) throw new Error('ambiguous TOML structure'); + if (structure.lines.some(line => line.live && /^\s*(?:mcp_servers\b|"mcp_servers"|'mcp_servers')\s*[.=]/.test(line.text))) { + throw new Error('unassessed inline or dotted MCP assignment'); + } + const headers = structure.lines.filter(line => line.live && isTomlTableLine(line.text)); + for (const header of headers) { + if (/^\s*\[\[?\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:\s*\.|\s*\])/.test(header.text) + && !/^\s*\[mcp_servers\.(?:"[A-Za-z0-9_-]+"|[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_-]+)*\]\s*(?:#.*)?$/.test(header.text)) { + throw new Error('unassessed MCP table syntax'); + } + } + const seen = new Set(); + const removals = []; + for (const [index, header] of headers.entries()) { + const matched = /^\s*\[mcp_servers\.(?:"([A-Za-z0-9_-]+)"|([A-Za-z0-9_-]+))\]\s*(?:#.*)?$/.exec(header.text); + if (!matched) continue; + const name = matched[1] ?? matched[2]; + if (seen.has(name)) throw new Error('duplicate MCP table'); + seen.add(name); + const end = headers[index + 1]?.start ?? snapshot.source.length; + const body = snapshot.source.slice(header.end, end); + const fields = body.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#')); + const decode = key => { + try { return JSON.parse(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, 'm').exec(body)?.[1] ?? 'null'); } + catch { return null; } + }; + const entry = { command: decode('command'), args: decode('args') }; + const base = { host: 'codex', file: snapshot.file, scope, project: scope === 'project' ? path.dirname(path.dirname(snapshot.file)) : null, name }; + if (retiredCodexTransport(entry)) { + const children = headers.some(h => h.text.trim().startsWith(`[mcp_servers.${name}.`) || h.text.trim().startsWith(`[mcp_servers."${name}".`)); + const repairable = !children && fields.length === 2 + && fields.some(f => /^command\s*=/.test(f)) && fields.some(f => /^args\s*=/.test(f)) + && entry.command === 'codex' && equalArgs(entry.args, ['mcp-server']); + findings.push({ ...base, code: 'retired-codex-mcp', level: 'fail', repairable, + message: 'Codex self-registration through retired MCP; use native host/provider routing', remedy: 'ak host align --apply' }); + if (repairable && selectedFinding(findings.at(-1), selectedIds)) removals.push({ start: header.start, end }); + } else if (executable(entry.command) === 'claude' && equalArgs(entry.args, ['mcp', 'serve'])) { + findings.push({ ...base, code: 'claude-tools-only', level: 'info', repairable: false, + message: 'Claude tools exposed through MCP; this is not a Claude agent session' }); + } else if (/mcp-server/.test(body) && (!entry.command || !entry.args)) { + throw new Error('MCP transport uses unassessed TOML syntax'); + } + } + if (enabledPluginRefs(snapshot.source).includes('codex@openai-codex')) { + findings.push({ host: 'codex', file: snapshot.file, scope, name: 'codex@openai-codex', + code: 'misplaced-claude-companion', level: 'fail', repairable: false, + message: 'Claude companion plugin enabled inside Codex', remedy: 'ak heal hooks --host codex' }); + } + if (removals.length) { + let candidate = snapshot.source; + for (const { start, end } of removals.reverse()) candidate = candidate.slice(0, start) + candidate.slice(end); + snapshot.candidate = candidate; + } +} + +export function inspectHostAlignment({ projectRoots = [process.cwd()], home = os.homedir(), + codexHome = process.env.CODEX_HOME || path.join(home, '.codex'), + claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), + selectedFindingIds = undefined, +} = {}) { + const roots = new Set(projectRoots.map(root => path.resolve(root))); + for (const root of [...roots]) { + const repository = repoRoot(root); + if (repository) roots.add(repository); + } + /** @type {Map} */ + const files = new Map([ + [path.join(home, '.claude.json'), { host: 'claude', scope: 'user', format: 'json' }], + [path.resolve(codexHome, 'config.toml'), { host: 'codex', scope: 'user', format: 'toml' }], + ]); + for (const root of roots) { + files.set(path.join(root, '.mcp.json'), { host: 'claude', scope: 'project', project: root, format: 'json' }); + const file = path.join(root, '.codex/config.toml'); + if (!files.has(file)) files.set(file, { host: 'codex', scope: 'project', project: root, format: 'toml' }); + } + const findings = [], snapshots = []; + if (path.resolve(claudeConfigDir) !== path.join(home, '.claude')) { + files.delete(path.join(home, '.claude.json')); + findings.push({ host: 'claude', scope: 'user', file: path.resolve(claudeConfigDir), + code: 'config-unassessed', level: 'fail', repairable: false, + message: 'custom CLAUDE_CONFIG_DIR requires host-specific configuration review', remedy: 'review the effective Claude config root before realignment' }); + } + for (const [file, meta] of [...files].sort(([left], [right]) => left.localeCompare(right))) { + let snapshot; + try { + snapshot = readSource(file); + snapshots.push(snapshot); + const local = []; + if (meta.format === 'json') scanJson(snapshot, meta.scope, roots, local, selectedFindingIds); + else scanToml(snapshot, meta.scope, local, selectedFindingIds); + findings.push(...local); + } catch (error) { + if (snapshot) delete snapshot.candidate; + if (error.code === 'ENOENT') continue; + findings.push({ ...meta, file, code: 'config-unassessed', level: 'fail', repairable: false, + message: 'configuration could not be safely assessed; review syntax, size, and symlinks', remedy: 'review configuration, then rerun ak host align' }); + } + } + const scope = { home, codexHome, claudeConfigDir, projectRoots: [...roots].sort(), selectedFindingIds }; + const digest = hash(JSON.stringify({ scope, findings, files: snapshots.map(s => [s.file, s.digest, s.identity]) })); + return { policy: HOST_ALIGNMENT_POLICY, scope, digest, aligned: !findings.some(f => f.level === 'fail'), findings, snapshots }; +} + +export function publicHostAlignment(report) { + const { snapshots, ...publicReport } = report; + return { ...publicReport, repairs: snapshots.filter(s => s.candidate !== undefined).map(s => ({ file: s.file, sourceDigest: s.digest })) }; +} + +/** Configuration-declared projects supplement the session census: a project + * can retain a live MCP file after its transcript has been archived. */ +export function configuredHostProjects(home = os.homedir()) { + let doc; + try { doc = uniqueJson(readSource(path.join(home, '.claude.json')).source); } + catch { return []; } // The alignment scan separately reports an unreadable config. + const candidates = Object.keys(doc?.projects ?? {}); + if (candidates.length > 1024) throw new Error('too many configured projects; select explicit --project locations'); + return candidates.filter(root => { + try { return path.isAbsolute(root) && fs.statSync(root).isDirectory(); } + catch { return false; } + }); +} + +export async function applyHostAlignment(report, { confirmed = false } = {}) { + const backups = [], changed = []; + if (!confirmed) return { ok: false, changed, backups, reason: 'approval-required' }; + const current = inspectHostAlignment(report.scope); + if (current.digest !== report.digest) return { ok: false, changed, backups, reason: 'configuration-changed-since-preview' }; + try { + for (const snapshot of current.snapshots.filter(s => s.candidate !== undefined)) { + const fresh = readSource(snapshot.file); + if (fresh.digest !== snapshot.digest || JSON.stringify(fresh.identity) !== JSON.stringify(snapshot.identity)) throw new Error('configuration changed before write'); + const backup = `${snapshot.file}.ak-host-align-${randomUUID()}.bak`; + fs.copyFileSync(snapshot.file, backup, fs.constants.COPYFILE_EXCL); + backups.push(backup); + writeFileWithBackup(snapshot.file, snapshot.candidate); + changed.push(snapshot.file); + if (readSource(snapshot.file).digest !== hash(snapshot.candidate)) throw new Error('write verification failed'); + } + const after = inspectHostAlignment(report.scope); + const selectedRemaining = report.scope.selectedFindingIds + ? after.findings.filter(f => f.code === 'config-unassessed' || selectedFinding(f, report.scope.selectedFindingIds)) : after.findings; + return { ok: !selectedRemaining.some(f => f.level === 'fail'), changed, backups, remaining: after.findings }; + } catch (error) { + return { ok: false, changed, backups, reason: error.message }; + } +} diff --git a/src/lib/mcp.mjs b/src/lib/mcp.mjs index 95a8e435..422112d1 100644 --- a/src/lib/mcp.mjs +++ b/src/lib/mcp.mjs @@ -11,6 +11,8 @@ import { run } from './exec.mjs'; import { readJson, addDenyRules, removeDenyRules } from './settings.mjs'; 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'; /** Enumerate MCP tool names from the installed package's mcp-tools modules, * grouped by name prefix (family). Returns Map. */ @@ -156,7 +158,7 @@ export function ruvectorRegistered() { export function codexMcpStatus(cfg, cwd = process.cwd()) { const root = repoRoot(cwd) ?? cwd; const servers = readJson(path.join(root, '.mcp.json'), {})?.mcpServers ?? {}; - return { registered: 'codex' in servers, owned: cfg?.integrations?.ownership?.codex?.mcp === 'ak' }; + return { registered: retiredCodexTransport(servers.codex), owned: cfg?.integrations?.ownership?.codex?.mcp === 'ak' }; } function tomlString(value) { @@ -257,8 +259,7 @@ export function codexMcpTopology({ cwd = process.cwd(), home = os.homedir() } = const agenticQeRegistrations = registrations.filter((entry) => entry.name === 'agentic-qe'); // Detection is broader than permission to remove a table. Custom environment // and timeout fields preserve ownership without hiding duplicate transports. - const isRuflo = (entry) => (entry.command === 'ruflo' && sameArgs(entry.args, ['mcp', 'start'])) - || (entry.command === 'ak' && sameArgs(entry.args, ['x', 'ruflo-mcp'])); + const isRuflo = (entry) => isRufloMcpTransport(entry); const rufloRegistrations = registrations.filter(isRuflo); // Merge observed fields user→project: a timeout-only project table inherits // the user's transport. Keep raw tables separate for exact repair matching. @@ -358,7 +359,7 @@ function createCurrentRepairBackup(file) { * supported command. Every target is identity-checked immediately before its * mutation and re-probed immediately after it. */ export async function repairCodexMcpTopology(targets, cwd = process.cwd(), { - runner = run, inspect = codexMcpTopology, + runner = run, inspect = codexMcpTopology, codexHome = process.env.CODEX_HOME, } = {}) { const backedUp = new Set(); const removed = []; @@ -366,6 +367,11 @@ export async function repairCodexMcpTopology(targets, cwd = process.cwd(), { if (!validRepairTarget(target)) { return { ok: false, changed: removed.length > 0, detail: 'Codex MCP repair target was not a recognized disclosed legacy shape' }; } + if (target.scope === 'user' && codexHome + && path.resolve(codexHome, 'config.toml') !== target.file) { + return { ok: false, changed: removed.length > 0, + detail: 'CODEX_HOME does not match the disclosed Codex config; native removal refused' }; + } const live = inspect({ cwd }).registrations.find((entry) => entry.file === target.file && entry.scope === target.scope && entry.name === target.name); if (!sameRepairIdentity(live, target)) { diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 79616e5c..fa3a48fc 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -14,8 +14,8 @@ import { recordProviderEnv, undoOwnedProviderEnv } from './provider-ownership.mj // ALL_PROVIDER_TYPES — claude-code (subscription), claude/openai/gemini/ // openrouter/azure-openai/bedrock/cognitum (metered api), ollama (local). It // normalizes `anthropic`→`claude` and warns on unknown values. So aqe is NOT -// limited to claude-code; codex-the-CLI simply isn't a provider *type* (its -// OpenAI models are reached via `openai`). +// limited to claude-code: current AQE also has a `codex` CLI subscription +// provider. Its native providers do not require cross-host MCP servers. // // Two independent axes: // host axis — which agent CLI executes a managed worker (claude, codex, diff --git a/src/lib/ruflo-mcp-transport.mjs b/src/lib/ruflo-mcp-transport.mjs new file mode 100644 index 00000000..4c27ee1b --- /dev/null +++ b/src/lib/ruflo-mcp-transport.mjs @@ -0,0 +1,12 @@ +// Recognition for topology diagnostics only. This does not grant permission +// to remove upstream npx registrations or extend remembered repair consent. +export function isRufloMcpTransport({ command, args }) { + if (!Array.isArray(args)) return false; + const same = expected => JSON.stringify(args) === JSON.stringify(expected); + if (command === 'ak') return same(['x', 'ruflo-mcp']); + if (command === 'ruflo' || command === 'claude-flow') return same(['mcp', 'start']); + if (command !== 'npx') return false; + const invocation = args[0] === '-y' || args[0] === '--yes' ? args.slice(1) : args; + return invocation.length === 3 && invocation[1] === 'mcp' && invocation[2] === 'start' + && /^(?:ruflo|claude-flow|@claude-flow\/cli)(?:@[a-zA-Z0-9._-]+)?$/.test(invocation[0]); +} diff --git a/src/lib/trust-manifest.mjs b/src/lib/trust-manifest.mjs index ced65f79..ad127cc8 100644 --- a/src/lib/trust-manifest.mjs +++ b/src/lib/trust-manifest.mjs @@ -144,7 +144,7 @@ export function codexMcpRepairTrustManifest(plan = []) { value: `[mcp_servers.${entry.name}]`, effect: entry.repairKind === 'recursive-codex' ? `create a current-state recovery copy, remove this deprecated recursive Codex transport ${mechanism}, and verify its absence` - : `create a current-state recovery copy, remove this duplicate legacy Ruflo transport ${mechanism}, and verify its absence`, + : `create a current-state recovery copy, remove this duplicate legacy Ruflo transport ${mechanism}, and verify its absence${entry.scope === 'user' ? '; remember this recognized correction for future setup/sync runs while the managed workspace-aware replacement remains present' : ''}`, }; }); if (!changes.length) return []; diff --git a/tests/kit/codex-mcp-convergence.test.mjs b/tests/kit/codex-mcp-convergence.test.mjs new file mode 100644 index 00000000..0403e103 --- /dev/null +++ b/tests/kit/codex-mcp-convergence.test.mjs @@ -0,0 +1,281 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + sandboxHome, assertSandboxed, sandboxProject, writeKitConfig, + offlineKitConfig, fakeGlobalRoot, captureLog, snapshot, assertUnchanged, +} from './helpers/home-sandbox.mjs'; + +const sandbox = sandboxHome('ak-mcp-convergence'); +const paths = await import('../../src/lib/paths.mjs'); +assertSandboxed(paths, sandbox); +const sync = await import('../../src/commands/sync.mjs'); +const setup = await import('../../src/commands/setup.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const { codexMcpTopology, repairCodexMcpTopology, register, claudeMcpTopology } = await import('../../src/lib/mcp.mjs'); +const { ensureRufloMcpInCodex } = await import('../../src/lib/providers.mjs'); +const { reconcileCodexMcp } = await import('../../src/lib/codex-mcp-reconcile.mjs'); +const project = sandboxProject('ak-mcp-convergence'); +const pkgRoot = path.resolve(import.meta.dirname, '../..'); +const canonical = '[mcp_servers.ruflo]\ncommand = "ak"\nargs = ["x", "ruflo-mcp"]\n'; +const legacy = '[mcp_servers.claude-flow]\ncommand = "ruflo"\nargs = ["mcp", "start"]\n'; +const unrelated = '[mcp_servers.notes]\ncommand = "notes-server"\n'; +const file = paths.codexConfigPath(); +const inspect = () => codexMcpTopology({ cwd: project, home: sandbox }); +let removals = 0; + +function seed(source = canonical + legacy + unrelated) { + writeKitConfig(sandbox, offlineKitConfig({ + aqe: false, security: false, agentdb: false, + mcp: { register: false, excludeFamilies: [] }, + integrations: { version: 3, hosts: { claude: true, codex: true, opencode: false }, + bindings: [], ownership: { codex: { reverseMcp: 'ak' } } }, + })); + paths._setGlobalRootForTest(fakeGlobalRoot(sandbox, { ruflo: '9.9.9' })); + fs.mkdirSync(paths.codexDir(), { recursive: true }); + fs.writeFileSync(file, source); + fs.writeFileSync(paths.claudeUserMcpPath(), JSON.stringify({ mcpServers: {} })); + removals = 0; +} + +async function repair(plan, cwd) { + return repairCodexMcpTopology(plan, cwd, { + inspect, + runner: async (command, args) => { + assert.equal(command, 'codex'); + assert.deepEqual(args, ['mcp', 'remove', 'claude-flow']); + removals++; + const source = fs.readFileSync(file, 'utf8'); + fs.writeFileSync(file, source.replace(/\[mcp_servers\.claude-flow\][\s\S]*?(?=\[mcp_servers\.|$)/, '')); + return { code: 0, stdout: '', stderr: '' }; + }, + }); +} + +function rows() { + return inspect().duplicateRuflo + ? [{ subsystem: 'codex-mcp', level: 'warn', message: 'duplicate Ruflo transports', fix: 'ak sync repairs duplicates' }] + : []; +} + +async function run({ yes = false, confirm = async () => false, collectFn = rows, repairFn = repair } = {}) { + const previous = process.cwd(); + process.chdir(project); + try { + return await captureLog(() => sync.run({ + pkgRoot, flags: { 'no-upgrade': true, yes, 'dry-run': false }, collectFn, + confirmCodexRepair: confirm, inspectCodexTopology: inspect, repairCodexTopology: repairFn, + })); + } finally { process.chdir(previous); } +} + +test('sync repairs an approved alias and remembers only the disclosed correction on later runs', async () => { + seed(); + let prompts = 0; + const first = await run({ confirm: async message => { prompts++; assert.match(message, /future|remember/i); return true; } }); + assert.equal(first.result, 0, first.out); + assert.equal(prompts, 1); + assert.equal(inspect().duplicateRuflo, false); + assert.ok(fs.readFileSync(file, 'utf8').includes(unrelated)); + // An external upgrader restores the same recognized legacy transport. + fs.appendFileSync(file, legacy); + const second = await run({ confirm: async () => { throw new Error('must reuse explicit repair consent'); } }); + assert.equal(second.result, 0, second.out); + assert.equal(inspect().duplicateRuflo, false); + assert.equal(removals, 2); + const third = await run(); + assert.equal(third.result, 0, third.out); + assert.equal(removals, 2, 'already converged runs perform no removal'); +}); + +test('declining repair leaves configuration and consent unchanged', async () => { + seed(); + const before = snapshot(sandbox); + const result = await run({ confirm: async () => false }); + assert.equal(result.result, 1); + assertUnchanged(before, sandbox, 'declined repair'); + assert.equal(removals, 0); +}); + +test('remembered repair never consumes an alias with a custom environment', async () => { + seed(); + assert.equal((await run({ yes: true })).result, 0); + fs.appendFileSync(file, legacy + '[mcp_servers.claude-flow.env]\nPRIVATE_DB = "keep"\n'); + const before = fs.readFileSync(file, 'utf8'); + const result = await run(); + assert.equal(result.result, 1, result.out); + assert.equal(fs.readFileSync(file, 'utf8'), before); + assert.equal(removals, 1); + assert.doesNotMatch(result.out, /converged —/); +}); + +test('sync repairs an approved alias recreated during later provisioning in the same run', async t => { + seed(); + const step = sync.SYNC_STEPS.find(entry => entry.id === 'mcp'); + t.mock.method(step, 'when', subs => subs.has('mcp')); + t.mock.method(step, 'run', async () => fs.writeFileSync(file, canonical + unrelated + legacy)); + let collects = 0; + const result = await run({ yes: true, collectFn: () => { + const observed = rows(); + return collects++ === 0 ? [...observed, { subsystem: 'mcp', level: 'warn', message: 'provisioning refresh', fix: 'refresh' }] : observed; + } }); + assert.equal(result.result, 0, result.out); + assert.equal(inspect().duplicateRuflo, false); + assert.equal(removals, 1); +}); + +test('sync cannot report convergence when an unrepairable duplicate remains', async () => { + seed(canonical + legacy + '[mcp_servers.claude-flow.env]\nPRIVATE_DB = "keep"\n'); + const result = await run({ yes: true }); + assert.equal(result.result, 1, result.out); + assert.equal(removals, 0); +}); + +test('fresh dual-host Codex provisioning and repeated refresh retain one canonical connection', async () => { + seed(unrelated); + const cfg = loadKitConfig(); + let adds = 0; + let claudeAdds = 0; + const provisionClaude = () => register(cfg, { + inspect: () => claudeMcpTopology({ cwd: project, home: sandbox }), + runner: async (command, args) => { + assert.equal(command, 'claude'); + assert.deepEqual(args, ['mcp', 'add', 'claude-flow', '-s', 'user', '--', 'ruflo', 'mcp', 'start']); + fs.writeFileSync(paths.claudeUserMcpPath(), JSON.stringify({ mcpServers: { + 'claude-flow': { command: 'ruflo', args: ['mcp', 'start'] }, + } })); + claudeAdds++; + return { code: 0, stdout: '', stderr: '' }; + }, + }); + const provision = () => ensureRufloMcpInCodex(cfg, project, { + haveFn: async () => true, + inspect: () => { + const entry = inspect().registrations.find(item => item.name === 'ruflo'); + return { registered: !!entry, owned: true, command: entry?.command, args: entry?.args }; + }, + runner: async (command, args) => { + assert.equal(command, 'codex'); + assert.deepEqual(args, ['mcp', 'add', 'ruflo', '--', 'ak', 'x', 'ruflo-mcp']); + fs.appendFileSync(file, canonical); + adds++; + return { code: 0, stdout: '', stderr: '' }; + }, + }); + for (let i = 0; i < 3; i++) { + assert.equal(await provisionClaude(), true); + assert.equal((await provision()).ok, true); + } + assert.equal(adds, 1); + assert.equal(claudeAdds, 1); + assert.deepEqual(claudeMcpTopology({ cwd: project, home: sandbox }).registrations.map(entry => entry.name), ['claude-flow']); + assert.equal(inspect().registrations.some(entry => entry.name === 'claude-flow'), false); + assert.equal(inspect().effectiveRufloRegistrations.length, 1); + assert.ok(fs.readFileSync(file, 'utf8').includes(unrelated)); +}); + +test('setup verifies and corrects an approved alias restored by project initialization', async () => { + seed(); + const previous = process.cwd(); + process.chdir(project); + let prompts = 0; + try { + const result = await captureLog(() => setup.run({ + pkgRoot, flags: { codex: true, project: true, yes: false }, + confirm: async () => { prompts++; return true; }, + inspectCodexTopology: inspect, repairCodexTopology: repair, + machineSetup: async () => true, + projectSetup: async () => { fs.writeFileSync(file, canonical + unrelated + legacy); return true; }, + finalizeSetup: async () => {}, + })); + assert.equal(result.result, 0, result.out); + assert.match(result.out, /future setup\/sync/); + assert.equal(prompts, 1, 'setup disclosure includes the narrowly remembered repair'); + assert.equal(inspect().duplicateRuflo, false); + assert.equal(removals, 1); + } finally { process.chdir(previous); } +}); + +test('setup reports incomplete when provisioning introduces an unrecognized duplicate', async () => { + seed(canonical); + const previous = process.cwd(); + process.chdir(project); + try { + const result = await captureLog(() => setup.run({ + pkgRoot, flags: { codex: true, project: true, yes: true }, + confirm: async () => true, + inspectCodexTopology: inspect, repairCodexTopology: repair, + machineSetup: async () => true, + projectSetup: async () => { + fs.appendFileSync(file, legacy + '[mcp_servers.claude-flow.env]\nPRIVATE_DB = "keep"\n'); + return true; + }, + finalizeSetup: async () => {}, + })); + assert.equal(result.result, 1, result.out); + assert.doesNotMatch(result.out, /setup complete/); + assert.equal(removals, 0); + } finally { process.chdir(previous); } +}); + +test('failed removal after provisioning does not create durable consent', async t => { + seed(); + let provisioned = false; + const step = sync.SYNC_STEPS.find(entry => entry.id === 'providers'); + if (step) t.mock.method(step, 'run', async () => { provisioned = true; }); + const result = await run({ yes: true, repairFn: async () => ({ ok: false, detail: 'fixture removal failed' }) }); + assert.equal(result.result, 1); + assert.equal(loadKitConfig().integrations.ownership.codex.mcpRepairConsent, undefined); + assert.equal(provisioned, true, 'legacy alias removal happens after provisioning'); +}); + +test('post-provision repair preserves a lone alias when the replacement is missing', async () => { + seed(legacy + unrelated); + const source = fs.readFileSync(file, 'utf8'); + const result = await reconcileCodexMcp({ cfg: loadKitConfig(), cwd: project, + yes: true, confirm: async () => true, inspect, repair }); + assert.equal(result.ok, false); + assert.equal(removals, 0); + assert.equal(fs.readFileSync(file, 'utf8'), source); +}); + +test('machine-only setup corrects a user alias when a canonical replacement already exists', async () => { + seed(); + const result = await captureLog(() => setup.run({ + pkgRoot, flags: { codex: true, minimal: true, yes: true }, + confirm: async () => true, + inspectCodexTopology: inspect, repairCodexTopology: repair, + machineSetup: async () => true, + projectSetup: async () => { throw new Error('machine-only setup must not initialize a project'); }, + finalizeSetup: async () => {}, + })); + assert.equal(result.result, 0, result.out); + assert.equal(inspect().duplicateRuflo, false); + assert.equal(removals, 1); +}); + +test('upstream npx canonical transport participates in duplicate detection without expanding repair consent', () => { + seed(legacy + '[mcp_servers.ruflo]\ncommand = "npx"\nargs = ["-y", "ruflo@latest", "mcp", "start"]\n'); + assert.equal(inspect().duplicateRuflo, true); +}); + +test('post-provision verification rejects an unrepairable recursive transport', async () => { + seed(canonical + '[mcp_servers.codex]\ncommand = "codex"\nargs = ["mcp-server"]\nstartup_timeout_sec = 40\n'); + const result = await reconcileCodexMcp({ cfg: loadKitConfig(), cwd: project, + yes: true, confirm: async () => true, inspect, repair }); + assert.equal(result.ok, false); + assert.equal(removals, 0); +}); + +test('native repair refuses a Codex home that differs from the approved config', async () => { + seed(); + const { codexMcpRepairPlan } = await import('../../src/lib/mcp.mjs'); + const before = snapshot(sandbox); + const result = await repairCodexMcpTopology(codexMcpRepairPlan(inspect()), project, { + inspect, codexHome: path.join(sandbox, 'another-profile'), + runner: async () => { throw new Error('wrong configuration must never be mutated'); }, + }); + assert.equal(result.ok, false); + assertUnchanged(before, sandbox, 'mismatched native Codex home'); +}); diff --git a/tests/kit/codex-mcp.test.mjs b/tests/kit/codex-mcp.test.mjs index eb94e771..6750661c 100644 --- a/tests/kit/codex-mcp.test.mjs +++ b/tests/kit/codex-mcp.test.mjs @@ -57,7 +57,7 @@ test('registered is false when .mcp.json has other servers but not codex', () => test('owned reflects the kit.json ak-ownership marker', () => { const dir = tmpProject(); try { - writeMcp(dir, { codex: {} }); + writeMcp(dir, { codex: { command: 'codex', args: ['mcp-server'] } }); assert.equal(codexMcpStatus({ integrations: { ownership: { codex: { mcp: 'ak' } } } }, dir).owned, true); assert.equal(codexMcpStatus({ integrations: { ownership: { codex: { mcp: null } } } }, dir).owned, false); assert.equal(codexMcpStatus({}, dir).owned, false); @@ -67,12 +67,20 @@ test('owned reflects the kit.json ak-ownership marker', () => { test('a pre-existing (unowned) codex server is registered but not owned', () => { const dir = tmpProject(); try { - writeMcp(dir, { codex: {} }); + writeMcp(dir, { codex: { command: 'codex', args: ['mcp-server'] } }); assert.deepEqual(codexMcpStatus({ integrations: { ownership: { codex: { mcp: null } } } }, dir), { registered: true, owned: false }); } finally { rm(dir); } }); +test('a modern server named codex is not a retired Codex transport', () => { + const dir = tmpProject(); + try { + writeMcp(dir, { codex: { type: 'http', url: 'https://example.com/mcp' } }); + assert.equal(codexMcpStatus({}, dir).registered, false); + } finally { rm(dir); } +}); + test('malformed .mcp.json degrades to not-registered (no throw)', () => { const dir = tmpProject(); try { diff --git a/tests/kit/host-alignment.test.mjs b/tests/kit/host-alignment.test.mjs new file mode 100644 index 00000000..4c336dad --- /dev/null +++ b/tests/kit/host-alignment.test.mjs @@ -0,0 +1,213 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, sandboxProject, snapshot, assertUnchanged, captureLog } from './helpers/home-sandbox.mjs'; + +const sandbox = sandboxHome('ak-host-alignment'); +const project = sandboxProject('ak-host-alignment'); +const other = sandboxProject('ak-host-alignment-other'); +const { run } = await import('../../src/commands/run.mjs'); +const legacy = { command: 'codex', args: ['mcp-server'] }; +const modern = { type: 'http', url: 'https://example.com/mcp' }; +function seed() { + fs.writeFileSync(path.join(sandbox, '.claude.json'), JSON.stringify({ mcpServers: {} })); + for (const root of [project, other]) fs.writeFileSync(path.join(root, '.mcp.json'), '{}\n'); + fs.mkdirSync(path.join(sandbox, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(sandbox, '.codex/config.toml'), ''); +} + +test('ak run refuses retired host MCP transport before launching workers', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { old: legacy } })); + const previous = process.cwd(); + process.chdir(project); + let launched = false; + try { + const result = await captureLog(() => run({ flags: { json: true }, positionals: ['feature', 'task'], + cfg: {}, executePlan: async () => { launched = true; return []; } })); + assert.equal(result.result, 1, result.out); + assert.equal(launched, false); + assert.match(result.out, /host align/); + } finally { process.chdir(previous); } +}); + +test('alignment finds actual transports across user, local and selected project scopes', async () => { + seed(); + fs.writeFileSync(path.join(sandbox, '.claude.json'), JSON.stringify({ + mcpServers: { legacyPeer: legacy, codex: modern }, + projects: { [project]: { mcpServers: { peer: legacy } }, [other]: { mcpServers: { peer: legacy } } }, + })); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { worker: legacy } })); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + assert.equal(report.findings.filter(f => f.code === 'retired-codex-mcp').length, 3); + assert.ok(report.findings.every(f => f.project !== other)); + assert.ok(report.findings.every(f => f.name !== 'codex')); +}); + +test('approved alignment preserves AQE routes, modern names, unrelated servers and other projects', async () => { + seed(); + const configFile = path.join(sandbox, '.claude.json'); + fs.writeFileSync(configFile, JSON.stringify({ mcpServers: { old: legacy, codex: modern }, + projects: { [other]: { mcpServers: { old: legacy } } }, theme: 'dark' })); + fs.mkdirSync(path.join(project, '.agentic-qe'), { recursive: true }); + const routerFile = path.join(project, '.agentic-qe/llm-config.json'); + const router = '{"agentOverrides":{"qe-test-architect":{"provider":"codex","model":"example"}},"defaultProvider":"claude-code"}\n'; + fs.writeFileSync(routerFile, router); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const plan = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + const result = await applyHostAlignment(plan, { confirmed: true }); + assert.equal(result.ok, true, JSON.stringify(result)); + const after = JSON.parse(fs.readFileSync(configFile)); + assert.deepEqual(after.mcpServers, { codex: modern }); + assert.deepEqual(after.projects[other].mcpServers.old, legacy); + assert.equal(after.theme, 'dark'); + assert.equal(fs.readFileSync(routerFile, 'utf8'), router); + assert.ok(result.backups.length > 0); + assert.equal(inspectHostAlignment({ projectRoots: [project], home: sandbox }).findings.length, 0); +}); + +test('preview and declined alignment are byte-inert', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { old: legacy } })); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const before = snapshot(project); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + const result = await applyHostAlignment(report, { confirmed: false }); + assert.equal(result.ok, false); + assertUnchanged(before, project, 'declined alignment'); +}); + +test('changed configuration invalidates approval before any write', async () => { + seed(); + const file = path.join(project, '.mcp.json'); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: legacy } })); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: modern } })); + const before = snapshot(project); + const result = await applyHostAlignment(report, { confirmed: true }); + assert.equal(result.ok, false); + assertUnchanged(before, project, 'stale approval'); +}); + +test('custom legacy transports and malformed JSON are reported but never automatically rewritten', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { old: { ...legacy, env: { PRIVATE: 'keep' } } } })); + fs.writeFileSync(path.join(other, '.mcp.json'), '{ malformed'); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project, other], home: sandbox }); + assert.ok(report.findings.some(f => f.code === 'config-unassessed')); + assert.equal(report.findings.find(f => f.code === 'retired-codex-mcp').repairable, false); +}); + +test('Claude tools-only MCP is informational and is preserved', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { tools: { command: 'claude', args: ['mcp', 'serve'] } } })); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + assert.ok(report.findings.some(f => f.code === 'claude-tools-only' && f.level === 'info')); + assert.equal(report.aligned, true); +}); + +test('alignment rejects duplicate JSON keys without rewriting their surviving value', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), '{"mcpServers":{"old":{"command":"other","command":"codex","args":["mcp-server"]}}}'); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const before = snapshot(project); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + assert.equal(report.aligned, false); + assert.equal((await applyHostAlignment(report, { confirmed: true })).ok, false); + assertUnchanged(before, project, 'duplicate JSON keys'); +}); + +test('alignment cleans project Codex self-registration while preserving modern and AQE registrations', async () => { + seed(); + fs.mkdirSync(path.join(project, '.codex'), { recursive: true }); + const file = path.join(project, '.codex/config.toml'); + const keep = '[mcp_servers.agentic-qe]\ncommand = "aqe-mcp"\n\n[mcp_servers.claude]\nurl = "https://example.com/mcp"\n'; + fs.writeFileSync(file, '[mcp_servers.codex]\ncommand = "codex"\nargs = ["mcp-server"]\n\n' + keep); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + const result = await applyHostAlignment(report, { confirmed: true }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(fs.readFileSync(file, 'utf8'), keep); +}); + +test('explicit realignment remembers only the approved transport recipe and scope', async () => { + seed(); + const file = path.join(project, '.mcp.json'); + const { alignHosts } = await import('../../src/commands/x/host-align.mjs'); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const cfg = {}; + let prompts = 0; + const options = { flags: { apply: true }, roots: [project], cfg, save: () => {}, + inspect: args => inspectHostAlignment({ ...args, home: sandbox }), + confirm: async () => { prompts++; return true; } }; + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: legacy } })); + assert.equal((await captureLog(() => alignHosts(options))).result, 0); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: legacy } })); + assert.equal((await captureLog(() => alignHosts(options))).result, 0); + assert.equal(prompts, 1); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { changed: legacy } })); + assert.equal((await captureLog(() => alignHosts(options))).result, 0); + assert.equal(prompts, 2, 'another name requires a new approval'); +}); + +test('unassessed TOML table spellings never bypass detection or orphan custom children', async () => { + seed(); + const file = path.join(sandbox, '.codex/config.toml'); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + for (const source of [ + '[ mcp_servers . codex ]\ncommand = "codex"\nargs = ["mcp-server"]\n', + '[mcp_servers.codex]\ncommand = "codex"\nargs = ["mcp-server"]\n[mcp_servers . codex . env]\nCUSTOM = "retain"\n', + ]) { + fs.writeFileSync(file, source); + const report = inspectHostAlignment({ projectRoots: [], home: sandbox }); + assert.equal(report.aligned, false); + assert.ok(report.findings.some(f => f.code === 'config-unassessed')); + assert.equal((await applyHostAlignment(report, { confirmed: true })).changed.length, 0); + assert.equal(fs.readFileSync(file, 'utf8'), source); + } +}); + +test('a custom Codex executable is detected without inheriting bare-command repair authority', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { + old: { command: '/custom/bin/codex', args: ['mcp-server'] }, + } })); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [project], home: sandbox }); + assert.equal(report.findings[0].repairable, false); +}); + +test('a multi-project preview digest is stable when its selected roots are reordered', async () => { + seed(); + fs.writeFileSync(path.join(project, '.mcp.json'), JSON.stringify({ mcpServers: { old: legacy } })); + const { inspectHostAlignment, applyHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const first = inspectHostAlignment({ projectRoots: [other, project], home: sandbox }); + const second = inspectHostAlignment({ projectRoots: [project, other], home: sandbox }); + assert.equal(first.digest, second.digest); + assert.equal((await applyHostAlignment(first, { confirmed: true })).ok, true); +}); + +test('alignment inspects the selected Codex home rather than a different default profile', async () => { + seed(); + const codexHome = path.join(sandbox, 'alternate-codex'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, 'config.toml'), '[mcp_servers.codex]\ncommand = "codex"\nargs = ["mcp-server"]\n'); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [], home: sandbox, codexHome }); + assert.equal(report.aligned, false); + assert.equal(report.findings[0].file, path.join(codexHome, 'config.toml')); +}); + +test('an overridden Claude config root is unassessed rather than falsely aligned', async () => { + seed(); + const { inspectHostAlignment } = await import('../../src/lib/host-alignment.mjs'); + const report = inspectHostAlignment({ projectRoots: [], home: sandbox, claudeConfigDir: path.join(sandbox, 'alternate-claude') }); + assert.equal(report.aligned, false); + assert.equal(report.findings[0].code, 'config-unassessed'); + assert.equal(report.findings[0].repairable, false); +}); diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index d858a736..e0a9034b 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -68,7 +68,7 @@ function fakeDejaVu(events = [], { mode = 'mcp', hosts = ['claude'], applyResult } function seedHome(cfg = offlineKitConfig()) { - rmrf(paths.claudeDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); + rmrf(paths.claudeDir(), paths.codexDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); fs.mkdirSync(paths.claudeDir(), { recursive: true }); fs.writeFileSync(paths.claudeMdPath(), '# my machine notes\n'); writeKitConfig(HOME, cfg); diff --git a/tests/kit/sync-command.test.mjs b/tests/kit/sync-command.test.mjs index 8abf2ead..2d077ebf 100644 --- a/tests/kit/sync-command.test.mjs +++ b/tests/kit/sync-command.test.mjs @@ -27,7 +27,7 @@ const PROJECT = sandboxProject('ak-sync'); const FLAGS = (over = {}) => ({ 'dry-run': false, 'no-upgrade': false, yes: false, json: false, ...over }); function seedHome(cfg = offlineKitConfig(), pkgs = {}) { - rmrf(paths.claudeDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); + rmrf(paths.claudeDir(), paths.codexDir(), paths.configDir(), path.join(HOME, '.config', 'opencode')); fs.mkdirSync(paths.claudeDir(), { recursive: true }); fs.writeFileSync(paths.claudeMdPath(), '# machine notes\n'); writeKitConfig(HOME, cfg); From a36a89cc3a978a25dd9b4c036fe51ee488d7d177 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 10 Sep 2026 11:35:25 -0700 Subject: [PATCH 2/4] feat(maintenance): add scoped host alignment reporting and correction --- docs/MAINTENANCE.md | 26 +++++ package.json | 2 +- .../client/maintenance-workspace.mjs | 1 + src/lib/maintenance/management/guidance.mjs | 2 + .../maintenance/management/host-alignment.mjs | 53 ++++++++++ src/lib/maintenance/management/model.mjs | 4 + src/lib/maintenance/management/projection.mjs | 5 +- src/lib/maintenance/management/query.mjs | 1 + .../management/service-inventory.mjs | 1 + src/lib/maintenance/provider-registry.mjs | 6 ++ .../maintenance/providers/host-alignment.mjs | 92 ++++++++++++++++ tests/kit/maintenance-host-alignment.test.mjs | 100 ++++++++++++++++++ .../kit/maintenance-management-model.test.mjs | 2 +- tests/ui/maintenance-host-alignment.mjs | 76 +++++++++++++ 14 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 src/lib/maintenance/management/host-alignment.mjs create mode 100644 src/lib/maintenance/providers/host-alignment.mjs create mode 100644 tests/kit/maintenance-host-alignment.test.mjs create mode 100644 tests/ui/maintenance-host-alignment.mjs diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index de1b9529..a5036a94 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -20,6 +20,32 @@ before provider effects: the default filesystem adapter does not establish the r durable storage on Windows. Directory-flush failures are not ignored. WSL uses its own Linux environment; native Windows mutation support remains an integration gate. +## Host alignment in User and Project views + +Select **Host alignment** under **More views**, then choose **User** or **Projects** +and an optional project filter. Use **Refresh evidence** to inspect current host +configuration. The rows identify retired peer transports and other host-alignment +anomalies without exposing configuration contents or local paths in the inventory. + +Open a row to inspect the host, scope, policy reason, preserved integrations and +available correction. **Repair registration** opens the existing exact-action +preview. Applying requires confirmation of that one registration; another finding +in the same file or another project is not included. A changed file invalidates +the preview. The transaction records verification and refreshes the affected catalog. + +Each correction creates a current-state recovery backup beside the configuration. +Automatic dashboard **Undo** is not provided for this recipe; review the backup +before manual restoration so later edits are preserved. Custom commands/environments, +ambiguous configuration and misplaced plugins remain review items instead of +receiving an unsupported Apply action. The companion plugin's separate exact +repair remains available through `ak heal hooks --host codex`. + +Dashboard approval does not grant broad future cleanup permission. The CLI's +`ak host align --apply` repair-and-remember preference remains a separate explicit +choice. Ruflo dual-mode execution, AQE native providers, provider fallbacks and +modern MCP endpoints are preserved. See +[ADR-0051](adr/0051-supported-peer-delegation-and-host-realignment.md). + ## Four destinations The dashboard workspace has four tabs. Each answers a different question. diff --git a/package.json b/package.json index b7dc7d17..bcdc0557 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ ], "scripts": { "test": "node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 \"tests/kit/*.test.mjs\" && node tests/statusline-segments.test.cjs && node tests/statusline-brain.test.cjs && node tests/agentdb.test.cjs && node tests/health-history.test.cjs && node tests/harvest.test.cjs && node tests/dashboard.test.cjs && node tests/admin-model.test.cjs && node tests/admin.test.cjs", - "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs", + "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs", "test:surface": "node --test tests/kit/dispatch-surface.test.mjs", "test:aqe-external-provider-live": "node --test tests/live/aqe-external-provider-transport.test.mjs", "test:qe-court-live": "node --test tests/live/qe-court-participant-transport.test.mjs", diff --git a/src/lib/dashboard/client/maintenance-workspace.mjs b/src/lib/dashboard/client/maintenance-workspace.mjs index af7ea70a..ec75cab9 100644 --- a/src/lib/dashboard/client/maintenance-workspace.mjs +++ b/src/lib/dashboard/client/maintenance-workspace.mjs @@ -37,6 +37,7 @@ import { ago } from './intelligence.mjs'; "duplicates":"Duplicated placements","disabled":"Disabled resources", "credentials-providers":"Credentials and providers","models-runtimes":"Models and runtimes", "storage-caches":"Storage and caches","recently-changed":"Recently changed", + "host-alignment":"Host alignment", "evidence-only":"Inventory evidence only" }; export var MNT_CONFLICT_EXPLANATIONS={ diff --git a/src/lib/maintenance/management/guidance.mjs b/src/lib/maintenance/management/guidance.mjs index ca7b8057..e65c7764 100644 --- a/src/lib/maintenance/management/guidance.mjs +++ b/src/lib/maintenance/management/guidance.mjs @@ -19,6 +19,7 @@ import { UNFINISHED_MAINTENANCE_STATUSES } from '../transaction-store.mjs'; import { isOptionalManagement, recommendationEntries, normalizeGuidanceInventory } from './guidance-purpose.mjs'; import { inspectorRelationships } from './inspector-relationships.mjs'; import { guidanceCoverage } from './guidance-coverage.mjs'; +import { hostAlignmentMatcher } from './host-alignment.mjs'; const DEPENDENCY_EDGE_KINDS = Object.freeze([ 'requires-executable', 'requires-runtime', 'requires-provider', 'requires-credential', @@ -259,6 +260,7 @@ function ollamaModelMatcher(placement, facts, ctx) { } const APPLY_MATCHERS = Object.freeze({ + 'host-alignment': hostAlignmentMatcher, 'claude-plugin': claudePluginMatcher, 'codex-plugin': codexPluginMatcher, 'codex-mcp': codexMcpMatcher, diff --git a/src/lib/maintenance/management/host-alignment.mjs b/src/lib/maintenance/management/host-alignment.mjs new file mode 100644 index 00000000..8ca5f7de --- /dev/null +++ b/src/lib/maintenance/management/host-alignment.mjs @@ -0,0 +1,53 @@ +// Pure, path-free projection of the configuration evidence provider. The +// existing scope/project query and one-placement action flow remain canonical. +import { resourceIdentity, placementIdentity, artifactIdentity, bindingIdentity } from './identity.mjs'; +import { assertion, scorecardFor } from './evidence.mjs'; +import { finalizePlacement, hostLabel } from './projection-builder.mjs'; +import { projectPresentation } from './projection-projects.mjs'; + +export function mapHostAlignment(builder, facts, ctx) { + const { installationKey, environmentId, projects, now } = ctx; + for (const entry of facts?.entries ?? []) { + const project = entry.project ? projects.get(entry.project) : null; + const scope = entry.scope === 'project' ? 'project' : 'user'; + const projectId = project?.projectId ?? null; + const kind = 'mcp-registration'; + const resourceId = resourceIdentity({ kind, sourceSelector: entry.id }, installationKey); + builder.upsertResource(resourceId, { kind, displayName: entry.name, + description: entry.message, descriptionSource: 'Host alignment policy' }); + const placementId = placementIdentity({ resourceId, environmentId, administrativeScope: scope, + projectId, locationSelector: entry.id }, installationKey); + const artifactId = artifactIdentity({ carrier: 'config-selector', locator: entry.file }, installationKey); + builder.upsertArtifact(artifactId, { carrier: 'config-selector', label: `${hostLabel(entry.host)} host configuration` }); + const bindingId = bindingIdentity({ placementId, artifactId, consumerKind: 'host', consumerLabel: hostLabel(entry.host), mechanism: 'MCP' }, installationKey); + builder.addBinding({ bindingId, placementId, artifactId, consumerKind: 'host', consumerLabel: hostLabel(entry.host), + mechanism: 'MCP', enabled: null, effectiveScope: scope, grade: 'verified', affectedByProposedAction: true }); + const evidence = ['identity', 'placement', 'consumers', 'impact'].map(field => assertion({ + subjectId: placementId, field, value: true, grade: 'verified', authority: 'configuration snapshot', + sourceRef: 'host-alignment', capturedAt: new Date(now()).toISOString(), scope, + })); + finalizePlacement(builder, { placementId, resourceId, environmentId, administrativeScope: scope, projectId, + locationBreadcrumb: project ? [...project.breadcrumb, hostLabel(entry.host), 'Host alignment'] : [hostLabel(entry.host), 'Host alignment'], + artifactIds: [artifactId], consumerBindingIds: [bindingId], + conditions: ['host-alignment-required', ...(entry.code === 'config-unassessed' ? ['source-scan-incomplete'] : [])], + evidenceScorecard: scorecardFor(evidence), displayName: entry.name, kind, + hostNamespace: entry.host, consumerHosts: [entry.host], + technicalDetails: [entry.message, 'Native Ruflo and AQE provider routing is preserved.', + entry.repairable ? 'One selected registration is corrected with a recovery backup; automatic dashboard Undo is unavailable.' : entry.remedy], + extra: { hostAlignmentResourceId: entry.id, ...(project ? projectPresentation(project) : {}) }, + }); + builder.locate(placementId, { path: entry.file }); + } +} + +export function hostAlignmentMatcher(placement, facts) { + if (!facts?.complete || !placement.hostAlignmentResourceId) return null; + const entry = facts.entries.find(e => e.id === placement.hostAlignmentResourceId && e.repairable); + if (!entry || entry.scope !== placement.administrativeScope || !placement.consumerHosts.includes(entry.host)) return null; + return { verb: 'repair-registration', operation: 'realign', outcome: 'Realign this host transport', + verifiedPremises: ['placement', 'consumers', 'impact'], + impact: { summary: 'Remove only this retired registration; retain a recovery backup.' }, + preserved: ['Other registrations', 'Ruflo and AQE native provider routing', 'Other projects'], + findingResourceKey: { kind: 'mcpServer', id: entry.id, host: entry.host, scope: entry.scope }, + }; +} diff --git a/src/lib/maintenance/management/model.mjs b/src/lib/maintenance/management/model.mjs index e8b15590..436e30bc 100644 --- a/src/lib/maintenance/management/model.mjs +++ b/src/lib/maintenance/management/model.mjs @@ -132,6 +132,7 @@ export const PLACEMENT_CONDITIONS = Object.freeze([ 'definitions-differ', 'superseded-revision', 'recovery-receipt-open', 'credential-mechanism-not-checked', 'source-scan-incomplete', 'reproducible-cache', 'orphaned-process', + 'host-alignment-required', ]); export const CONDITION_LABELS = Object.freeze({ 'healthy': 'Healthy', @@ -145,6 +146,7 @@ export const CONDITION_LABELS = Object.freeze({ 'source-scan-incomplete': 'Source scan incomplete', 'reproducible-cache': 'Reproducible cache', 'orphaned-process': 'Orphaned process', + 'host-alignment-required': 'Host realignment required', }); export const GUIDANCE_LANES = Object.freeze(['apply', 'steps', 'decision', 'update', 'recovery']); export const GUIDANCE_LANE_LABELS = Object.freeze({ @@ -167,6 +169,7 @@ export const CURATED_VIEWS = Object.freeze([ 'all', 'can-apply', 'steps', 'decisions', 'updates', 'dependencies', 'conflicts', 'duplicates', 'disabled', 'credentials-providers', 'models-runtimes', 'storage-caches', 'recently-changed', 'evidence-only', + 'host-alignment', ]); export const CURATED_VIEW_LABELS = Object.freeze({ 'all': 'All resources', @@ -183,6 +186,7 @@ export const CURATED_VIEW_LABELS = Object.freeze({ 'storage-caches': 'Storage and caches', 'recently-changed': 'Recently changed', 'evidence-only': 'Inventory evidence only', + 'host-alignment': 'Host alignment', }); export const FACETS = Object.freeze([ 'scope', 'environment', 'project', 'projectType', 'sessionOrigin', 'family', 'kind', 'adapter', 'consumer', 'carrier', 'provenance', diff --git a/src/lib/maintenance/management/projection.mjs b/src/lib/maintenance/management/projection.mjs index c3ec74c3..08bc1674 100644 --- a/src/lib/maintenance/management/projection.mjs +++ b/src/lib/maintenance/management/projection.mjs @@ -99,6 +99,7 @@ // max-lines budget; it is not a separate public contract. import path from 'node:path'; import { catalogVersions, addReleaseObservations } from './catalog-versions.mjs'; +import { mapHostAlignment } from './host-alignment.mjs'; import { createHash } from 'node:crypto'; import { MANAGEMENT_INVENTORY_SCHEMA, MANAGEMENT_SCHEMA_VERSION, assertManagementInventory, canonicalJson, sourceComplete, @@ -989,6 +990,7 @@ function runMappingStages( builder, { footprint, hookReadModel, modelSnapshot, providerDetections, discovery, byProjectId }, ctx, ) { mapCatalog(builder, footprint.catalog, ctx); + mapHostAlignment(builder, discovery.hostAlignment, ctx); mapHooks(builder, hookReadModel, ctx); mapInstallTools(builder, footprint.install?.tools, ctx); mapStorageReclaimables(builder, footprint.storage?.reclaimables, ctx); @@ -1076,7 +1078,8 @@ export function buildManagementInventory({ // needs a real, opaque projectId — `project` placements may never carry a // null one. Filled in from the presence's own lexical root before catalog // mapping runs, so mapCatalogGroup's ordinary registry lookup finds it. - registerFallbackProjectPaths(projects, projectPathsIn(footprint.catalog), { installationKey }); + registerFallbackProjectPaths(projects, [...projectPathsIn(footprint.catalog), + ...(discovery.hostAlignment?.entries ?? []).map(entry => entry.project).filter(Boolean)], { installationKey }); enrichProjectPresentation(builder, projects, [...(footprint?.projects?.projects ?? []), ...(footprint?.projects?.discoveryProjects ?? [])], { installationKey }); // Add presentation evidence after identity assignment; a better label must diff --git a/src/lib/maintenance/management/query.mjs b/src/lib/maintenance/management/query.mjs index 67079d0d..84e7519a 100644 --- a/src/lib/maintenance/management/query.mjs +++ b/src/lib/maintenance/management/query.mjs @@ -181,6 +181,7 @@ function matchesView(placement, view, index) { } if (view === 'disabled') return (placement.conditions ?? []).includes('disabled'); if (view === 'recently-changed') return placement.recentlyChangedAt != null; + if (view === 'host-alignment') return placement.conditions.includes('host-alignment-required'); if (view === 'evidence-only') return groupBucket(placement) === 'evidence-only'; return true; } diff --git a/src/lib/maintenance/management/service-inventory.mjs b/src/lib/maintenance/management/service-inventory.mjs index ff8a0b80..b05c7653 100644 --- a/src/lib/maintenance/management/service-inventory.mjs +++ b/src/lib/maintenance/management/service-inventory.mjs @@ -217,6 +217,7 @@ async function gatherAndProject(ctx, { deep }) { discovery: { instructionFiles, dependencyProbes, installResourceKinds: {}, modelStorage, pluginEvidence: { claude: detections.get('claude-plugin'), codex: detections.get('codex-plugin') }, + hostAlignment: detections.get('host-alignment'), projects: discoveryProjects, }, environment: { platform: ctx.platform }, diff --git a/src/lib/maintenance/provider-registry.mjs b/src/lib/maintenance/provider-registry.mjs index d6522d84..670fa424 100644 --- a/src/lib/maintenance/provider-registry.mjs +++ b/src/lib/maintenance/provider-registry.mjs @@ -6,6 +6,8 @@ import { createOllamaModelRemoveProvider } from './providers/ollama-model-remove import { createOwnedNpxCacheProvider } from './providers/owned-storage.mjs'; import { createOwnedSkillProvider } from './providers/owned-skill.mjs'; import { createRufloMcpOrphanProvider } from './providers/ruflo-mcp-orphan.mjs'; +import { createHostAlignmentProvider } from './providers/host-alignment.mjs'; +import { configuredHostProjects } from '../host-alignment.mjs'; import { managedBaseline } from '../npx.mjs'; import { npxCacheDir } from '../paths.mjs'; @@ -66,6 +68,10 @@ export function createDefaultMaintenanceProviderRegistry(options = {}) { createCodexPluginProvider(options.codexPlugin), createCodexMcpProvider(options.codexMcp), createRufloMcpOrphanProvider(options.rufloMcpOrphan), + createHostAlignmentProvider({ projectRoots: options.hostAlignment?.projectRoots + ?? [...new Set([process.cwd(), ...(options.footprint?.projects?.projects ?? []).map(p => p.path).filter(Boolean)])], + discoverProjects: configuredHostProjects, + ...options.hostAlignment }), ]; const candidates = (options.footprint?.storage?.reclaimables ?? []) .filter((row) => row?.kind === 'stale-npx-env'); diff --git a/src/lib/maintenance/providers/host-alignment.mjs b/src/lib/maintenance/providers/host-alignment.mjs new file mode 100644 index 00000000..9ebeea7d --- /dev/null +++ b/src/lib/maintenance/providers/host-alignment.mjs @@ -0,0 +1,92 @@ +import os from 'node:os'; +import { inspectHostAlignment, applyHostAlignment, hostAlignmentFindingId } from '../../host-alignment.mjs'; +import { projectReference } from '../evidence.mjs'; +import { baseAction, providerFinding, sha256 } from './shared.mjs'; + +const ID = 'host-alignment'; +const VERSION = 'v1'; +const fingerprint = (finding, snapshot) => sha256({ id: hostAlignmentFindingId(finding), source: snapshot?.digest, identity: snapshot?.identity }); +const absent = id => sha256({ id, absent: true }); +const displayName = finding => `Host alignment: ${/^[a-zA-Z0-9._-]{1,80}$/.test(finding.name ?? '') ? finding.name : 'configuration'}`; + +/** @param {{projectRoots?:string[],home?:string,inspect?:typeof inspectHostAlignment,applyAlignment?:typeof applyHostAlignment,discoverProjects?:(home:string)=>string[]}} [options] */ +export function createHostAlignmentProvider({ + projectRoots = [process.cwd()], home = os.homedir(), inspect = inspectHostAlignment, applyAlignment = applyHostAlignment, + discoverProjects = () => [], +} = {}) { + function detect() { + const report = inspect({ projectRoots: [...new Set([...projectRoots, ...discoverProjects(home)])], home }); + const entries = report.findings.filter(f => f.level === 'fail').map(finding => ({ + id: hostAlignmentFindingId(finding), host: finding.host, + scope: finding.scope === 'local' ? 'project' : finding.scope, + project: finding.project ?? null, file: finding.file, name: displayName(finding), + code: finding.code, message: finding.message, remedy: finding.remedy, + repairable: finding.repairable, sourceFingerprint: fingerprint(finding, report.snapshots.find(s => s.file === finding.file)), + })); + return { status: 'available', complete: !report.findings.some(f => f.code === 'config-unassessed'), + authority: 'configuration-snapshot', entries, asOf: new Date().toISOString() }; + } + + function findings(facts) { + return (facts.entries ?? []).map(entry => providerFinding({ + providerId: ID, providerVersion: VERSION, stableKey: entry.id, + state: 'stale-configuration', bucket: 'needsReview', classification: `host-alignment:${entry.code}`, + safetyClass: entry.repairable ? 'approval-required' : 'never-automatic', + resource: { id: entry.id, kind: 'mcpServer', name: entry.name, host: entry.host, scope: entry.scope, + projectRef: projectReference(entry.project) }, + ownership: { owner: 'user', authority: 'explicit-scoped-approval', managed: false }, + evidence: { sources: ['host-alignment:configuration-snapshot'], asOf: facts.asOf, + completeness: facts.complete ? 'complete' : 'partial' }, + impact: { summary: entry.message, files: 1, preserved: ['AQE and Ruflo native routing', 'Other registrations and settings'], + preview: ['Remove only the selected retired transport definition.', 'Save a current-state recovery backup beside its configuration.', 'Verify the selected finding is gone. Automatic dashboard Undo is unavailable.'] }, + operation: 'realign', label: 'Realign host transport', executable: entry.repairable, + rollback: 'irreversible', restart: 'required', + recommendation: entry.repairable ? 'Preview and approve this exact host transport correction.' : entry.remedy, + steps: ['Review the selected host, scope and registration.', 'Approve this exact correction.', 'Restart the affected host session and rescan.'], + preserved: ['AQE provider configuration', 'Ruflo dual-mode routing', 'Other projects and MCP registrations'], + })); + } + + function actionFor(finding, facts) { + if (!facts?.complete || finding.nextAction?.providerId !== ID || finding.nextAction.operation !== 'realign') return null; + const entry = facts.entries.find(item => item.id === finding.resource?.id && item.repairable); + if (!entry) return null; + return baseAction(finding, { providerId: ID, providerVersion: VERSION, operation: 'realign', + sourceFingerprint: entry.sourceFingerprint, rollback: 'irreversible', restart: 'required' }); + } + + function current(action) { + if (action?.providerId !== ID || action.operation !== 'realign') return null; + const facts = detect(); + return facts.complete && facts.entries.find(entry => entry.id === action.resourceIdentity?.id + && entry.repairable && entry.sourceFingerprint === action.sourceFingerprint) || null; + } + + async function apply(action) { + const entry = current(action); + if (!entry) return { status: 'unknown', summary: 'Host alignment target changed; refresh the preview.' }; + const report = inspect({ projectRoots: entry.project ? [entry.project] : [], home, selectedFindingIds: [entry.id] }); + const observed = report.findings.find(f => hostAlignmentFindingId(f) === entry.id); + if (!observed || fingerprint(observed, report.snapshots.find(s => s.file === entry.file)) !== action.sourceFingerprint) { + return { status: 'unknown', summary: 'Host alignment source changed before correction.' }; + } + const result = await applyAlignment(report, { confirmed: true }); + return result.ok ? { status: 'applied', postFingerprint: absent(entry.id), + summary: 'Selected host transport realigned; a recovery backup was saved beside the configuration.' } + : { status: 'unknown', summary: 'Host correction did not verify; inspect the configuration and recovery backups.' }; + } + + function verify(action, outcome) { + const facts = detect(); + const removed = facts.complete && !facts.entries.some(entry => entry.id === action.resourceIdentity?.id); + const postFingerprint = removed ? absent(action.resourceIdentity?.id) : null; + return { ok: !!removed && postFingerprint === outcome.postFingerprint, postFingerprint }; + } + + return { id: ID, version: VERSION, authority: 'configuration-snapshot', status: 'available', + resourceKinds: ['mcpServer'], operations: ['realign'], rollback: ['irreversible'], + limitations: [{ code: 'manual-backup-recovery', message: 'Recovery backups are retained; automatic dashboard Undo is unavailable.' }], + detect, findings, actionFor, apply, verify, + preflight: async action => { const entry = current(action); return { ok: !!entry, sourceFingerprint: entry?.sourceFingerprint ?? null }; }, + }; +} diff --git a/tests/kit/maintenance-host-alignment.test.mjs b/tests/kit/maintenance-host-alignment.test.mjs new file mode 100644 index 00000000..c94cdab3 --- /dev/null +++ b/tests/kit/maintenance-host-alignment.test.mjs @@ -0,0 +1,100 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, sandboxProject } from './helpers/home-sandbox.mjs'; +const sandbox = sandboxHome('ak-maintenance-alignment'); +const project = sandboxProject('ak-maintenance-alignment'); +const { buildManagementInventory } = await import('../../src/lib/maintenance/management/projection.mjs'); +const { runInventoryQuery } = await import('../../src/lib/maintenance/management/query.mjs'); +const KEY = 'host-alignment-ui-test-key'; + +const entry = (id, scope, projectPath = null) => ({ id, host: 'claude', scope, project: projectPath, + file: projectPath ? path.join(projectPath, '.mcp.json') : path.join(sandbox, '.claude.json'), + name: 'Host alignment: codex', message: 'Retired Codex transport', repairable: true, + code: 'retired-codex-mcp', sourceFingerprint: 'a'.repeat(64) }); + +test('host-alignment findings appear in distinct User and Project filtered views without leaking paths', () => { + const { inventory } = buildManagementInventory({ installationKey: KEY, environment: { platform: process.platform }, + discovery: { hostAlignment: { entries: [entry('align-user', 'user'), entry('align-project', 'project', project)] } }, + }); + const user = runInventoryQuery(inventory, { scope: 'user', view: 'host-alignment' }); + const projects = runInventoryQuery(inventory, { scope: 'project', view: 'host-alignment' }); + assert.equal(user.total, 1); + assert.equal(projects.total, 1); + assert.ok(inventory.placements.find(p => p.administrativeScope === 'project').projectId); + assert.equal(JSON.stringify(inventory).includes(sandbox), false); + assert.equal(JSON.stringify(inventory).includes(project), false); +}); + +test('Maintenance preview/apply corrects only the selected registration, even when two share a config', async () => { + const file = path.join(sandbox, '.claude.json'); + const legacy = { command: 'codex', args: ['mcp-server'] }; + fs.writeFileSync(file, JSON.stringify({ mcpServers: { first: legacy, second: legacy } })); + const { createHostAlignmentProvider } = await import('../../src/lib/maintenance/providers/host-alignment.mjs'); + const provider = createHostAlignmentProvider({ home: sandbox, projectRoots: [project] }); + const facts = await provider.detect(); + const finding = provider.findings(facts).find(f => f.resource.name.includes('first')); + const action = provider.actionFor(finding, facts); + assert.ok(action); + assert.equal((await provider.preflight(action)).ok, true); + const outcome = await provider.apply(action); + assert.equal(outcome.status, 'applied'); + assert.equal((await provider.verify(action, outcome)).ok, true); + const after = JSON.parse(fs.readFileSync(file)); + assert.deepEqual(after.mcpServers, { second: legacy }); + assert.equal((await provider.detect()).entries.length, 1); +}); + +test('a stale Maintenance action cannot remove a changed registration', async () => { + const file = path.join(sandbox, '.claude.json'); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: { command: 'codex', args: ['mcp-server'] } } })); + const { createHostAlignmentProvider } = await import('../../src/lib/maintenance/providers/host-alignment.mjs'); + const provider = createHostAlignmentProvider({ home: sandbox, projectRoots: [] }); + const facts = await provider.detect(); + const action = provider.actionFor(provider.findings(facts)[0], facts); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: { type: 'http', url: 'https://example.com/mcp' } } })); + const source = fs.readFileSync(file, 'utf8'); + assert.equal((await provider.preflight(action)).ok, false); + assert.equal((await provider.apply(action)).status, 'unknown'); + assert.equal(fs.readFileSync(file, 'utf8'), source); +}); + +test('an unassessed project configuration remains visible and cannot receive an apply action', async () => { + fs.writeFileSync(path.join(project, '.mcp.json'), '{ broken'); + const { createHostAlignmentProvider } = await import('../../src/lib/maintenance/providers/host-alignment.mjs'); + const provider = createHostAlignmentProvider({ home: sandbox, projectRoots: [project] }); + const facts = await provider.detect(); + const { inventory } = buildManagementInventory({ installationKey: KEY, environment: { platform: process.platform }, + discovery: { hostAlignment: facts } }); + const row = inventory.placements.find(p => p.administrativeScope === 'project'); + assert.ok(row.projectId); + assert.ok(row.conditions.includes('source-scan-incomplete')); + assert.equal(provider.actionFor(provider.findings(facts)[0], facts), null); + fs.writeFileSync(path.join(project, '.mcp.json'), '{}'); +}); + +test('Maintenance transaction requires confirmation and records a verified one-registration correction', { + skip: process.platform === 'win32' ? 'existing Maintenance durable mutation backend is POSIX-only' : false, +}, async () => { + const file = path.join(sandbox, '.claude.json'); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { old: { command: 'codex', args: ['mcp-server'] } } })); + const { createHostAlignmentProvider } = await import('../../src/lib/maintenance/providers/host-alignment.mjs'); + const { createMaintenanceService } = await import('../../src/lib/maintenance/service.mjs'); + const provider = createHostAlignmentProvider({ home: sandbox, projectRoots: [project] }); + const now = Date.now(); + const footprint = { generatedAt: new Date(now).toISOString(), snapshot: { present: true, asOf: now, stale: false, ageMs: 0 }, + catalog: { asOf: now, complete: true, degraded: [], truncated: [], partial: [], sourceStamps: [], items: [] }, + storage: { asOf: now, reclaimables: [] } }; + const service = createMaintenanceService({ providers: new Map([[provider.id, provider]]), + collector: { read: async () => footprint, isScanning: () => false, refreshDeep: async () => ({ ok: true, persisted: { ok: true } }) }, + controlRoot: path.join(sandbox, 'control'), now: () => now }); + const report = await service.scan(); + const finding = report.findings.find(f => f.nextAction.providerId === 'host-alignment'); + const plan = await service.plan({ findingIds: [finding.id], executable: true }); + await assert.rejects(() => service.apply({ plan, actionIds: [plan.actions[0].id], expectedPlanDigest: plan.planDigest }), /confirmation/i); + const result = await service.apply({ plan, actionIds: [plan.actions[0].id], expectedPlanDigest: plan.planDigest, confirmed: true }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.receipt.actions[0].verification.verified, true); + assert.deepEqual(JSON.parse(fs.readFileSync(file)).mcpServers, {}); +}); diff --git a/tests/kit/maintenance-management-model.test.mjs b/tests/kit/maintenance-management-model.test.mjs index 5bb6603e..fc380663 100644 --- a/tests/kit/maintenance-management-model.test.mjs +++ b/tests/kit/maintenance-management-model.test.mjs @@ -190,7 +190,7 @@ test('MNT-DSC-013: complete coverage cannot carry pending partitions and source test('fixture ids derive from the fixture key only', () => { assert.equal(id('plc', { a: 1 }), opaqueId('plc', { a: 1 }, FIXTURE_KEY)); - assert.equal(CURATED_VIEWS.length, 14); + assert.equal(CURATED_VIEWS.length, 15); }); test('MNT-INV-004/J2: a shared-artifact set may hold one placement but must name exactly one artifact; other kinds need two placements', () => { diff --git a/tests/ui/maintenance-host-alignment.mjs b/tests/ui/maintenance-host-alignment.mjs new file mode 100644 index 00000000..a7853d37 --- /dev/null +++ b/tests/ui/maintenance-host-alignment.mjs @@ -0,0 +1,76 @@ +// Real Maintenance markup/client/projection with fixture HTTP responses. +// Mutation authority is covered separately by the real transaction-service test. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { chromium } from 'playwright'; +import { renderPage } from '../../src/lib/dashboard/page.mjs'; +import { CSS } from '../../src/lib/dashboard/styles.mjs'; +import { buildManagementInventory } from '../../src/lib/maintenance/management/projection.mjs'; +import { admitGuidance, inspectorFor } from '../../src/lib/maintenance/management/guidance.mjs'; +import { runInventoryQuery } from '../../src/lib/maintenance/management/query.mjs'; +import { publicInventoryPage, publicInspector } from '../../src/lib/dashboard/maintenance-api.mjs'; + +const KEY = 'host-alignment-browser-fixture'; +function clientSource(name) { + return fs.readFileSync(new URL('../../src/lib/dashboard/client/'+name+'.mjs', import.meta.url), 'utf8') + .replace(/^import\s[\s\S]*?from ['"][^'"]+['"];\s*$/gm, '') + .replace(/\bexport (?=(?:function|var)\b)/g, ''); +} + +test('Host alignment view filters User and Project rows and offers exact registration preview', async t => { + const entries = [ + { id: 'host-alignment-user', name: 'Host alignment: user-peer', host: 'claude', scope: 'user', project: null, file: '/fixture/user/.claude.json' }, + { id: 'host-alignment-project', name: 'Host alignment: project-peer', host: 'claude', scope: 'project', project: '/fixture/project', file: '/fixture/project/.mcp.json' }, + ].map(e => ({ ...e, repairable: true, code: 'retired-codex-mcp', message: 'Retired Codex MCP transport', sourceFingerprint: 'a'.repeat(64) })); + const facts = { status: 'available', complete: true, entries }; + const projected = buildManagementInventory({ installationKey: KEY, environment: { platform: 'darwin' }, discovery: { hostAlignment: facts } }).inventory; + const inventory = admitGuidance({ inventory: projected, installationKey: KEY, + providers: new Map([['host-alignment', { version: 'v1', operations: ['realign'] }]]), + detections: new Map([['host-alignment', facts]]) }).inventory; + assert.ok(inventory.guidanceEntries.some(entry => entry.lane === 'apply'), JSON.stringify(inventory.guidanceEntries)); + const markup = renderPage({ name: 'Fixture', version: 'test' }).match(/
/)[0]; + const browser = await chromium.launch({ channel: 'chrome', headless: true }); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1360, height: 1000 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('http://alignment.test/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname.includes('/inventory')) { + const query = { scope: url.searchParams.get('scope') || 'across', view: url.searchParams.get('view') || 'all', presentation: 'flat' }; + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(publicInventoryPage(runInventoryQuery(inventory, query))) }); + } + if (url.pathname.includes('/placements/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify(publicInspector(inspectorFor(inventory, url.pathname.split('/').pop()))) }); + return route.fulfill({ contentType: 'text/html', body: '
'+markup+'

' }); + }); + await page.goto('http://alignment.test/'); + await page.addScriptTag({ content: ` + function authHeaders(){return {};} + function esc(value){return String(value).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} + function ago(){return '';} + function beginMaintPreview(button, request){window.selectedPreview=request;} + ${['maintenance-workspace','maintenance-operation','maintenance-cards','maintenance-filters','maintenance-guidance','maintenance-relationships','maintenance-inspector','maintenance-language-logos','maintenance-focus','maintenance-inventory'].map(clientSource).join('\n')} + MNT.scope='user';MNT.view='host-alignment';wireMntInventory();wireMntInspector();wireMntGuidance();loadMntInventory(); + ` }); + await page.locator('[data-mnt-plc]').first().waitFor(); + assert.match(await page.locator('#mnt-results').innerText(), /user-peer/); + assert.doesNotMatch(await page.locator('#mnt-results').innerText(), /project-peer/); + await page.locator('[data-mnt-plc]').first().click(); + await page.waitForFunction(() => !globalThis.mntInspectorBusy); + assert.equal(errors.length, 0, errors.join('\n')); + assert.match(await page.locator('#mnt-inspector').innerText(), /Realign|Repair registration/, String(await page.evaluate(() => mntInspectorError && (mntInspectorError.stack || mntInspectorError)))); + const preview = page.locator('[data-mnt-plan-plc]'); + await preview.waitFor(); + await preview.click(); + const selected = await page.evaluate(() => window.selectedPreview); + assert.equal(selected.placementId, inventory.placements.find(p => p.administrativeScope === 'user').placementId); + await page.locator('[data-mnt-scope="project"]').click(); + await page.waitForFunction(() => !globalThis.mntInventoryBusy); + assert.match(await page.locator('#mnt-results').innerText(), /project-peer/); + assert.doesNotMatch(await page.locator('#mnt-results').innerText(), /user-peer/); + assert.equal(errors.length, 0, errors.join('\n')); + await page.screenshot({ path: path.join(os.tmpdir(), 'maintenance-host-alignment.png'), fullPage: true }); +}); From 938e0b9bd490c26464c5d872cf6a2e44b1b8382d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 10 Sep 2026 11:36:14 -0700 Subject: [PATCH 3/4] docs(hosts): record delegation policy and verified realignment evidence --- MAINTAINER.md | 1 + docs/UPGRADING.md | 65 ++++++++ ...dex-mcp-and-bound-qe-court-participants.md | 16 +- ...ed-peer-delegation-and-host-realignment.md | 157 ++++++++++++++++++ docs/adr/README.md | 1 + .../dual-host-mcp-convergence-2026-09-10.md | 71 ++++++++ docs/evidence/host-realignment-2026-09-10.md | 52 ++++++ package.json | 1 + 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0051-supported-peer-delegation-and-host-realignment.md create mode 100644 docs/evidence/dual-host-mcp-convergence-2026-09-10.md create mode 100644 docs/evidence/host-realignment-2026-09-10.md diff --git a/MAINTAINER.md b/MAINTAINER.md index 56637d0d..3cda5a8f 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -104,6 +104,7 @@ docs/ `docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md`, `docs/adr/0043-managed-ruflo-browser-executor.md`, `docs/adr/0044-receipt-aware-maintenance-control-plane.md`, +`docs/adr/0051-supported-peer-delegation-and-host-realignment.md`, `tests/live/aqe-external-provider-transport.test.mjs`, `tests/live/qe-court-participant-transport.test.mjs`, `tests/live/codex-context-contract.test.mjs`, and diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f38768f8..f6af9d9c 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -5,6 +5,71 @@ latest capability is *two* motions, not one: get the newer code, then turn the f This page exists because those two are easy to conflate — and `ak sync`, despite its name, updates the code and reconverges choices you have already made. +## Supported host delegation and realignment + +Claude and Codex remain ambidextrous through their native CLI workers. Agentic-kit +uses `ak run`; Ruflo's dual-mode orchestrator and AQE's `claude-code` / `codex` +providers retain their own supported routes. The optional Codex plugin in Claude +uses App Server. These paths do not require the retired `codex mcp-server`. + +To audit and correct this workstation: + +```bash +ak host align --all-projects +ak host align --all-projects --apply +``` + +The first command is read-only. The second names the affected files and offers +backed-up removal of recognized retired transports. `--yes` approves the displayed +corrections noninteractively. Approval remembers the exact repair recipe, +file, host, scope, project and name; later matching corrections do not prompt again. +Remove `integrations.hostAlignment` from `kit.json` to revoke that preference. + +The all-projects scope combines the bounded session census with existing projects +declared in Claude configuration, plus the home-directory `.mcp.json`. Add +`--project /absolute/path` for a project or worktree not in those sources. +Custom environments or executables, ambiguous syntax, symlinks, and misplaced +plugins require review. The companion plugin's existing correction workflow is +`ak heal hooks --host codex`; alignment does not reinstall or delete plugins. + +`ak status` reports user/current-project anomalies. Setup and sync offer +realignment, and `ak run` refuses affected workers while blocking anomalies +remain. AQE routing, provider fallbacks, modern servers named `codex` or `claude`, +and supported `claude mcp serve` tool exposure are preserved. See +[ADR-0051](adr/0051-supported-peer-delegation-and-host-realignment.md) for the policy, +official source citations, authority boundaries and verification limits. + +## 2026-09-10: Remembered Codex MCP correction + +Claude Code's `claude-flow` registration and Codex's `ruflo` registration follow +Ruflo's host-specific conventions. Seeing both names across hosts is expected; +two enabled Ruflo connections inside Codex need review. + +Run `ak sync` when status reports a duplicate. The repair prompt names the exact +configuration and offers to remember correction of the recognized user-scope +`claude-flow` alias. Accepting that prompt, or the equivalent disclosed setup +manifest with `--yes`, authorizes later setup/sync runs to repeat this bounded +correction. Historical approvals are not converted into remembered consent. + +Legacy alias removal runs after provisioning, with an enabled canonical `ruflo` +replacement present. Each removal retains the live fingerprint check, creates a +current-state backup, and verifies the result. The remembered correction is used +only while agentic-kit owns the workspace-aware `ak x ruflo-mcp` replacement. +It does not authorize removing project entries, other names, custom commands, +custom environment settings, or plugin-provided servers. Standard upstream `npx` +launch forms are recognized for diagnostics; they do not expand removal consent. + +Setup and sync recheck the final topology. An unresolved duplicate or in-scope +recursive transport prevents a success verdict. Machine-only setup can repair a +user-scope duplicate when its replacement already exists, without editing project +registrations. A mismatched `CODEX_HOME` stops native removal before any write. + +The preference is stored at +`integrations.ownership.codex.mcpRepairConsent` in `kit.json`. Remove that property +to revoke remembered correction. Future matching repairs will ask again. This +protects the outcome of setup/sync; it cannot prevent another program from editing +configuration between runs. + ## 2026-09-04: Human session identity in System `storage.topSessions[]` now carries an additive `identity` object with the original storage name, diff --git a/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md b/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md index f5287c21..a7937c81 100644 --- a/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md +++ b/docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md @@ -3,7 +3,7 @@ - **Status:** Implemented; handoff transport amended by [ADR-0034](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md) - **Date:** 2026-08-25 -- **Updated:** 2026-08-31 +- **Updated:** 2026-09-10 - **Update note:** Initial implementation retires only receipt-owned legacy MCP state, diagnoses effective Codex MCP topology, extends POSIX cleanup to process groups, and adds fail-closed QE-Court readiness plus a reciprocal live participant-transport regression. @@ -16,6 +16,12 @@ release drift is now actionable only when GitHub publishes the exact bundle asset consumed by the installer; tag-only releases remain visible but are deferred without touching the healthy installed Brain. + 2026-09-10: setup/sync explicitly disclose remembered correction of the exact + recognized user-scope legacy Ruflo alias. Legacy removal is deferred until + after provisioning and requires an enabled canonical replacement. Successful + removal can record consent for matching future repairs while the workspace-aware + replacement remains agentic-kit-owned. Final topology verification rejects + unresolved duplication and in-scope recursion; custom entries remain preserved. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0001-one-routing-policy-many-projections.md), [ADR-0006](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0006-primary-host-and-ambidextrous-mirroring.md), @@ -50,8 +56,12 @@ seats. A successful Claude/Codex transport check therefore cannot be called a co 2. Setup, sync, and host selection stop creating `codex mcp-server`. They remove a legacy project registration only when `integrations.ownership.codex.mcp === "ak"`, confirm its absence, and clear the receipt only after confirmation. User-owned recursive and legacy duplicate entries - are disclosed by `ak sync`, removed only after explicit confirmation (or `--yes`), and verified; - unrelated user-owned entries remain preserved. + are disclosed by `ak sync`, removed only after explicit confirmation (or `--yes`), and verified. + A new repair-and-remember approval may persist the recognized user-scope alias + correction for later setup/sync runs; historical approvals confer no ongoing + authority. Reuse requires the same absolute config, alias and launch shape, + plus the enabled agentic-kit-owned workspace-aware replacement. Unrelated + user-owned entries remain preserved. 3. Codex keeps one independent, workspace-aware Ruflo MCP registration. Agentic-QE continues to own its Codex platform/MCP integration. Agentic-kit detects recursive Codex self-registration, missing concrete Agentic-QE registration, and duplicate Ruflo transports without rewriting diff --git a/docs/adr/0051-supported-peer-delegation-and-host-realignment.md b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md new file mode 100644 index 00000000..86b20eaf --- /dev/null +++ b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md @@ -0,0 +1,157 @@ +# ADR-0051 — Supported peer delegation and host realignment + +- **Status:** Accepted; implemented locally, release not published +- **Date:** 2026-09-10 +- **Deciders:** Project maintainer, through the current design discussion +- **Amends:** [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) +- **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), + [ADR-0018](0018-generalized-host-worker-execution.md), + [ADR-0037](0037-complexity-program-structural-patterns.md), + [ADR-0040](0040-codex-hook-audit-and-conservative-remediation.md) + +## Context + +The maintainer requires ambidextrous hosts, no continued support for retired +transports, and an explicit offer of correction whenever user- or project-scoped +configuration diverges. Repeated setup or upgrades must not silently restore a +degraded state or report success while an anomaly remains. + +MCP registration, agent execution, inference-provider selection, and routing +policy are different mechanisms. Removing every registration or provider named +`codex` or `claude` would break valid integrations. Research of installed Ruflo +3.41.1 and Agentic QE 3.14.1 established independent supported CLI execution paths. + +## Decision + +### Preserve supported delegation at each owning layer + +| Layer | Supported mechanism | Owner | +| --- | --- | --- | +| General managed workflows | `ak run` supervises `claude --print` and `codex exec` | agentic-kit | +| Ruflo dual-mode workflows | Ruflo's orchestrator launches Claude/Codex CLI workers | Ruflo | +| QE inference | AQE's `claude-code` and `codex` providers launch the corresponding CLIs | Agentic QE | +| Interactive Claude to Codex | Optional official Codex companion plugin using App Server | User / OpenAI | +| MCP capabilities | Ruflo, AQE, and other tool servers expose their own tools | Respective integration owner | + +Either Claude or Codex can initiate work targeting the other through the supported +execution paths. Ambidexterity means usable peer execution; it does not require +identical protocol names, identical capabilities, or a symmetric plugin pair. +OpenCode and admitted external adapters retain their existing capability gates; +this decision does not promote an adapter or grant new execution authority. + +AQE's project `llm-config.json`, `agentOverrides`, provider enablement, external +provider declarations, and user fallback choices remain in their existing +ownership domains. Agentic-kit continues projecting only its curated explicit +activity routes. Realignment never rewrites these into another execution system. + +### Identify transport anomalies by behavior, not names + +- `codex mcp-server` is retired, whether registered in Claude, Codex, or under + another alias. Do not provision it as a supported path. +- Codex registered inside Codex through that transport is a self-registration + hazard, not evidence of Claude interoperability. +- `codex@openai-codex` belongs in Claude. An enabled Codex copy is a placement + anomaly handled by the existing exact plugin-healing workflow. +- `claude mcp serve` is currently supported for exposing Claude Code's tools. It + is informational, not deprecated, and is not substituted for a Claude agent + session. Preserve intentionally configured tool servers. +- Modern remote servers named `codex` or `claude`, valid native providers, and + Ruflo's host-specific `claude-flow` / `ruflo` names are not anomalies by name. + +### Inspect, offer, correct, and verify + +`ak status` reports relevant user and current-project findings without mutation. +`ak host align` previews the same policy; explicit `--project` locations and +`--all-projects` extend inspection to the bounded census and existing projects +declared in Claude configuration, preserving distinct worktree locations. +Additional locations can be selected explicitly. The all-projects scope also examines the home directory's +`.mcp.json` as a project-location file, not as a global Claude registration. + +`--apply` offers exact file/scope/name corrections. A newly accepted correction +may be remembered for that same recipe and location; historical approval grants +no new ongoing authority. A new name, scope, custom environment, command shape, +or ambiguous file requires review. Approval does not convert arbitrary external +configuration into agentic-kit-owned data. + +Every write requires a fresh matching source snapshot, regular bounded files, +a current-state recovery copy, and post-write verification. Unrelated settings, +servers, projects, provider routing, and plugin installations are preserved. +Malformed/ambiguous configuration is unassessed, never silently called aligned. +Partial failure reports completed changes and recovery copies without claiming +success. Existing exact plugin healing remains the correction path for a +misplaced companion; no new broad plugin mutation is introduced. + +Setup/sync offer alignment and reject unresolved in-scope anomalies. `ak run` +refuses to launch an affected host while its selected scope contains a blocking +transport anomaly. The guard includes configured escalation hosts. + +## Grounding and rationale + +1. OpenAI explicitly marks `codex mcp-server` deprecated and directs Claude users + to its companion plugin/App Server: [official notice](https://learn.chatgpt.com/docs/mcp-server). +2. `codex exec` is documented for pipeline use and structured events: + [OpenAI non-interactive mode](https://learn.chatgpt.com/docs/non-interactive-mode). +3. `claude -p` exposes the agent loop programmatically: + [Anthropic programmatic usage](https://code.claude.com/docs/en/headless). +4. `claude mcp serve` exposes tools, with confirmation delegated to the client: + [Anthropic MCP server documentation](https://code.claude.com/docs/en/mcp#use-claude-code-as-an-mcp-server). +5. Ruflo launches each native CLI in `executeHeadless`: + [dual-mode orchestrator](https://github.com/ruvnet/ruflo/blob/main/v3/%40claude-flow/codex/src/dual-mode/orchestrator.ts). +6. AQE independently launches each CLI and strips the relevant API billing keys: + [Claude provider](https://github.com/proffesor-for-testing/agentic-qe/blob/main/src/shared/llm/providers/claude-code.ts), + [Codex provider](https://github.com/proffesor-for-testing/agentic-qe/blob/main/src/shared/llm/providers/codex.ts). + +The installed AQE resolver selected Codex for test architecture/security scanning +and Claude Code for code/security review under this workstation's existing +overrides. That proves route resolution, not authentication or model availability. +Those routes must resolve identically before and after MCP realignment. + +Ruflo also ships a separate optional HTTP MCP bridge with a legacy Codex backend. +Its configuration is not the native dual-mode orchestrator or AQE provider route. +Do not silently patch installed upstream code or assume that bridge is running. +An active use of that backend requires an upstream migration, not removal of +working native providers. Environment flags alone do not prove runtime health. + +## Consequences and limits + +The managed surface rejects retired transports without maintaining a legacy +execution fallback. Detection and migration remain necessary to remove residue. +Supported upstream execution paths coexist rather than being replaced by a +single new router. Host login, provider billing, model access, sandbox settings, +and runtime health remain separate facts. + +This policy governs agentic-kit's workflows and explicit alignment actions. It +cannot prevent another application editing configuration or police every direct +upstream CLI invocation. AQE/Ruflo children may inherit other host configuration; +removing the known legacy transport is not a proof of complete child isolation. + +## Acceptance evidence + +Required checks: user/local/project coverage; fresh and repeated alignment; +declined and stale approval; custom/modern transport preservation; distinct +worktree scope; no secret payload in public output; unchanged AQE routes; and +zero worker launches when a relevant retired transport is detected. + +Implementation: `src/lib/host-alignment.mjs`, `src/commands/x/host-align.mjs`, +status/setup/sync integration, and the pre-execution guard in `src/commands/run.mjs`. +Executable regressions: `tests/kit/host-alignment.test.mjs`. + +### Maintenance dashboard amendment — 2026-09-10 + +The Maintenance inventory exposes a Host alignment view compatible with User, +Project and specific-project filters. A read-only evidence provider projects +opaque placement identities and field-local evidence; raw configuration and paths +remain outside public inventory payloads. Unassessed project files retain their +project identity and cannot abort the entire projection or obtain an Apply action. + +The existing one-placement transaction workflow owns preview, explicit approval, +source revalidation, application, verification and receipt recording. The provider +selects one finding ID, even when several findings share a physical file. It never +delegates a row click to the CLI's whole-scope apply. This recipe retains a backup +but does not offer automated dashboard Undo. Dashboard approval does not silently +create the CLI's remembered correction policy. + +Regression evidence covers scope filtering, path-free projection, stale action +rejection, selected-only removal, and the real transaction coordinator. Browser +verification exercises the actual markup, filtering client and preview selection +against deterministic evidence fixtures. diff --git a/docs/adr/README.md b/docs/adr/README.md index d525eaec..4b6e6877 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -60,6 +60,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0047](0047-streaming-observation-forest.md) | Streaming observation forest for deep scans | Accepted; Projects pilot and separate Discovery continuation implemented | | [0048](0048-inventory-led-maintenance-resource-management.md) | Inventory-led Maintenance resource management | Accepted; Focus browser implemented and focused checks pass; human/cross-platform gates pending | | [0050](0050-dashboard-project-identity-and-context-reporting.md) | Dashboard project identity and context reporting | Implemented | +| [0051](0051-supported-peer-delegation-and-host-realignment.md) | Supported peer delegation and scoped host realignment | Accepted; implemented locally | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude diff --git a/docs/evidence/dual-host-mcp-convergence-2026-09-10.md b/docs/evidence/dual-host-mcp-convergence-2026-09-10.md new file mode 100644 index 00000000..669bb7b8 --- /dev/null +++ b/docs/evidence/dual-host-mcp-convergence-2026-09-10.md @@ -0,0 +1,71 @@ +# Dual-host MCP provisioning and recurrence evidence + +Date: 2026-09-10. Baseline agentic-kit commit: `2fffecd`. +Initial validation branch: `fix/dual-host-mcp-convergence`. +Environment: macOS, Node 26.4.0; installed Ruflo and underlying CLI 3.41.1. + +## Upstream findings + +- Claude CLI initialization uses `claude-flow`; Codex uses `ruflo`. These are + separate host bindings, not two independent capabilities. +- Ruflo's normal Claude MCP initializer avoids adding either alias when one is + already present. `--force` deliberately bypasses that guard. +- The installed Codex registration function recognizes the name `ruflo` only; + an inventory containing `claude-flow` running Ruflo triggers another add. + The check also exists in upstream main + `a64f8b1ad89035c8b204f8b6e0893a2288551e06`. +- Upstream [#2612](https://github.com/ruvnet/ruflo/issues/2612) tracks the closed + Claude alias issue. [#2640](https://github.com/ruvnet/ruflo/issues/2640) remains + the related plugin-versus-standalone duplication report. + +The upstream characterization called unmodified installed provisioning functions +in a temporary directory. Host configuration reads and Codex subprocess responses +were controlled fixtures; no real MCP server was launched. It establishes the +registration decision, not an end-to-end fresh package installation. + +## Agentic-kit findings + +Current setup suppresses upstream Codex autodetection and separately provisions +`ruflo` through `ak x ruflo-mcp`. No inspected setup, sync, native executable +wrapper, or current npm postinstall was shown to create Codex's historical +`claude-flow` entry. Its original writer remains unidentified. + +Two repeatable local defects were established before the fix: final sync could +report convergence despite an unresolved duplicate warning, and no repair pass +covered a duplicate restored by later provisioning. Setup had the same missing +final verification. The tests failed against baseline for those behaviors. + +## Executable regression evidence + +`tests/kit/codex-mcp-convergence.test.mjs` exercises real setup/sync orchestration, +topology detection, backup/fingerprint repair, consent persistence, and provider +registration using temporary files. Native host commands and package/initializer +side effects are replaced only at external boundaries. + +Scenarios cover fresh and repeated provisioning, explicit consent, later +restoration of the same alias, declined repair, custom environment preservation, +same-run initializer restoration, missing replacements, failed removals, +machine-only setup, upstream npx recognition, unresolved recursion, and mismatched +Codex home. Recreated aliases are injected faults, not evidence identifying a +real-world writer. No blanket protection against outside configuration writers +is claimed. + +## Validation results + +- New convergence suite: 14 passed; focused integration/repair/setup/sync suites: + 86 passed before the final fresh-host fixture extension, which also passed. +- Full `pnpm test`: 3,928 passed, six existing skips, zero failures in the + coverage-enforced suite; all subsequent legacy suites passed. +- Coverage: 92.01% lines, 80.91% branches, 91.55% functions. +- Typecheck, lint, complexity gate, Markdown lint, and build passed. Lint emitted + warnings but no errors. +- Integrated source matched the tested isolated worktree; 31 convergence and + repair tests passed again in the working checkout. +- Native correction removed the inspected user-scope legacy alias after a + current-state backup. Codex's native inventory then showed only the enabled + `ruflo` connection using `ak x ruflo-mcp`. A second reconciliation made no + change and asked no question. The current session's already-loaded tool + inventory is separate from this on-disk result. + +The local configuration correction did not submit an upstream issue or publish a +package. Implementation integration follows the separately authorized PR workflow. diff --git a/docs/evidence/host-realignment-2026-09-10.md b/docs/evidence/host-realignment-2026-09-10.md new file mode 100644 index 00000000..b9329e22 --- /dev/null +++ b/docs/evidence/host-realignment-2026-09-10.md @@ -0,0 +1,52 @@ +# Host realignment evidence — 2026-09-10 + +Decision: [ADR-0051](../adr/0051-supported-peer-delegation-and-host-realignment.md). +Instructions: [Upgrading — supported host delegation](../UPGRADING.md#supported-host-delegation-and-realignment). + +## Applied machine changes + +The updated alignment path inspected user configuration and 44 project locations +from the bounded census, existing configuration-declared projects, and an explicit +emailibrium worktree. It found and removed 17 exact retired `codex mcp-server` +registrations, preserving a fresh recovery copy beside each changed file. + +| Location | Claude `.mcp.json` | Codex `.codex/config.toml` | +| --- | --- | --- | +| emailibrium | Removed retired entry | Removed self-registration | +| finima | Removed retired entry | Removed self-registration | +| keel | Removed retired entry | Removed self-registration | +| prompt-genie | Removed retired entry | Removed self-registration | +| reelbox-cli | Removed retired entry | Removed self-registration | +| chrisphillipson.me/site | Removed retired entry | Removed self-registration | +| retort | Removed retired entry | No change | +| claude-autopilot | Removed retired entry | No change | +| ruflo-local | Removed retired entry | No change | +| emailibrium worktree agent-a940e1661237474ba | Removed retired entry | No change | +| Home directory | Removed retired entry | No change | + +The second full alignment reported zero findings and zero proposed repairs. +Sixteen protected state items matched their pre-repair snapshots, including +available AQE routing files, Ruflo configuration, host plugin settings, and +agentic-kit's routing/provider preferences. No provider fallback was changed. + +The first apply attempt stopped before writing because differently ordered +project inputs produced a different preview digest. A regression reproduced that +problem; sorting source paths made preview/apply deterministic before the +successful retry. There were no partial configuration mutations from that attempt. + +Recovery files use `.ak-host-align-.bak`. They contain the exact +pre-repair configuration. Review them before restoring, because later intentional +edits must not be overwritten. Cleanup did not commit or push any affected +application project. Agentic-kit's implementation follows its separate PR workflow. + +## Evidence limits + +Ruflo 3.41.1 and AQE 3.14.1 native delegation mechanisms were inspected in installed +source and compared with upstream. AQE's live local resolver selected the expected +Claude/Codex providers before cleanup. Configuration preservation proves those +routes were not rewritten; it does not establish every model's current entitlement +or a successful paid inference request. No new provider API spending was initiated. + +The alignment guard governs agentic-kit workflows and scoped corrective actions. +Other programs can still edit configuration. Future matching, approved repairs +can be reapplied; unfamiliar or custom entries remain explicit review items. diff --git a/package.json b/package.json index bcdc0557..8f870825 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md", "docs/adr/0043-managed-ruflo-browser-executor.md", "docs/adr/0044-receipt-aware-maintenance-control-plane.md", + "docs/adr/0051-supported-peer-delegation-and-host-realignment.md", "tests/live/aqe-external-provider-transport.test.mjs", "tests/live/qe-court-participant-transport.test.mjs", "tests/live/codex-context-contract.test.mjs", From a7d7cef8da0d4651837f8bc6c5ae9e93f7ed56e0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 10 Sep 2026 11:38:00 -0700 Subject: [PATCH 4/4] test(maintenance): reference browser globals explicitly --- tests/ui/maintenance-host-alignment.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui/maintenance-host-alignment.mjs b/tests/ui/maintenance-host-alignment.mjs index a7853d37..2192a156 100644 --- a/tests/ui/maintenance-host-alignment.mjs +++ b/tests/ui/maintenance-host-alignment.mjs @@ -61,11 +61,11 @@ test('Host alignment view filters User and Project rows and offers exact registr await page.locator('[data-mnt-plc]').first().click(); await page.waitForFunction(() => !globalThis.mntInspectorBusy); assert.equal(errors.length, 0, errors.join('\n')); - assert.match(await page.locator('#mnt-inspector').innerText(), /Realign|Repair registration/, String(await page.evaluate(() => mntInspectorError && (mntInspectorError.stack || mntInspectorError)))); + assert.match(await page.locator('#mnt-inspector').innerText(), /Realign|Repair registration/, String(await page.evaluate(() => globalThis.mntInspectorError && (globalThis.mntInspectorError.stack || globalThis.mntInspectorError)))); const preview = page.locator('[data-mnt-plan-plc]'); await preview.waitFor(); await preview.click(); - const selected = await page.evaluate(() => window.selectedPreview); + const selected = await page.evaluate(() => globalThis.selectedPreview); assert.equal(selected.placementId, inventory.placements.find(p => p.administrativeScope === 'user').placementId); await page.locator('[data-mnt-scope="project"]').click(); await page.waitForFunction(() => !globalThis.mntInventoryBusy);