From c756c5a1f1fb28986ae3f90f5cf0ee89b11ad167 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 28 Jul 2026 23:33:05 -0700 Subject: [PATCH 1/3] fix: reconcile routing host disablement --- docs/PROVIDERS.md | 8 ++++ src/commands/x/provider.mjs | 20 ++++++++- src/lib/providers.mjs | 21 +++++++--- src/lib/routing.mjs | 42 +++++++++++++++++++ tests/kit/provider-cli.test.mjs | 58 +++++++++++++++++++++++++-- tests/kit/routing-projection.test.mjs | 23 +++++++++++ tests/kit/routing.test.mjs | 19 ++++++++- 7 files changed, 180 insertions(+), 11 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 18968db..0a107e3 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -234,6 +234,14 @@ Defaults (all overridable; your edits are marked `custom` and never re-seeded): `ak host pick --help` prints this list too. Tuning is per-route and reversible: hand-edit `kit.json` `providers.dualRouting`, pass `--route`, or `ak host off` to clear it entirely. +**Disabling is complete, not just a flag change.** `ak host pick --host ` treats the +set as authoritative. Excluding a routing host removes it from persisted routes and escalation +ladders before AQE is reprojected; seeded entries are removed silently, while a user-pinned route +prints a warning naming the disabled host. It also removes stale agentic-kit-curated AQE overrides +while preserving foreign override keys. Excluding Codex additionally retires only the two +marker-owned Codex MCP bridges; user-registered MCP servers are left alone. Re-enabling Codex +converges those bridges again. + `dualRouting` intentionally names a host and model, not an inference provider. Provider resolution is a separate binding lookup; absent grounded evidence remains unknown or explicitly inferred. Likewise, `ak dual run` continues to mean the Claude+Codex collaboration substrate. Multiple diff --git a/src/commands/x/provider.mjs b/src/commands/x/provider.mjs index a9accdb..a5a0c44 100644 --- a/src/commands/x/provider.mjs +++ b/src/commands/x/provider.mjs @@ -14,7 +14,7 @@ import { ensureRufloMcpInCodex, undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource, collectIntegrationFacts, } from '../../lib/providers.mjs'; -import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; +import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, pruneRoutesForHosts, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; @@ -401,6 +401,9 @@ async function pick({ flags, cwd, pkgRoot }) { let models = cfg.providers.models ?? []; const prevPrimary = cfg.providers.primaryHost ?? DEFAULT_PRIMARY_HOST; const oldPolicy = cfg.providers.dualRouting ?? {}; + const prevCodex = !!cfg.providers?.hosts?.codex; + const codexMcpManaged = cfg.providers?.codexMcp === 'ak'; + const rufloCodexManaged = cfg.providers?.rufloCodexMcp === 'ak'; const nonInteractive = flags.host !== undefined || flags['aqe-provider'] !== undefined || flags['aqe-fallback'] !== undefined || flags.provider !== undefined @@ -543,6 +546,20 @@ async function pick({ flags, cwd, pkgRoot }) { for (const w of warnings) warn(w); cfg.providers.dualRouting = { ...cfg.providers.dualRouting, ...policy }; } + const prunedRoutes = pruneRoutesForHosts(cfg.providers.dualRouting, { hosts: routing }); + cfg.providers.dualRouting = prunedRoutes.policy; + for (const message of prunedRoutes.warnings) warn(message); + + // Codex owns two directional MCP bridges. Disable only the marker-owned + // bridges, matching OpenCode's receipt-based teardown semantics. + let codexRetired = null; + if (prevCodex && !cfg.providers.hosts.codex) { + const mcp = await undoCodexMcp(cwd, { managed: codexMcpManaged }); + const rmcp = await undoRufloMcpInCodex(cwd, { managed: rufloCodexManaged }); + cfg.providers.codexMcp = null; + cfg.providers.rufloCodexMcp = null; + codexRetired = { mcp, rmcp }; + } saveKitConfig(cfg); // install any enabled host that is entirely absent (external installs untouched) @@ -604,6 +621,7 @@ async function pick({ flags, cwd, pkgRoot }) { const h = applyHosts(cfg, cwd); (h.ok ? ok : fail)(`hosts: ${h.detail}`); + if (codexRetired) ok(`codex disabled: ${codexRetired.mcp.detail}; ${codexRetired.rmcp.detail}`); if (primaryHost !== DEFAULT_PRIMARY_HOST) { const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none'; ok(`primary host: ${primaryHost} (alternate: ${alt})`); diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 0a108ee..04d0c6a 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -31,7 +31,7 @@ import { readJson, writeJsonWithBackup } from './settings.mjs'; import { installedVersion, cmpVersions } from './versions.mjs'; import * as paths from './paths.mjs'; import { bold, dim, cyan } from './output.mjs'; -import { policyToAgentOverrides, seedDualRouting, resolveRoutes, routingSummary, divergedRoutes, ACTIVITIES, DEFAULT_PRIMARY_HOST, PRIMARY_HOSTS } from './routing.mjs'; +import { configuredPolicyToAgentOverrides, seedDualRouting, resolveRoutes, routingSummary, divergedRoutes, ACTIVITIES, AGENT_ACTIVITY_MAP, DEFAULT_PRIMARY_HOST, PRIMARY_HOSTS } from './routing.mjs'; import { HOST_ADAPTERS } from './hosts.mjs'; import { HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, @@ -418,13 +418,20 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const policy = cfg.providers?.dualRouting ?? {}; const hasChain = chain.length > 0; const hasPolicy = Object.keys(policy).length > 0; - if (!hasChain && !hasPolicy) return { ok: true, changed: false, detail: 'no aqe router config to apply' }; // Same repo-root resolution as settingsTarget — the three scope gates must // never disagree about what "in a project" means (see paths.repoRoot). const root = paths.repoRoot(cwd); if (!root) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; const file = aqeRouterFile(root); const existing = readJson(file, {}) ?? {}; + const priorOverrides = existing.agentOverrides ?? {}; + const projected = configuredPolicyToAgentOverrides(policy); + const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP)); + const staleOverrides = Object.keys(priorOverrides) + .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); + if (!hasChain && !hasPolicy && staleOverrides.length === 0) { + return { ok: true, changed: false, detail: 'no aqe router config to apply' }; + } const next = { ...existing }; next._managedBy = AQE_MANAGED_TAG; const details = []; @@ -454,14 +461,16 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { } } - if (hasPolicy && aqeSupportsAgentOverrides()) { + if ((hasPolicy || staleOverrides.length) && aqeSupportsAgentOverrides()) { // MERGE, don't replace: ak owns only the curated agent-types it projects; // preserve foreign entries (aqe's own defaults or a hand-added agent). The // projector drops non-constructible providers (mirrors sanitizeAgentOverrides) // and only ever emits {provider, model} — no apiKey. - const projected = policyToAgentOverrides(policy); - next.agentOverrides = { ...(existing.agentOverrides ?? {}), ...projected }; - details.push(`agentOverrides: ${Object.keys(projected).length} agents`); + next.agentOverrides = { ...priorOverrides }; + for (const agent of staleOverrides) delete next.agentOverrides[agent]; + Object.assign(next.agentOverrides, projected); + details.push(`agentOverrides: ${Object.keys(projected).length} agents` + + (staleOverrides.length ? ` (${staleOverrides.length} stale ak entries pruned)` : '')); wrote = true; } else if (hasPolicy) { details.push('agentOverrides: skipped (needs agentic-qe ≥ 3.13.1)'); diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index 7446090..e1b1a06 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -341,6 +341,48 @@ export function policyToAgentOverrides(policy = {}, { agentMap = AGENT_ACTIVITY_ return overrides; } +/** Project only explicitly persisted routes. This intentionally does not fill + * holes from dual-host defaults: a host-disable operation may remove an entry, + * and projecting a default for that hole would reintroduce the disabled host. */ +export function configuredPolicyToAgentOverrides(policy = {}, { agentMap = AGENT_ACTIVITY_MAP } = {}) { + const overrides = {}; + for (const [agent, act] of Object.entries(agentMap)) { + const route = policy[act]; + if (!route) continue; + const provider = HOST_PROVIDER[route.host]; + if (!AQE_CONSTRUCTIBLE_PROVIDERS.includes(provider)) continue; + overrides[agent] = { provider, model: route.model }; + } + return overrides; +} + +/** Remove disabled hosts from persisted routes and escalation ladders. Seeded + * entries are ak-owned and silent; user pins return actionable warnings. */ +export function pruneRoutesForHosts(policy = {}, { hosts = HOSTS } = {}) { + const enabled = new Set(hosts); + const next = {}; + const warnings = []; + const pruned = []; + for (const [activity, route] of Object.entries(policy)) { + const source = route?.source ?? 'user'; + if (!enabled.has(route?.host)) { + pruned.push({ activity, kind: 'route', host: route?.host ?? null, source }); + if (source !== 'seeded') warnings.push(`removed user route '${activity}' — host '${route?.host ?? 'unknown'}' is disabled`); + continue; + } + const before = Array.isArray(route.escalate) ? route.escalate : []; + const escalate = before.filter((rung) => enabled.has(rung?.host)); + const removed = before.filter((rung) => !enabled.has(rung?.host)); + for (const rung of removed) { + pruned.push({ activity, kind: 'escalation', host: rung?.host ?? null, source }); + if (source !== 'seeded') warnings.push(`removed user escalation for '${activity}' — host '${rung?.host ?? 'unknown'}' is disabled`); + } + next[activity] = { ...route, ...(escalate.length ? { escalate } : {}) }; + if (removed.length && !escalate.length) delete next[activity].escalate; + } + return { policy: next, pruned, warnings }; +} + // ── Projection #2: dual-run collaboration config ──────────────────────────── // A template is an ordered DAG of activities; the policy fills host+model per // node. Grounded in rUv's CollaborationTemplates (feature/security/refactor); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 7027f0d..414e4b5 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -99,8 +99,8 @@ test('ak x provider status omits the dual-host guidance tips with only one host function fakeBins(dir) { const bin = path.join(dir, 'bin'); fs.mkdirSync(bin, { recursive: true }); - for (const name of ['claude', 'opencode']) { - fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + for (const name of ['claude', 'codex', 'opencode']) { + fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nif [ -n "$AK_TEST_ARGV_LOG" ]; then printf "%s\\n" "$0 $*" >> "$AK_TEST_ARGV_LOG"; fi\nexit 0\n', { mode: 0o755 }); fs.writeFileSync(path.join(bin, `${name}.cmd`), '@echo off\r\nexit /b 0\r\n'); } return bin; @@ -139,7 +139,7 @@ function pickSandbox({ hosts, providers = {} }) { return { home, project, binDir, catalog }; } -function akPick(args, { cwd, home, binDir, catalog }, { input } = {}) { +function akPick(args, { cwd, home, binDir, catalog }, { input, env = {} } = {}) { return spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', cwd, @@ -156,6 +156,7 @@ function akPick(args, { cwd, home, binDir, catalog }, { input } = {}) { // never leak in and make detection non-deterministic. PATH: [binDir, '/usr/bin', '/bin'].join(path.delimiter), RUFLO_REPO: catalog, + ...env, }, }); } @@ -270,6 +271,57 @@ test('a provider retune preserves every ownership marker (codex MCP bridges + op } }); +test('excluding codex tears down only owned bridges and removes disabled routes', () => { + const sb = pickSandbox({ + hosts: { claude: true, codex: true, opencode: false }, + providers: { + codexMcp: 'ak', rufloCodexMcp: 'ak', + dualRouting: { + implementation: { host: 'codex', model: 'gpt-5.4', source: 'seeded' }, + testing: { host: 'claude', model: 'claude-sonnet-5', source: 'user', escalate: [{ host: 'codex', model: 'gpt-5.4' }] }, + }, + }, + }); + const log = path.join(sb.home, 'argv.log'); + try { + const r = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb, { env: { AK_TEST_ARGV_LOG: log } }); + assert.equal(r.status, 0, `disable failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); + assert.match(r.stdout, /removed user escalation.*codex.*disabled/); + const p = kitJson(sb.home).providers; + assert.equal(p.codexMcp, null); + assert.equal(p.rufloCodexMcp, null); + assert.equal(p.dualRouting.implementation, undefined); + assert.deepEqual(p.dualRouting.testing, { host: 'claude', model: 'claude-sonnet-5', source: 'user' }); + const calls = fs.readFileSync(log, 'utf8'); + assert.match(calls, /claude mcp remove codex -s project/); + assert.match(calls, /codex mcp remove ruflo/); + } finally { + rm(sb.home, sb.project); + } +}); + +test('excluding claude keeps codex-owned bridges while pruning claude routes', () => { + const sb = pickSandbox({ + hosts: { claude: true, codex: true, opencode: false }, + providers: { + codexMcp: 'ak', rufloCodexMcp: 'ak', + dualRouting: { review: { host: 'claude', model: 'claude-sonnet-5', source: 'seeded' } }, + }, + }); + const log = path.join(sb.home, 'argv.log'); + try { + const r = akPick(['x', 'provider', 'pick', '--host', 'codex', '--yes'], sb, { env: { AK_TEST_ARGV_LOG: log } }); + assert.equal(r.status, 0, r.stderr); + const p = kitJson(sb.home).providers; + assert.equal(p.dualRouting.review, undefined); + assert.equal(p.codexMcp, 'ak'); + assert.equal(p.rufloCodexMcp, 'ak'); + assert.doesNotMatch(fs.readFileSync(log, 'utf8'), /mcp remove/); + } finally { + rm(sb.home, sb.project); + } +}); + test('interactive pick: installed opencode is displayed as an integration host but only ENTER-enabled when already on', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); try { diff --git a/tests/kit/routing-projection.test.mjs b/tests/kit/routing-projection.test.mjs index 9380334..bbaad50 100644 --- a/tests/kit/routing-projection.test.mjs +++ b/tests/kit/routing-projection.test.mjs @@ -107,6 +107,29 @@ test('agentOverrides MERGES — a foreign entry survives (H1: never clobbered)', rm(dir); rm(groot); }); +test('stale curated overrides are pruned while configured and foreign entries survive', () => { + const groot = fakeAqe('3.13.1'); + const dir = tmpProject(); + fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true }); + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ + _managedBy: 'agentic-kit', + agentOverrides: { + 'qe-security-scanner': { provider: 'codex', model: 'gpt-5.4' }, + 'qe-code-reviewer': { provider: 'claude-code', model: 'claude-sonnet-5' }, + 'qe-custom-agent': { provider: 'ollama' }, + }, + })); + const res = applyAqeRouter(cfgWith({ dualRouting: { + review: { host: 'claude', model: 'claude-sonnet-5', source: 'user' }, + } }), dir); + const disk = readDisk(dir); + assert.match(res.detail, /stale ak entries pruned/); + assert.equal(disk.agentOverrides['qe-security-scanner'], undefined); + assert.deepEqual(disk.agentOverrides['qe-code-reviewer'], { provider: 'claude-code', model: 'claude-sonnet-5' }); + assert.deepEqual(disk.agentOverrides['qe-custom-agent'], { provider: 'ollama' }); + rm(dir); rm(groot); +}); + test('an invalid fallback chain does not block the agentOverrides projection (M3)', () => { const groot = fakeAqe('3.13.1'); const dir = tmpProject(); diff --git a/tests/kit/routing.test.mjs b/tests/kit/routing.test.mjs index 39f0a59..a845629 100644 --- a/tests/kit/routing.test.mjs +++ b/tests/kit/routing.test.mjs @@ -4,7 +4,7 @@ import { ACTIVITIES, AK_ORIGINATED, DEFAULT_ROUTES, HOST_PROVIDER, SUBSCRIPTION_PROVIDERS, AQE_CONSTRUCTIBLE_PROVIDERS, MODEL_CATALOG, MODEL_CATALOG_VERIFIED, modelChoices, formatModelHelp, resolveRoutes, seedDualRouting, policyToAgentOverrides, routedVendors, routingSummary, - materializeRunPlan, + configuredPolicyToAgentOverrides, pruneRoutesForHosts, materializeRunPlan, validateRoute, parseRouteSpecs, DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy, } from '../../src/lib/routing.mjs'; @@ -106,6 +106,23 @@ test('overriding an activity flows through to its agent overrides', () => { assert.equal(ov['qe-test-architect'].model, 'claude-sonnet-5'); }); +test('configured projection never recreates a missing route from dual-host defaults', () => { + const ov = configuredPolicyToAgentOverrides({ review: { host: 'claude', model: 'claude-sonnet-5', source: 'user' } }); + assert.equal(ov['qe-code-reviewer'].provider, 'claude-code'); + assert.equal(ov['qe-test-architect'], undefined); +}); + +test('pruning disabled hosts drops primary routes and only invalid escalation rungs', () => { + const out = pruneRoutesForHosts({ + implementation: { host: 'codex', model: 'gpt-5.4', source: 'seeded' }, + testing: { host: 'claude', model: 'claude-sonnet-5', source: 'user', escalate: [{ host: 'codex', model: 'gpt-5.4' }] }, + }, { hosts: ['claude'] }); + assert.equal(out.policy.implementation, undefined); + assert.deepEqual(out.policy.testing, { host: 'claude', model: 'claude-sonnet-5', source: 'user' }); + assert.equal(out.pruned.length, 2); + assert.deepEqual(out.warnings, ["removed user escalation for 'testing' — host 'codex' is disabled"]); +}); + // ── Diversity + summary ───────────────────────────────────────────────────── test('default routing spans at least two vendors (qe-court diversity)', () => { From c329029360793114165d8cabdc2ff80103b0a2c5 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 28 Jul 2026 23:37:43 -0700 Subject: [PATCH 2/3] test: make provider CLI shims log on Windows --- tests/kit/provider-cli.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 414e4b5..3bd1969 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -101,7 +101,7 @@ function fakeBins(dir) { fs.mkdirSync(bin, { recursive: true }); for (const name of ['claude', 'codex', 'opencode']) { fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nif [ -n "$AK_TEST_ARGV_LOG" ]; then printf "%s\\n" "$0 $*" >> "$AK_TEST_ARGV_LOG"; fi\nexit 0\n', { mode: 0o755 }); - fs.writeFileSync(path.join(bin, `${name}.cmd`), '@echo off\r\nexit /b 0\r\n'); + fs.writeFileSync(path.join(bin, `${name}.cmd`), `@echo off\r\nif not "%AK_TEST_ARGV_LOG%"=="" echo ${name} %*>> "%AK_TEST_ARGV_LOG%"\r\nexit /b 0\r\n`); } return bin; } From 20764bd16cabaf3c1bc6f8703de8b2c4ec396671 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Tue, 28 Jul 2026 23:41:39 -0700 Subject: [PATCH 3/3] test: cover bridge teardown without shell shims --- src/lib/providers.mjs | 10 +++++----- tests/kit/provider-cli.test.mjs | 12 +++--------- tests/kit/routing-projection.test.mjs | 21 ++++++++++++++++++++- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 04d0c6a..40a5392 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -612,9 +612,9 @@ export async function ensureCodexMcp(cfg, cwd = process.cwd()) { /** Remove the project-scoped codex MCP server — ONLY when ak registered it * (managed === true). Never tears down a server the user added themselves. */ -export async function undoCodexMcp(cwd = process.cwd(), { managed = false } = {}) { +export async function undoCodexMcp(cwd = process.cwd(), { managed = false, runner = run } = {}) { if (!managed) return { ok: true, changed: false, detail: 'codex MCP left as-is (not ak-registered)' }; - const r = await run('claude', ['mcp', 'remove', 'codex', '-s', 'project'], { cwd }); + const r = await runner('claude', ['mcp', 'remove', 'codex', '-s', 'project'], { cwd }); return { ok: true, changed: r.code === 0, detail: r.code === 0 ? 'codex MCP removed' : 'codex MCP not registered' }; } @@ -645,10 +645,10 @@ export async function ensureRufloMcpInCodex(cfg, cwd = process.cwd()) { /** Remove the ruflo MCP server from Codex — ONLY when ak registered it * (managed === true). Never tears down a server the user added themselves. */ -export async function undoRufloMcpInCodex(cwd = process.cwd(), { managed = false } = {}) { +export async function undoRufloMcpInCodex(cwd = process.cwd(), { managed = false, runner = run, haveFn = have } = {}) { if (!managed) return { ok: true, changed: false, detail: 'ruflo→codex MCP left as-is (not ak-registered)' }; - if (!(await have('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' }; - const r = await run('codex', ['mcp', 'remove', 'ruflo'], { cwd }); + if (!(await haveFn('codex'))) return { ok: true, changed: false, detail: 'codex CLI not installed' }; + const r = await runner('codex', ['mcp', 'remove', 'ruflo'], { cwd }); return { ok: true, changed: r.code === 0, detail: r.code === 0 ? 'ruflo MCP removed from codex' : 'ruflo→codex MCP not registered' }; } diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 3bd1969..58c9ff6 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -101,7 +101,7 @@ function fakeBins(dir) { fs.mkdirSync(bin, { recursive: true }); for (const name of ['claude', 'codex', 'opencode']) { fs.writeFileSync(path.join(bin, name), '#!/bin/sh\nif [ -n "$AK_TEST_ARGV_LOG" ]; then printf "%s\\n" "$0 $*" >> "$AK_TEST_ARGV_LOG"; fi\nexit 0\n', { mode: 0o755 }); - fs.writeFileSync(path.join(bin, `${name}.cmd`), `@echo off\r\nif not "%AK_TEST_ARGV_LOG%"=="" echo ${name} %*>> "%AK_TEST_ARGV_LOG%"\r\nexit /b 0\r\n`); + fs.writeFileSync(path.join(bin, `${name}.cmd`), '@echo off\r\nexit /b 0\r\n'); } return bin; } @@ -282,9 +282,8 @@ test('excluding codex tears down only owned bridges and removes disabled routes' }, }, }); - const log = path.join(sb.home, 'argv.log'); try { - const r = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb, { env: { AK_TEST_ARGV_LOG: log } }); + const r = akPick(['x', 'provider', 'pick', '--host', 'claude', '--yes'], sb); assert.equal(r.status, 0, `disable failed\nstdout: ${r.stdout}\nstderr: ${r.stderr}`); assert.match(r.stdout, /removed user escalation.*codex.*disabled/); const p = kitJson(sb.home).providers; @@ -292,9 +291,6 @@ test('excluding codex tears down only owned bridges and removes disabled routes' assert.equal(p.rufloCodexMcp, null); assert.equal(p.dualRouting.implementation, undefined); assert.deepEqual(p.dualRouting.testing, { host: 'claude', model: 'claude-sonnet-5', source: 'user' }); - const calls = fs.readFileSync(log, 'utf8'); - assert.match(calls, /claude mcp remove codex -s project/); - assert.match(calls, /codex mcp remove ruflo/); } finally { rm(sb.home, sb.project); } @@ -308,15 +304,13 @@ test('excluding claude keeps codex-owned bridges while pruning claude routes', ( dualRouting: { review: { host: 'claude', model: 'claude-sonnet-5', source: 'seeded' } }, }, }); - const log = path.join(sb.home, 'argv.log'); try { - const r = akPick(['x', 'provider', 'pick', '--host', 'codex', '--yes'], sb, { env: { AK_TEST_ARGV_LOG: log } }); + const r = akPick(['x', 'provider', 'pick', '--host', 'codex', '--yes'], sb); assert.equal(r.status, 0, r.stderr); const p = kitJson(sb.home).providers; assert.equal(p.dualRouting.review, undefined); assert.equal(p.codexMcp, 'ak'); assert.equal(p.rufloCodexMcp, 'ak'); - assert.doesNotMatch(fs.readFileSync(log, 'utf8'), /mcp remove/); } finally { rm(sb.home, sb.project); } diff --git a/tests/kit/routing-projection.test.mjs b/tests/kit/routing-projection.test.mjs index bbaad50..e1c3ae2 100644 --- a/tests/kit/routing-projection.test.mjs +++ b/tests/kit/routing-projection.test.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, undoCodexMcp } from '../../src/lib/providers.mjs'; +import { applyAqeRouter, aqeRouterFile, undoAqeRouter, ensureCodexMcp, undoCodexMcp, undoRufloMcpInCodex } from '../../src/lib/providers.mjs'; import { seedDualRouting } from '../../src/lib/routing.mjs'; import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; @@ -152,6 +152,25 @@ test('codex MCP teardown is a no-op unless ak owns it (H2), and never shells whe assert.match(ensure.detail, /not enabled/); }); +test('owned bridge teardown sends the precise safe argv on every platform', async () => { + const calls = []; + const runner = async (cmd, args, opts) => { + calls.push({ cmd, args, opts }); + return { code: 0, stdout: '', stderr: '' }; + }; + const cwd = '/work/project'; + + const codex = await undoCodexMcp(cwd, { managed: true, runner }); + const ruflo = await undoRufloMcpInCodex(cwd, { managed: true, runner, haveFn: async () => true }); + + assert.equal(codex.changed, true); + assert.equal(ruflo.changed, true); + assert.deepEqual(calls, [ + { cmd: 'claude', args: ['mcp', 'remove', 'codex', '-s', 'project'], opts: { cwd } }, + { cmd: 'codex', args: ['mcp', 'remove', 'ruflo'], opts: { cwd } }, + ]); +}); + test('undoAqeRouter removes the ak-created file (agentOverrides included)', () => { const groot = fakeAqe('3.13.1'); const dir = tmpProject();