From 64dd461ae7b9ebcdeb427c81ecc7a3d899a84d4d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 14:43:29 -0700 Subject: [PATCH 01/21] feat(adapters): add earned AQE provider bridge --- bin/agentic-kit.mjs | 3 +- src/commands/x/aqe-provider.mjs | 84 +++++++ src/commands/x/host-adapters-grants.mjs | 25 +- src/commands/x/host-adapters.mjs | 5 +- src/lib/adapters/admission.mjs | 42 ++++ src/lib/adapters/aqe-provider.mjs | 258 +++++++++++++++++++ src/lib/adapters/conformance.mjs | 94 ++++++- src/lib/adapters/grants.mjs | 31 ++- src/lib/adapters/index.mjs | 1 + src/lib/adapters/integrity.mjs | 2 + src/lib/adapters/manifest.mjs | 139 ++++++++++- tests/kit/adapter-aqe-provider.test.mjs | 319 ++++++++++++++++++++++++ tests/kit/adapter-grants.test.mjs | 34 ++- tests/kit/conformance-tiers.test.mjs | 103 +++++++- tests/kit/host-adapters-cli.test.mjs | 70 +++++- 15 files changed, 1150 insertions(+), 60 deletions(-) create mode 100644 src/commands/x/aqe-provider.mjs create mode 100644 src/lib/adapters/aqe-provider.mjs create mode 100644 tests/kit/adapter-aqe-provider.test.mjs diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index eed88bc..9c61020 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -32,6 +32,7 @@ const PORCELAIN = Object.assign(Object.create(null), { const PLUMBING = Object.assign(Object.create(null), { 'admin': () => import('../src/commands/x/admin.mjs'), + 'aqe-provider': () => import('../src/commands/x/aqe-provider.mjs'), 'daemon-gc': () => import('../src/commands/x/daemon-gc.mjs'), 'dashboard': () => import('../src/commands/x/dashboard.mjs'), 'harvest': () => import('../src/commands/x/harvest.mjs'), @@ -179,7 +180,7 @@ async function main() { // setup and host own complete mutation/reporting flows. Running the generic // nudge after a declined trust preflight could write version-cache state and // violate their "before any changes" boundary. - if (!values.json && !values['dry-run'] && !['sync', 'usage', 'models', 'setup', 'host', 'ruflo-mcp'].includes(cmd)) { + if (!values.json && !values['dry-run'] && !['sync', 'usage', 'models', 'setup', 'host', 'ruflo-mcp', 'aqe-provider'].includes(cmd)) { try { const { driftReport } = await import('../src/lib/versions.mjs'); for (const r of await driftReport()) { diff --git a/src/commands/x/aqe-provider.mjs b/src/commands/x/aqe-provider.mjs new file mode 100644 index 0000000..c88c3a1 --- /dev/null +++ b/src/commands/x/aqe-provider.mjs @@ -0,0 +1,84 @@ +// Hidden stdin/stdout trampoline for Agentic-QE ADR-127 external providers. +// This command is intentionally absent from public help: users configure and +// grant adapters; AQE alone calls this transport. stdout is completion-only. +import path from 'node:path'; +import { runAdmittedAqeProvider } from '../../lib/adapters/aqe-provider.mjs'; + +const MAX_PROMPT_BYTES = 1024 * 1024; + +export const options = { + model: { type: 'string' }, + 'expect-hash': { type: 'string' }, + 'project-root': { type: 'string' }, +}; + +export const help = `ak x aqe-provider — internal Agentic-QE external-provider transport + +This command is generated into .agentic-qe/llm-config.json by agentic-kit. +It requires a hash-pinned, admitted, enabled, explicitly granted adapter and +accepts the provider prompt on stdin. It is not a user configuration surface. + +Examples: + # Invoked by Agentic-QE from an agentic-kit-managed declaration: + printf 'prompt' | ak x aqe-provider hermes --model default \\ + --expect-hash --project-root /absolute/project`; + +async function readPrompt(stream = process.stdin) { + const chunks = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + bytes += buffer.length; + if (bytes > MAX_PROMPT_BYTES) throw new Error(`AQE provider prompt exceeds ${MAX_PROMPT_BYTES} bytes`); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function diagnosticLine(value) { + const text = String(value ?? 'AQE provider bridge failed'); + return text.endsWith('\n') ? text : `${text}\n`; +} + +export async function run({ flags, positionals }) { + const id = positionals[0]; + if (typeof id !== 'string' || !id || positionals.length !== 1) { + process.stderr.write('AQE provider bridge requires exactly one provider id\n'); + return 2; + } + if (typeof flags['expect-hash'] !== 'string' || !/^[a-f0-9]{64}$/.test(flags['expect-hash'])) { + process.stderr.write('AQE provider bridge requires --expect-hash \n'); + return 2; + } + if (typeof flags['project-root'] !== 'string' || !path.isAbsolute(flags['project-root'])) { + process.stderr.write('AQE provider bridge requires --project-root \n'); + return 2; + } + + let prompt; + try { + prompt = await readPrompt(); + } catch (error) { + process.stderr.write(diagnosticLine(error?.message ?? error)); + return 1; + } + + const result = await runAdmittedAqeProvider(id, { + stdin: prompt, + model: flags.model, + expectedHash: flags['expect-hash'], + projectRoot: path.resolve(flags['project-root']), + }); + if (!result.ok) { + // Never write partial hook output to stdout on failure: AQE treats any + // nonempty stdout as a completion even when the child exits non-zero. + process.stderr.write(diagnosticLine(result.stderrText?.trim() || result.detail)); + return Number.isInteger(result.exitCode) && result.exitCode > 0 && result.exitCode <= 255 + ? result.exitCode + : 1; + } + + if (result.stderrText) process.stderr.write(result.stderrText); + process.stdout.write(result.stdoutText); + return 0; +} diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index 36d9105..d66fc2b 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -28,9 +28,7 @@ import { // alias), not a built-in promotion — promoting to a built-in is a manual // registry PR (§3), not this command. -/** TIER_GRANTS' values are the only capabilities `ak` can ever grant — - * critically this excludes 'aqeProvider', an upstream-owned enumeration - * (ADR-0031 §4) that must never appear grantable here. */ +/** TIER_GRANTS' values are the only capabilities `ak` can ever grant. */ function grantableCapability(capability) { return Object.values(TIER_GRANTS).includes(capability); } @@ -49,16 +47,21 @@ function gatingTierFor(capability) { * production path SELECTS an external host as primary today (`ak host pick * --primary-host` only accepts claude|codex — built-in-scoped), so a * granted canBePrimary is visible/eligible but not yet auto-consumed. - * commandStatusline reaches the same overlay but has NO runtime reader + * aqeProvider activates the admitted provider projection consumed by AQE's + * external-provider registry. commandStatusline reaches the same overlay but has NO runtime reader * anywhere in src/ (grep-verified) — its statusline render path is a later * wave, so it is currently inert. F-5: this is also the one-sentence * distinction between the two grantable caps the disclosure owes the * maintainer, so both call sites (pre-confirm disclosure, post-grant * success) share this single source of truth rather than drifting. */ function capabilityStatusNote(capability) { - return capability === 'canBePrimary' - ? "canBePrimary is live: from the next ak invocation this host's tier label shows 'can lead' and it joins effectivePrimaryHostIds() — but no production path yet SELECTS an external host as primary ('ak host pick' stays built-in-scoped), so this is visible/eligible, not yet auto-consumed." - : 'commandStatusline is currently inert: it reaches the effective host registry, but no runtime path reads it anywhere yet — the statusline render path is a later wave.'; + if (capability === 'canBePrimary') { + return "canBePrimary is live: from the next ak invocation this host's tier label shows 'can lead' and it joins effectivePrimaryHostIds() — but no production path yet SELECTS an external host as primary ('ak host pick' stays built-in-scoped), so this is visible/eligible, not yet auto-consumed."; + } + if (capability === 'aqeProvider') { + return 'aqeProvider is live: from the next flagged ak invocation, this exact admitted adapter-content hash can be projected through Agentic-QE 3.13.12 externalProviders; disabling the host, revoking this grant, or editing declared content voids it.'; + } + return 'commandStatusline is currently inert: it reaches the effective host registry, but no runtime path reads it anywhere yet — the statusline render path is a later wave.'; } export async function grant({ @@ -70,7 +73,7 @@ export async function grant({ } const safeCapability = stripControl(capability); if (!grantableCapability(capability)) { - fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}. 'aqeProvider' in particular is an upstream-owned identity (agentic-qe's own provider enumeration) and is never ak-grantable — see ADR-0031 §4.`); + fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}.`); return 1; } const safeName = stripControl(name); @@ -83,6 +86,10 @@ export async function grant({ return 1; } const { manifest, hash } = loaded; + if (capability === 'aqeProvider' && !manifest.aqe?.provider) { + fail(`grant refused: '${safeName}' does not declare manifest.aqe.provider at this adapter-content hash`); + return 1; + } const tier = gatingTierFor(capability); const safeTier = stripControl(tier); @@ -119,7 +126,7 @@ export async function grant({ trustState = `manifest error (consent-error: ${error?.message ?? String(error)})`; } console.log(` manifest trust state: ${trustState}`); - info('this grant pins the MANIFEST content (its hash), not the hook script bytes it references — see ADR-0031 §2 for that boundary.'); + info('this grant pins the combined adapter content identity: validated manifest plus every declared hook-file digest.'); info("granting a capability is a trust act, same posture as 'trust': it takes effect in the effective host registry from the next ak invocation."); info(capabilityStatusNote(capability)); diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 6fe4f45..186ab29 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -317,6 +317,9 @@ export function hookCommandsFor(manifest) { if (manifest.execution?.run?.hook?.command) { hooks.push(`execution.run: ${JSON.stringify(manifest.execution.run.hook.command)}`); } + if (manifest.aqe?.provider?.hook?.command) { + hooks.push(`aqe.provider: ${JSON.stringify(manifest.aqe.provider.hook.command)}`); + } return hooks; } @@ -341,7 +344,7 @@ async function warnAboutHooks(name, entry, rawReader) { } const hooks = hookCommandsFor(manifest); if (!hooks.length) { - info(` '${name}' declares no lifecycle/execution hooks — nothing will be spawned.`); + info(` '${name}' declares no lifecycle, execution, or AQE-provider hooks — nothing will be spawned.`); return; } warn(' the following hooks will run as REAL subprocesses:'); diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index 520b00c..1f47d6b 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -181,6 +181,18 @@ export async function bootstrapHostAdapters({ const warnings = results.filter((result) => !result.admitted) .map(({ name, reason, detail }) => ({ name, reason, detail })); + // The AQE provider bridge is an exact snapshot of THIS bootstrap pass. + // Clear it before rebuilding so a second in-process bootstrap cannot leave + // a formerly-admitted/granted provider live after consent, enablement, or + // configuration changes. Flag-off remains a true zero-import no-op above. + let aqeProviderBridge; + try { + aqeProviderBridge = await import('./aqe-provider.mjs'); + aqeProviderBridge.resetAdmittedAqeProviders(); + } catch { + aqeProviderBridge = null; + } + if (admitted.length) { const { applyAdmitted } = await import('./admitted.mjs'); @@ -229,6 +241,36 @@ export async function bootstrapHostAdapters({ // the other. const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); + // AQE ADR-127 / issue #628: a manifest's aqe.provider block is only a + // candidate. It becomes a live trampoline target after ALL local gates: + // admission above, explicit host enablement, and a hash-current + // aqeProvider grant. The dedicated registry keeps this non-boolean + // identity out of applyAdmitted's deliberately narrow host-capability + // overlay. One bad provider is isolated to one warning. + if (aqeProviderBridge) { + const aqeCandidates = admitted.filter((result) => ( + !!result.manifest?.aqe?.provider + && cfg.integrations?.hosts?.[result.name] === true + && grantsByName && Object.hasOwn(grantsByName, result.name) + && grantsByName[result.name]?.aqeProvider === true + )); + for (const result of aqeCandidates) { + try { + aqeProviderBridge.registerAdmittedAqeProvider(result.manifest, { + baseDir: baseDirForSource(sourceByName.get(result.name)), + integrity: result.integrity, + contentHash: result.contentHash, + }); + } catch (error) { + warnings.push({ + name: result.name, + reason: error?.reason ?? 'aqe-provider-registration-failed', + detail: error?.message ?? String(error), + }); + } + } + } + // P2 (ADR-0031): an admitted manifest declaring both an execution block // and host.capabilities.canRouteActivities gets its execution adapter // derived and registered here, so `ak run` can route to it. Same diff --git a/src/lib/adapters/aqe-provider.mjs b/src/lib/adapters/aqe-provider.mjs new file mode 100644 index 0000000..55ac0c9 --- /dev/null +++ b/src/lib/adapters/aqe-provider.mjs @@ -0,0 +1,258 @@ +// Earned Agentic-QE external-provider bridge (AQE ADR-127 / issue #628). +// +// A manifest may declare a provider CANDIDATE as data, but it never executes +// directly and never self-activates. bootstrapHostAdapters registers a +// candidate here only after admission, explicit host enablement, and a live +// hash-pinned aqeProvider grant. AQE invokes the stable agentic-kit trampoline; +// the trampoline resolves this process-local registry and runs the real hook +// through runAdapterHook's integrity, cwd, environment, timeout, output-cap, +// and process-group controls. +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runAdapterHook } from './hook-runner.mjs'; +import { immutable } from './schema.mjs'; +import { verifyAdapterContent } from './integrity.mjs'; + +const DEFAULT_MODEL = 'default'; +const DEFAULT_MAX_CONCURRENCY = 2; +const OUTER_TIMEOUT_MARGIN_MS = 2_500; +const MAX_PROMPT_BYTES = 1024 * 1024; +const PROBE_PROMPT = 'Reply with exactly: OK'; +const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../bin/agentic-kit.mjs'); + +let providers = new Map(); + +function requireManifest(manifest) { + const id = manifest?.host?.id; + const provider = manifest?.aqe?.provider; + if (typeof id !== 'string' || !id || !provider?.hook) { + throw new TypeError('AQE provider bridge requires a validated manifest with host.id and aqe.provider.hook'); + } + return { id, provider }; +} + +function publicIntegrity(integrity) { + return { + hash: integrity.hash, + manifestHash: integrity.manifestHash ?? null, + hookFiles: (integrity.hookFiles ?? []).map((file) => ({ path: file.path, sha256: file.sha256 })), + }; +} + +function providerMetadata(id, provider) { + const models = [...(provider.models ?? [DEFAULT_MODEL])]; + const defaultModel = provider.defaultModel ?? models[0] ?? DEFAULT_MODEL; + return { + billingMode: provider.billingMode ?? 'metered-api', + models, + defaultModel, + maxConcurrency: provider.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + stripEnv: [...(provider.stripEnv ?? [])], + displayName: provider.displayName ?? id, + ...(provider.hook.timeoutMs === undefined + ? {} + : { timeoutMs: provider.hook.timeoutMs + OUTER_TIMEOUT_MARGIN_MS }), + }; +} + +function publicRecord(record) { + return immutable({ + id: record.id, + hostId: record.id, + contentHash: record.contentHash, + integrity: publicIntegrity(record.integrity), + provider: providerMetadata(record.id, record.provider), + }); +} + +/** Register one already-admitted, currently-granted provider. This function + * cannot perform admission or grant lookup itself; its sole production caller + * is bootstrapHostAdapters, which owns those gates. It does re-verify the + * content immediately so a file edit between admission and registration + * cannot create a live bridge. + * @param {any} manifest + * @param {{baseDir?:string|null, integrity?:any, contentHash?:string}} [options] */ +export function registerAdmittedAqeProvider(manifest, { + baseDir = null, integrity, contentHash, +} = {}) { + const { id, provider } = requireManifest(manifest); + if (!integrity || typeof integrity.hash !== 'string' || !integrity.hash) { + throw new TypeError(`AQE provider '${id}' registration requires admission integrity`); + } + const expectedHash = contentHash ?? integrity.hash; + if (expectedHash !== integrity.hash) { + throw new TypeError(`AQE provider '${id}' content hash does not match admission integrity`); + } + verifyAdapterContent(manifest, integrity, { baseDir }); + const argv0 = provider.hook.command[0]; + if (baseDir == null && (argv0.includes('/') || argv0.includes('\\')) && !path.isAbsolute(argv0)) { + throw new TypeError(`AQE provider '${id}' has a relative hook command but no retained adapter directory`); + } + const record = { + id, manifest, provider, baseDir, integrity, contentHash: expectedHash, + }; + providers.set(id, record); + return publicRecord(record); +} + +export function resetAdmittedAqeProviders() { + providers = new Map(); +} + +/** Public, immutable provider receipts. Manifest and base-directory internals + * stay private; projection consumers get only identity, integrity provenance, + * and AQE-safe metadata. */ +export function admittedAqeProviders() { + return Object.freeze([...providers.values()].map(publicRecord)); +} + +export function admittedAqeProviderFor(id) { + const record = providers.get(id); + return record ? publicRecord(record) : null; +} + +/** AQE v3.13.12 externalProviders declarations for the currently-live set. */ +export function projectedAqeExternalProviders({ projectRoot = process.cwd() } = {}) { + if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) { + throw new TypeError('AQE external-provider projection requires an absolute project root'); + } + const out = Object.create(null); + for (const record of providers.values()) { + const meta = providerMetadata(record.id, record.provider); + out[record.id] = { + kind: 'cli', + // Pin both sides of the invocation. The absolute, package-owned entrypoint + // avoids PATH substitution; expected hash + project root ensure a stale + // project declaration cannot silently execute newly-granted adapter bytes. + command: [ + process.execPath, CLI_ENTRY, 'x', 'aqe-provider', record.id, + '--expect-hash', record.contentHash, + '--project-root', projectRoot, + ], + billingMode: meta.billingMode, + models: [...meta.models], + defaultModel: meta.defaultModel, + modelFlag: '--model', + maxConcurrency: meta.maxConcurrency, + stripEnv: [...meta.stripEnv], + displayName: meta.displayName, + ...(meta.timeoutMs === undefined ? {} : { timeoutMs: meta.timeoutMs }), + }; + } + return immutable(out); +} + +function selectedEnvironment(record, env, { model, projectRoot }) { + const selected = { + AK_AQE_PROVIDER: record.id, + AK_AQE_MODEL: model, + AK_AQE_PROJECT_CWD: projectRoot, + }; + for (const name of record.provider.hook.passEnv ?? []) { + if (typeof env?.[name] === 'string') selected[name] = env[name]; + } + return selected; +} + +function validateInvocation(record, { + stdin, model, projectRoot, expectedHash, +}) { + if (typeof stdin !== 'string') return 'AQE provider bridge requires a string prompt on stdin'; + if (Buffer.byteLength(stdin, 'utf8') > MAX_PROMPT_BYTES) { + return `AQE provider prompt exceeds ${MAX_PROMPT_BYTES} bytes`; + } + if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) { + return 'AQE provider bridge requires an absolute project root'; + } + if (expectedHash !== undefined && expectedHash !== record.contentHash) { + return `AQE provider '${record.id}' content hash does not match projected ${String(expectedHash)}`; + } + const models = record.provider.models ?? [DEFAULT_MODEL]; + if (typeof model !== 'string' || !model || !models.includes(model)) { + return `AQE provider '${record.id}' does not declare model '${String(model)}'`; + } + return null; +} + +async function executeRecord(record, { + stdin, model, projectRoot, expectedHash, timeoutMs, env = process.env, +}) { + const problem = validateInvocation(record, { + stdin, model, projectRoot, expectedHash, + }); + if (problem) return { ok: false, stdoutText: '', stderrText: '', exitCode: null, detail: problem }; + const result = await runAdapterHook({ + hook: record.provider.hook, + hostId: record.id, + verb: 'aqe-provider', + stdin, + timeoutMs, + env: selectedEnvironment(record, env, { model, projectRoot }), + cwd: record.baseDir ?? projectRoot, + manifest: record.manifest, + integrity: record.integrity, + baseDir: record.baseDir, + }); + if (!result.ok) { + return { + ok: false, + stdoutText: '', + stderrText: result.stderrText ?? '', + exitCode: result.exitCode, + detail: (result.stderrText ?? '').trim() || result.detail || `AQE provider '${record.id}' failed`, + }; + } + return { + ok: true, + stdoutText: result.stdoutText ?? '', + stderrText: result.stderrText ?? '', + exitCode: result.exitCode, + detail: null, + }; +} + +/** Production execution surface. Absence means the adapter is not currently + * admitted, enabled, and granted in this process; never fall back to a raw + * manifest command. + * @param {string} id + * @param {{stdin?:string, model?:string, projectRoot?:string, timeoutMs?:number, + * expectedHash?:string, env?:NodeJS.ProcessEnv}} [options] */ +export async function runAdmittedAqeProvider(id, { + stdin, model, projectRoot = process.cwd(), expectedHash, timeoutMs, env = process.env, +} = {}) { + const record = providers.get(id); + if (!record) { + return { + ok: false, stdoutText: '', stderrText: '', exitCode: null, + detail: `AQE provider '${String(id)}' is not active (admission, enablement, consent, or grant is missing/stale)`, + }; + } + const selectedModel = model ?? record.provider.defaultModel ?? record.provider.models?.[0] ?? DEFAULT_MODEL; + return executeRecord(record, { + stdin, model: selectedModel, projectRoot, expectedHash, timeoutMs, env, + }); +} + +/** Evidence-first conformance seam. The caller must already have proven + * admission and supplies that exact manifest/integrity; no registry or grant + * is consulted. Execution is otherwise identical to production. + * @param {{manifest?:any, baseDir?:string|null, integrity?:any, + * projectRoot?:string, timeoutMs?:number}} [options] */ +export async function runAdmittedAqeProviderProbe({ + manifest, baseDir = null, integrity, projectRoot, timeoutMs, +} = {}) { + const { id, provider } = requireManifest(manifest); + if (!integrity || typeof integrity.hash !== 'string' || !integrity.hash) { + return { ok: false, stdoutText: '', stderrText: '', exitCode: null, detail: `AQE provider '${id}' probe requires admission integrity` }; + } + const record = { id, manifest, provider, baseDir, integrity, contentHash: integrity.hash }; + const model = provider.defaultModel ?? provider.models?.[0] ?? DEFAULT_MODEL; + return executeRecord(record, { + stdin: PROBE_PROMPT, + model, + projectRoot, + expectedHash: undefined, + timeoutMs, + env: process.env, + }); +} diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs index 9a44de9..fcab9a8 100644 --- a/src/lib/adapters/conformance.mjs +++ b/src/lib/adapters/conformance.mjs @@ -1,7 +1,8 @@ // Tiered conformance harness (ADR-0031 §2, §5) — generalizes the single // admission-tier black-box report (tests/kit/adapter-conformance.test.mjs's -// runConformanceReport) into the five graduation tiers named there: admission, -// session-driving, activity-routing, primary-eligible, statusline. Each tier +// runConformanceReport) into the six graduation tiers named there: admission, +// session-driving, activity-routing, aqe-provider, primary-eligible, +// statusline. Each tier // is a black-box check against a REAL installed adapter layout — real // manifest, real admission, real subprocess hooks — no third-party code ever // runs in-process, matching every other module under adapters/. @@ -36,6 +37,7 @@ import { } from './consent.mjs'; import { registerAdmittedLifecycle, resetAdmittedLifecycle } from './lifecycle-registry.mjs'; import { registerAdmittedExecution, resetAdmittedExecution } from '../execution/admitted.mjs'; +import { resetAdmittedAqeProviders } from './aqe-provider.mjs'; import { executeRunPlan } from '../execution/runner.mjs'; import { have } from '../exec.mjs'; import { @@ -53,6 +55,7 @@ const nowIso = () => new Date().toISOString(); // of a transport check). Directive and bounded so every tier's exercise // tests wiring, not the model's ambition. const CONFORMANCE_PROBE_PROMPT = 'Reply with exactly: OK'; +const AQE_PROVIDER_PROBE_RESPONSE = 'OK'; /** admitAdapters' readManifest contract: `(source) => Promise` — a real * resolve, matching admission.mjs's own default (dynamic import so this @@ -401,6 +404,61 @@ async function checkPrimaryEligible({ return { status: 'failed', checks: [{ name: exerciseLabel, ok: false, detail: outcome.detail }] }; } +// ── aqe-provider tier: real external-provider transport exercise ──────── +// Agentic-QE 3.13.12 makes an external provider identity possible, but the +// adapter still has to earn agentic-kit's privileged projection. This probe +// registers the already-admitted candidate ONLY in the conformance process, +// invokes the same public runtime API production uses, and requires an exact +// stdin -> stdout response. It deliberately does not inspect or require an +// existing aqeProvider grant: evidence must exist before grantCapability can +// confer that capability, exactly like primary-eligible's evidence-first +// exercise above. +async function checkAqeProvider({ + manifest, name, baseDir, integrity, hash, cwd, +}) { + if (!manifest?.aqe?.provider) { + return { + status: 'skipped', + checks: [{ name: 'aqe.provider candidate declared', ok: true, detail: 'not declared — nothing to prove' }], + }; + } + + const label = 'AQE external-provider stdin/stdout round trip'; + let runtime; + try { + runtime = await import('./aqe-provider.mjs'); + } catch (error) { + const detail = `AQE provider runtime unavailable: ${error?.message ?? String(error)}`; + return { status: 'failed', checks: [{ name: label, ok: false, detail }] }; + } + + try { + const model = manifest.aqe.provider.defaultModel ?? manifest.aqe.provider.models?.[0] ?? 'default'; + const result = await runtime.runAdmittedAqeProviderProbe({ + manifest, + baseDir, + integrity, + projectRoot: cwd, + }); + const completion = typeof result?.stdoutText === 'string' + ? result.stdoutText.trim() + : typeof result?.completion === 'string' + ? result.completion.trim() + : ''; + if (result?.ok !== true || completion !== AQE_PROVIDER_PROBE_RESPONSE) { + const detail = result?.detail + ?? `expected exact '${AQE_PROVIDER_PROBE_RESPONSE}' completion, got ${JSON.stringify(completion)}`; + return { status: 'failed', checks: [{ name: label, ok: false, detail }] }; + } + const evidence = `provider '${name}' completed the real AQE candidate hook through runAdmittedAqeProviderProbe ` + + `(model=${model}, exact response=${AQE_PROVIDER_PROBE_RESPONSE}, contentHash=${hash})`; + return { status: 'passed', checks: [{ name: label, ok: true, detail: evidence }], evidence }; + } catch (error) { + const detail = `AQE provider exercise threw: ${error?.message ?? String(error)}`; + return { status: 'failed', checks: [{ name: label, ok: false, detail }] }; + } +} + // ── statusline tier ───────────────────────────────────────────────────── // Gates a capability the manifest can NEVER declare (commandStatusline is // inexpressible in the schema — ADR-0029/0031). Cannot be exercised @@ -483,6 +541,12 @@ async function checkGrantGatedTier({ * no business landing in whatever directory the operator happened to run * `ak host adapters conformance` from. * + * aqe-provider likewise has no caller-injectable exercise: once admission + * and activity-routing pass, it registers the candidate in the conformance + * process and calls the public admitted-AQE-provider runtime with an exact + * stdin/stdout probe. The production registration path remains grant-gated; + * this evidence-only registration cannot leak past the harness reset. + * * primary-eligible has no caller-injectable exercise (unlike statusline, * still a placeholder this wave): it always runs runPrimaryEligibleExercise, * a real escalation-plus-direct-run probe driven through executeRunPlan @@ -596,14 +660,13 @@ export async function runTieredConformance({ tierResults.push({ tier: 'session-driving', ...checkSessionDriving({ manifest: effectiveManifest, upstreamRef: sessionDrivingUpstreamRef }) }); } - // primary-eligible's real exercise runs actual workers through the - // admitted host's derived execution adapter, so it needs activity-routing - // to have genuinely passed THIS run — not merely to have been requested. + // primary-eligible and aqe-provider both require activity-routing to have + // genuinely passed THIS run — not merely to have been requested. // Computed here, once, whenever either tier needs it, so a caller asking // for `tiers: ['primary-eligible']` alone still gets the real dependency // check rather than an unconditioned pass/skip. let activityRoutingResult = null; - if (wantTier('activity-routing') || wantTier('primary-eligible')) { + if (wantTier('activity-routing') || wantTier('aqe-provider') || wantTier('primary-eligible')) { activityRoutingResult = await checkActivityRouting({ manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, integrity, haveFn, clock, cwd: workerCwd, timeoutMs: effectiveTimeoutMs, @@ -613,6 +676,24 @@ export async function runTieredConformance({ } } + if (wantTier('aqe-provider')) { + let result; + if (!effectiveManifest) { + result = { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } else if (activityRoutingResult?.status !== 'passed') { + result = { + status: 'skipped', + checks: [{ name: 'activity-routing prerequisite', ok: false, detail: 'activity-routing did not pass — cannot evaluate' }], + }; + } else { + result = await checkAqeProvider({ + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, + integrity, hash, cwd: workerCwd, + }); + } + tierResults.push({ tier: 'aqe-provider', ...result }); + } + if (wantTier('primary-eligible')) { // F-1 (security review): NOT checkGrantGatedTier — that gates the // exercise on an already-existing canBePrimary grant, which deadlocks @@ -698,6 +779,7 @@ export async function runTieredConformance({ resetAdmitted(); resetAdmittedExecution(); resetAdmittedLifecycle(); + resetAdmittedAqeProviders(); if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } if (workerCwdTempDir) { try { fs.rmSync(workerCwdTempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } } diff --git a/src/lib/adapters/grants.mjs b/src/lib/adapters/grants.mjs index 0dba068..22afe47 100644 --- a/src/lib/adapters/grants.mjs +++ b/src/lib/adapters/grants.mjs @@ -25,25 +25,26 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir } from '../paths.mjs'; -/** The five conformance tiers, in graduation order (ADR-0031 §2). */ +/** The six conformance tiers, in graduation order (ADR-0031 §2). */ export const CONFORMANCE_TIERS = Object.freeze([ - 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + 'admission', 'session-driving', 'activity-routing', 'aqe-provider', 'primary-eligible', 'statusline', ]); /** The only capabilities `ak` can grant. `session-driving` and * `activity-routing` gate capabilities a manifest may already express * (canDriveSession, canRouteActivities) — their tier records are evidence, - * not grants, so they have no entry here. `aqeProvider` is never grantable by - * `ak` at all (upstream-owned enumeration, ADR-0031 §4) and MUST NOT appear - * in this map. */ + * not grants, so they have no entry here. Agentic-QE 3.13.12's external + * provider registry makes `aqeProvider` earnable: the adapter must first pass + * the real `aqe-provider` transport exercise, then a maintainer grants the + * hash-pinned capability exactly like the other privileged surfaces. */ export const TIER_GRANTS = Object.freeze({ + 'aqe-provider': 'aqeProvider', 'primary-eligible': 'canBePrimary', statusline: 'commandStatusline', }); /** Reverse of TIER_GRANTS: capability -> its gating tier, or undefined if - * `capability` is not one `ak` can actually grant (e.g. a forged/legacy - * 'aqeProvider' key sitting in a hand-edited or merged store). Shared by + * `capability` is not one `ak` can actually grant. Shared by * grantCapability (write-time check) and grantedCapabilitiesFor (read-time * re-check — F-1: the write-time gate alone does not protect a flat JSON * file an operator can edit directly). */ @@ -283,10 +284,9 @@ export function revokeGrants(name, { file = adapterGrantsPath() } = {}) { * revokeGrants above which wipes the whole record. Refuses (throws * TypeError) any `capability` that is not one of TIER_GRANTS' values — * matches grantCapability's own allow-list, so this can never be asked to - * remove a forged/legacy key (e.g. 'aqeProvider') that grantCapability could - * never have written in the first place. Returns whether the capability - * existed beforehand; false (never throws) for a missing/never-recorded - * `name`. */ + * remove a forged/legacy key that grantCapability could never have written + * in the first place. Returns whether the capability existed beforehand; + * false (never throws) for a missing/never-recorded `name`. */ export function revokeCapability(name, capability, { file = adapterGrantsPath() } = {}) { if (typeof name !== 'string' || !name) return false; if (!Object.values(TIER_GRANTS).includes(capability)) { @@ -341,11 +341,10 @@ export function grantsFor(name, { file = adapterGrantsPath(), currentHash } = {} * record.capabilities verbatim. adapter-grants.json is a flat JSON file an * operator (or a bad merge) can hand-edit directly — grantCapability's * write-time gate does not protect against that. A capability is only ever - * returned when it (a) is a real TIER_GRANTS value — never e.g. a forged - * 'aqeProvider' key — AND (b) its gating tier is recorded 'passed' at this - * exact hash. This raises store forgery from "add one key" to "also forge a - * matching passed tier", and makes aqeProvider unreturnable even if present - * in the raw file. */ + * returned when it (a) is a real TIER_GRANTS value AND (b) its gating tier is + * recorded 'passed' at this exact hash. This raises store forgery from "add + * one key" to "also forge a matching passed tier"; `aqeProvider` is now a + * legitimate value only when backed by a passed `aqe-provider` exercise. */ export function grantedCapabilitiesFor(name, currentHash, { file = adapterGrantsPath() } = {}) { try { const record = grantsFor(name, { file }); diff --git a/src/lib/adapters/index.mjs b/src/lib/adapters/index.mjs index 365ed2b..13f064e 100644 --- a/src/lib/adapters/index.mjs +++ b/src/lib/adapters/index.mjs @@ -10,3 +10,4 @@ export * from './ownership.mjs'; export * from './manifest.mjs'; export * from './admission.mjs'; export * from './admitted.mjs'; +export * from './aqe-provider.mjs'; diff --git a/src/lib/adapters/integrity.mjs b/src/lib/adapters/integrity.mjs index dd4b440..388d1d0 100644 --- a/src/lib/adapters/integrity.mjs +++ b/src/lib/adapters/integrity.mjs @@ -56,6 +56,8 @@ function hookEntries(manifest) { } const executionHook = manifest?.execution?.run?.hook; if (executionHook) entries.push({ label: 'execution.run', hook: executionHook }); + const aqeProviderHook = manifest?.aqe?.provider?.hook; + if (aqeProviderHook) entries.push({ label: 'aqe.provider', hook: aqeProviderHook }); return entries; } diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index bcf3b33..7e55cbe 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -13,6 +13,18 @@ import { validateHostAdapter, HOST_REGISTRY, PROJECTION_REGISTRY, OBSERVABILITY_ import { LIFECYCLE_OPERATIONS } from './lifecycle.mjs'; const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/; +const AQE_PROVIDER_TYPE_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const AQE_BUILTIN_OR_RESERVED_TYPES = new Set([ + 'anthropic', 'claude-code', 'claude', 'codex', 'openai', 'gemini', + 'openrouter', 'azure-openai', 'bedrock', 'cognitum', 'ollama', 'onnx', +]); +const AQE_BILLING_MODES = Object.freeze(['subscription', 'metered-api', 'metered-capped', 'local']); +const AQE_BRIDGE_ENV = new Set(['PATH', 'HOME', 'XDG_CONFIG_HOME', 'APPDATA', 'AK_EXPERIMENTAL_HOST_ADAPTERS']); +const ENV_CODE_INJECTION = new Set([ + 'NODE_OPTIONS', 'BASH_ENV', 'ENV', 'PYTHONPATH', 'PYTHONHOME', 'RUBYOPT', + 'PERL5OPT', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', +]); export const DRIVING_SURFACES = Object.freeze(['cli-subprocess', 'acp', 'mcp']); @@ -52,7 +64,7 @@ export class ManifestRejected extends TypeError { // validateHostAdapter, src/lib/hosts.mjs, src/lib/providers.mjs) consume — // widen them only alongside a new legitimate consumer, never speculatively. const MANIFEST_ALLOWED_KEYS = Object.freeze([ - 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', 'execution', + 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', 'execution', 'aqe', ]); // Everything validateHostAdapter itself reads (id, label, install, // capabilities, trust, enabledByDefault, configProjection, observability) @@ -198,6 +210,129 @@ function validateExecution(value) { return structuredClone(value); } +function validateEnvNames(value, field) { + if (value === undefined) return undefined; + try { + assertStringArray(value, field); + } catch (error) { + throw new ManifestRejected('invalid-aqe-provider', error.message); + } + const invalid = value.find((name) => !ENV_NAME_RE.test(name)); + if (invalid !== undefined) { + throw new ManifestRejected('invalid-aqe-provider', `${field} contains invalid environment name '${invalid}'`); + } + return [...value]; +} + +/** Validate candidate data; host.id fixes identity and grants activation. */ +function validateAqe(value, host, driving, execution) { + assertRecord(value, 'aqe'); + assertNoUnknownKeys(value, ['provider'], 'aqe'); + assertRecord(value.provider, 'aqe.provider'); + assertNoUnknownKeys(value.provider, [ + 'hook', 'billingMode', 'models', 'defaultModel', 'maxConcurrency', 'stripEnv', 'displayName', + ], 'aqe.provider'); + + const type = host.id; + if (!AQE_PROVIDER_TYPE_RE.test(type)) { + throw new ManifestRejected( + 'invalid-aqe-provider-type', + `host.id '${type}' must match ${AQE_PROVIDER_TYPE_RE.source} to be an AQE provider identity`, + ); + } + if (AQE_BUILTIN_OR_RESERVED_TYPES.has(type)) { + throw new ManifestRejected('aqe-provider-collision', `host.id '${type}' is built in or reserved by agentic-qe`); + } + if (!driving.surfaces.includes('cli-subprocess')) { + throw new ManifestRejected('aqe-provider-surface', 'manifest.aqe requires driving.surfaces to include cli-subprocess'); + } + if (host.capabilities.canRouteActivities !== true || !execution?.run?.hook) { + throw new ManifestRejected( + 'aqe-provider-routing', + 'manifest.aqe requires host.capabilities.canRouteActivities:true and manifest.execution.run.hook', + ); + } + + const provider = value.provider; + assertRecord(provider.hook, 'aqe.provider.hook'); + assertNoUnknownKeys(provider.hook, ['command', 'timeoutMs', 'files', 'passEnv'], 'aqe.provider.hook'); + try { + assertStringArray(provider.hook.command, 'aqe.provider.hook.command', { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-aqe-provider', error.message); + } + if (provider.hook.timeoutMs !== undefined + && (!Number.isInteger(provider.hook.timeoutMs) || provider.hook.timeoutMs <= 0)) { + throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.hook.timeoutMs must be a positive integer'); + } + validateHookFiles(provider.hook.files, 'aqe.provider.hook.files', 'invalid-aqe-provider'); + const passEnv = validateEnvNames(provider.hook.passEnv, 'aqe.provider.hook.passEnv'); + const stripEnv = validateEnvNames(provider.stripEnv, 'aqe.provider.stripEnv'); + const unsafePass = passEnv?.find((name) => AQE_BRIDGE_ENV.has(name) + || ENV_CODE_INJECTION.has(name) || name.startsWith('AK_AQE_')); + if (unsafePass) { + throw new ManifestRejected('invalid-aqe-provider', `aqe.provider.hook.passEnv may not forward bridge/runtime variable '${unsafePass}'`); + } + const unsafeStrip = stripEnv?.find((name) => AQE_BRIDGE_ENV.has(name)); + if (unsafeStrip) { + throw new ManifestRejected('invalid-aqe-provider', `aqe.provider.stripEnv may not remove bridge runtime variable '${unsafeStrip}'`); + } + const conflict = passEnv?.find((name) => stripEnv?.includes(name)); + if (conflict) { + throw new ManifestRejected('invalid-aqe-provider', `environment '${conflict}' cannot appear in both passEnv and stripEnv`); + } + + if (provider.billingMode !== undefined && !AQE_BILLING_MODES.includes(provider.billingMode)) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.billingMode must be one of ${AQE_BILLING_MODES.join(', ')}`, + ); + } + let models = ['default']; + if (provider.models !== undefined) { + try { + assertStringArray(provider.models, 'aqe.provider.models', { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-aqe-provider', error.message); + } + models = [...provider.models]; + } + const defaultModel = provider.defaultModel ?? models[0]; + if (provider.defaultModel !== undefined) { + if (typeof provider.defaultModel !== 'string' || !provider.defaultModel) { + throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.defaultModel must be a non-empty string'); + } + if (!models.includes(provider.defaultModel)) { + throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.defaultModel must be present in aqe.provider.models'); + } + } + if (provider.maxConcurrency !== undefined + && (!Number.isInteger(provider.maxConcurrency) || provider.maxConcurrency <= 0)) { + throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.maxConcurrency must be a positive integer'); + } + if (provider.displayName !== undefined + && (typeof provider.displayName !== 'string' || !provider.displayName.trim())) { + throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.displayName must be a non-empty string'); + } + + return { + provider: { + hook: { + command: [...provider.hook.command], + ...(provider.hook.timeoutMs === undefined ? {} : { timeoutMs: provider.hook.timeoutMs }), + ...(provider.hook.files === undefined ? {} : { files: [...provider.hook.files] }), + ...(passEnv === undefined ? {} : { passEnv }), + }, + ...(provider.billingMode === undefined ? {} : { billingMode: provider.billingMode }), + models, + defaultModel, + ...(provider.maxConcurrency === undefined ? {} : { maxConcurrency: provider.maxConcurrency }), + ...(stripEnv === undefined ? {} : { stripEnv }), + ...(provider.displayName === undefined ? {} : { displayName: provider.displayName }), + }, + }; +} + /** Hook file inventories are portable paths relative to the manifest's own * directory. Content is hashed later, once admission has resolved that * directory; schema validation keeps absolute/traversal paths out of the @@ -348,6 +483,7 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle); const trust = validateManifestTrust(value.trust); const execution = value.execution === undefined ? undefined : validateExecution(value.execution); + const aqe = value.aqe === undefined ? undefined : validateAqe(value.aqe, host, driving, execution); return immutable({ name: value.name, @@ -362,6 +498,7 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob // execution block changes consent's covered hash automatically — no // separate hashing path to keep in sync. ...(execution === undefined ? {} : { execution }), + ...(aqe === undefined ? {} : { aqe }), trust, }); } diff --git a/tests/kit/adapter-aqe-provider.test.mjs b/tests/kit/adapter-aqe-provider.test.mjs new file mode 100644 index 0000000..571d5f6 --- /dev/null +++ b/tests/kit/adapter-aqe-provider.test.mjs @@ -0,0 +1,319 @@ +import { beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; +import { resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; +import { grantCapability, recordTierResult } from '../../src/lib/adapters/grants.mjs'; +import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; +import { + admittedAqeProviderFor, + admittedAqeProviders, + projectedAqeExternalProviders, + registerAdmittedAqeProvider, + resetAdmittedAqeProviders, + runAdmittedAqeProvider, + runAdmittedAqeProviderProbe, +} from '../../src/lib/adapters/aqe-provider.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +beforeEach(() => { + resetAdmittedAqeProviders(); + resetAdmitted(); +}); + +function validHost(id = 'hermes') { + return { + id, + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + }; +} + +function rawManifest(id = 'hermes', aqe = {}) { + return { + name: id, + version: '1.0.0', + contract: 1, + host: validHost(id), + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { + run: { + hook: { + command: [process.execPath, 'execution-hook.mjs'], + files: ['execution-hook.mjs'], + timeoutMs: 5000, + }, + }, + }, + aqe: { + provider: { + hook: { + command: [process.execPath, 'aqe-hook.mjs'], + files: ['aqe-hook.mjs'], + timeoutMs: 5000, + passEnv: ['BRIDGE_TOKEN'], + }, + billingMode: 'subscription', + models: ['default', 'fast'], + defaultModel: 'default', + maxConcurrency: 3, + stripEnv: ['OPENAI_API_KEY'], + displayName: 'Hermes subscription', + ...aqe, + }, + }, + trust: { changes: [] }, + }; +} + +function fixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-')); + fs.writeFileSync(path.join(dir, 'execution-hook.mjs'), ` +process.stdin.resume(); +process.stdin.on('end', () => process.stdout.write('OK')); +`); + fs.writeFileSync(path.join(dir, 'aqe-hook.mjs'), ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) prompt += chunk; +process.stdout.write(JSON.stringify({ + prompt, + model: process.env.AK_AQE_MODEL, + provider: process.env.AK_AQE_PROVIDER, + project: process.env.AK_AQE_PROJECT_CWD, + token: process.env.BRIDGE_TOKEN ?? null, + leaked: process.env.UNLISTED_SECRET ?? null +})); +`); + const manifest = validateAdapterManifest(rawManifest()); + const integrity = hashAdapterContent(manifest, { baseDir: dir }); + return { dir, manifest, integrity }; +} + +test('aqe.provider is strict, host-derived, normalized candidate data', () => { + const manifest = validateAdapterManifest(rawManifest()); + assert.equal(Object.hasOwn(manifest.aqe.provider, 'type'), false); + assert.deepEqual(manifest.aqe.provider.models, ['default', 'fast']); + assert.equal(manifest.aqe.provider.defaultModel, 'default'); + assert.throws( + () => validateAdapterManifest(rawManifest('openai')), + (error) => error.reason === 'aqe-provider-collision', + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { apiKey: 'secret' })), + (error) => error.reason === 'unknown-field', + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['BRIDGE_TOKEN'] })), + (error) => error.reason === 'invalid-aqe-provider', + ); + const injected = rawManifest(); + injected.aqe.provider.hook.passEnv = ['NODE_OPTIONS']; + assert.throws( + () => validateAdapterManifest(injected), + (error) => error.reason === 'invalid-aqe-provider', + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['PATH'] })), + (error) => error.reason === 'invalid-aqe-provider', + ); + const noCli = rawManifest(); + noCli.driving.surfaces = ['mcp']; + assert.throws( + () => validateAdapterManifest(noCli), + (error) => error.reason === 'aqe-provider-surface', + ); + const noWorkerPath = rawManifest(); + delete noWorkerPath.execution; + assert.throws( + () => validateAdapterManifest(noWorkerPath), + (error) => error.reason === 'aqe-provider-routing', + ); +}); + +test('AQE hook files participate in the admitted adapter content identity', () => { + const { dir, manifest, integrity } = fixture(); + try { + assert.deepEqual(integrity.hookFiles.map((file) => file.path), ['aqe-hook.mjs', 'execution-hook.mjs']); + fs.appendFileSync(path.join(dir, 'aqe-hook.mjs'), '\n// changed\n'); + const changed = hashAdapterContent(manifest, { baseDir: dir }); + assert.notEqual(changed.hash, integrity.hash); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('live provider receipts are immutable and projection exposes no manifest internals', () => { + const { dir, manifest, integrity } = fixture(); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity, contentHash: integrity.hash }); + const receipt = admittedAqeProviderFor('hermes'); + assert.equal(receipt.id, 'hermes'); + assert.equal(receipt.hostId, 'hermes'); + assert.equal(receipt.contentHash, integrity.hash); + assert.equal(Object.hasOwn(receipt, 'manifest'), false); + assert.equal(Object.hasOwn(receipt, 'baseDir'), false); + assert.ok(Object.isFrozen(receipt)); + assert.equal(admittedAqeProviders().length, 1); + + const projected = projectedAqeExternalProviders(); + assert.equal(projected.hermes.command[0], process.execPath); + assert.match(projected.hermes.command[1], /bin[/\\]agentic-kit\.mjs$/); + assert.deepEqual(projected.hermes.command.slice(2, 5), ['x', 'aqe-provider', 'hermes']); + assert.deepEqual(projected.hermes.command.slice(5), [ + '--expect-hash', integrity.hash, '--project-root', process.cwd(), + ]); + assert.equal(projected.hermes.kind, 'cli'); + assert.equal(projected.hermes.modelFlag, '--model'); + assert.equal(projected.hermes.timeoutMs, 7500); + assert.deepEqual(projected.hermes.stripEnv, ['OPENAI_API_KEY']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('bootstrap activates only an admitted, enabled, hash-current aqeProvider grant', async (t) => { + const priorXdg = process.env.XDG_CONFIG_HOME; + const grantHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-grant-')); + process.env.XDG_CONFIG_HOME = grantHome; + const { dir, manifest, integrity } = fixture(); + const manifestFile = path.join(dir, 'manifest.json'); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + t.after(() => { + process.env.XDG_CONFIG_HOME = priorXdg; + fs.rmSync(grantHome, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + resetAdmittedAqeProviders(); + resetAdmitted(); + }); + + recordTierResult('hermes', 'aqe-provider', { + hash: integrity.hash, evidence: 'real AQE stdin/stdout provider probe returned OK', + }); + grantCapability('hermes', 'aqeProvider', { hash: integrity.hash }); + const consent = { + recordedHashFor: () => integrity.hash, + isTrusted: (_name, hash) => hash === integrity.hash, + }; + const cfg = { + hostAdapters: [{ name: 'hermes', source: manifestFile }], + integrations: { hosts: { hermes: true } }, + }; + const active = await bootstrapHostAdapters({ + cfg, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(active.admitted.length, 1); + assert.equal(active.warnings.length, 0); + assert.equal(admittedAqeProviderFor('hermes')?.contentHash, integrity.hash); + + await bootstrapHostAdapters({ + cfg: { ...cfg, integrations: { hosts: { hermes: false } } }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(admittedAqeProviderFor('hermes'), null, 'a disabled host clears the live provider snapshot'); +}); + +test('production bridge uses supervised stdin/model/env/cwd path and strips unrelated secrets', async () => { + const { dir, manifest, integrity } = fixture(); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity }); + const result = await runAdmittedAqeProvider('hermes', { + stdin: 'hello from AQE', + model: 'fast', + projectRoot: dir, + env: { BRIDGE_TOKEN: 'allowed', UNLISTED_SECRET: 'must-not-leak' }, + }); + assert.equal(result.ok, true, result.detail); + const payload = JSON.parse(result.stdoutText); + assert.deepEqual(payload, { + prompt: 'hello from AQE', model: 'fast', provider: 'hermes', project: dir, + token: 'allowed', leaked: null, + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('unsupported models and post-admission hook edits fail before execution with no stdout', async () => { + const { dir, manifest, integrity } = fixture(); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity }); + const badModel = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'undeclared', projectRoot: dir, + }); + assert.equal(badModel.ok, false); + assert.equal(badModel.stdoutText, ''); + assert.match(badModel.detail, /does not declare model/); + + const staleProjection = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'default', projectRoot: dir, expectedHash: 'f'.repeat(64), + }); + assert.equal(staleProjection.ok, false); + assert.equal(staleProjection.stdoutText, ''); + assert.match(staleProjection.detail, /does not match projected/); + + fs.appendFileSync(path.join(dir, 'aqe-hook.mjs'), '\nthrow new Error("must not run");\n'); + const stale = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'default', projectRoot: dir, + }); + assert.equal(stale.ok, false); + assert.equal(stale.stdoutText, ''); + assert.match(stale.detail, /hook-content-changed/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('evidence-first probe uses the production runner with a fixed bounded prompt', async () => { + const { dir, manifest, integrity } = fixture(); + try { + const result = await runAdmittedAqeProviderProbe({ + manifest, baseDir: dir, integrity, projectRoot: dir, timeoutMs: 5000, + }); + assert.equal(result.ok, true, result.detail); + const payload = JSON.parse(result.stdoutText); + assert.equal(payload.prompt, 'Reply with exactly: OK'); + assert.equal(payload.model, 'default'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('hidden CLI transport never emits failure or drift diagnostics on stdout', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-home-')); + try { + const result = spawnSync(process.execPath, [ + path.join(ROOT, 'bin', 'agentic-kit.mjs'), 'x', 'aqe-provider', 'missing-provider', '--model', 'default', + '--expect-hash', 'a'.repeat(64), '--project-root', ROOT, + ], { + cwd: ROOT, + env: { ...process.env, HOME: home, AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + input: 'prompt', + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /not active/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs index 824d8fe..85b7e92 100644 --- a/tests/kit/adapter-grants.test.mjs +++ b/tests/kit/adapter-grants.test.mjs @@ -20,15 +20,18 @@ function tempFile() { const HASH_A = 'a'.repeat(64); const HASH_B = 'b'.repeat(64); -test('exports the five conformance tiers in graduation order', () => { +test('exports the six conformance tiers in graduation order', () => { assert.deepEqual(CONFORMANCE_TIERS, [ - 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + 'admission', 'session-driving', 'activity-routing', 'aqe-provider', 'primary-eligible', 'statusline', ]); }); -test('TIER_GRANTS only maps primary-eligible and statusline; aqeProvider never appears', () => { - assert.deepEqual(TIER_GRANTS, { 'primary-eligible': 'canBePrimary', statusline: 'commandStatusline' }); - assert.ok(!Object.values(TIER_GRANTS).includes('aqeProvider')); +test('TIER_GRANTS maps aqe-provider evidence to the external AQE provider capability', () => { + assert.deepEqual(TIER_GRANTS, { + 'aqe-provider': 'aqeProvider', + 'primary-eligible': 'canBePrimary', + statusline: 'commandStatusline', + }); }); test('record -> grant happy path: passed tier at the same hash grants the capability', () => { @@ -66,10 +69,12 @@ test('grantCapability refused: tier passed but at a DIFFERENT hash', () => { assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); }); -test('grantCapability refused: aqeProvider is never a grantable capability', () => { +test('aqeProvider grant requires and consumes same-hash aqe-provider evidence', () => { const file = tempFile(); - recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); - assert.throws(() => grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }), /aqe-provider/); + recordTierResult('acme', 'aqe-provider', { hash: HASH_A, evidence: 'real AQE provider probe returned OK' }, { file }); + grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { aqeProvider: true }); }); test('grantedCapabilitiesFor returns {} on hash mismatch', () => { @@ -231,7 +236,7 @@ test('gatedTiersFor: currentHash mismatch voids gated-tier records, same as gran assert.deepEqual(gatedTiersFor('acme', { file, currentHash: HASH_B }), []); }); -// ── Finding 11: a grant-bearing tier ('primary-eligible', 'statusline') +// ── Finding 11: every grant-bearing tier // must never be recorded 'passed' with empty evidence — a grant must always // trace back to real conformance evidence (ADR-0031 §1). ────────────────── @@ -240,6 +245,7 @@ test('recordTierResult on a grant-bearing tier requires non-empty evidence', () assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A }, { file }), TypeError); assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: '' }, { file }), TypeError); assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: ' ' }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'aqe-provider', { hash: HASH_A, evidence: '' }, { file }), TypeError); assert.throws(() => recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: '' }, { file }), TypeError); // nothing was recorded by any of the rejected attempts assert.equal(grantsFor('acme', { file }), null); @@ -276,12 +282,12 @@ test('F-1: grantedCapabilitiesFor rejects a forged capability that has no matchi ); }); -test("F-1: grantedCapabilitiesFor rejects a forged 'aqeProvider' key even though it is present in the raw store", () => { +test("F-1: grantedCapabilitiesFor rejects a forged 'aqeProvider' key with no passed aqe-provider tier", () => { const file = tempFile(); recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); const store = JSON.parse(fs.readFileSync(file, 'utf8')); - store.acme.capabilities.aqeProvider = true; // forged — never written by grantCapability + store.acme.capabilities.aqeProvider = true; // forged — lacks its own passed tier fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); const granted = grantedCapabilitiesFor('acme', HASH_A, { file }); @@ -431,11 +437,13 @@ test('revokeCapability returns false when the capability was never granted, or t assert.equal(revokeCapability('acme', 'canBePrimary', { file }), false); }); -test('revokeCapability rejects a non-grantable capability, including a forged aqeProvider key', () => { +test('revokeCapability accepts aqeProvider and still rejects unknown capabilities', () => { const file = tempFile(); recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); - assert.throws(() => revokeCapability('acme', 'aqeProvider', { file }), TypeError); + recordTierResult('acme', 'aqe-provider', { hash: HASH_A, evidence: 'real AQE provider probe returned OK' }, { file }); + grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }); + assert.equal(revokeCapability('acme', 'aqeProvider', { file }), true); assert.throws(() => revokeCapability('acme', 'transcripts', { file }), TypeError); // Nothing was touched by the rejected attempts. assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs index 0b1e0b2..4d002f0 100644 --- a/tests/kit/conformance-tiers.test.mjs +++ b/tests/kit/conformance-tiers.test.mjs @@ -58,7 +58,9 @@ function rawAcmeManifest(overrides = {}) { * test can supply its own `run-hook.mjs` body to observe exactly what the * activity-routing tier's real worker receives (prompt, AK_WORKER_CWD). */ -function writeProbeAdapter(tempDir, { runHookSource, hookTimeoutMs = 5000 } = {}) { +function writeProbeAdapter(tempDir, { + runHookSource, hookTimeoutMs = 5000, aqeHookSource, aqeHookTimeoutMs = 5000, +} = {}) { const manifest = { name: 'probe', version: '1.0.0', @@ -85,6 +87,18 @@ function writeProbeAdapter(tempDir, { runHookSource, hookTimeoutMs = 5000 } = {} detection: { bin: 'probe', versionArgs: ['--version'], versionPattern: '\\d+\\.\\d+\\.\\d+' }, driving: { surfaces: ['cli-subprocess'] }, execution: { run: { hook: { command: ['node', 'run-hook.mjs'], files: ['run-hook.mjs'], timeoutMs: hookTimeoutMs } } }, + ...(aqeHookSource ? { + aqe: { + provider: { + hook: { command: ['node', 'aqe-hook.mjs'], files: ['aqe-hook.mjs'], timeoutMs: aqeHookTimeoutMs }, + billingMode: 'local', + models: ['probe-model'], + defaultModel: 'probe-model', + maxConcurrency: 1, + displayName: 'Probe AQE provider', + }, + }, + } : {}), trust: { changes: [{ id: 'probe-subprocess-hooks', @@ -98,6 +112,7 @@ function writeProbeAdapter(tempDir, { runHookSource, hookTimeoutMs = 5000 } = {} }; fs.writeFileSync(path.join(tempDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); fs.writeFileSync(path.join(tempDir, 'run-hook.mjs'), runHookSource); + if (aqeHookSource) fs.writeFileSync(path.join(tempDir, 'aqe-hook.mjs'), aqeHookSource); return path.join(tempDir, 'manifest.json'); } @@ -105,9 +120,9 @@ const readManifestFromTempFile = async (source) => JSON.parse(fs.readFileSync(so // ── exports sanity ─────────────────────────────────────────────────────── -test('re-exports the five ADR-0031 §2 conformance tiers in graduation order', () => { +test('re-exports the six ADR-0031 §2 conformance tiers in graduation order', () => { assert.deepEqual(CONFORMANCE_TIERS, [ - 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + 'admission', 'session-driving', 'activity-routing', 'aqe-provider', 'primary-eligible', 'statusline', ]); }); @@ -166,7 +181,7 @@ test('F2: a manifest that schema-validates but fails real admission (name-mismat manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, name: 'not-acme', // disagrees with the fixture manifest's own host.id ('acme') -> admitOne refuses 'name-mismatch' - tiers: ['admission', 'activity-routing', 'session-driving', 'primary-eligible', 'statusline'], + tiers: ['admission', 'activity-routing', 'session-driving', 'aqe-provider', 'primary-eligible', 'statusline'], grantsFile, haveFn: async () => true, }); @@ -175,7 +190,7 @@ test('F2: a manifest that schema-validates but fails real admission (name-mismat assert.equal(admissionTier.status, 'failed', JSON.stringify(admissionTier.checks, null, 2)); assert.match(admissionTier.checks.find((c) => !c.ok)?.detail ?? '', /does not match manifest host id/); - for (const tierName of ['activity-routing', 'session-driving', 'primary-eligible', 'statusline']) { + for (const tierName of ['activity-routing', 'session-driving', 'aqe-provider', 'primary-eligible', 'statusline']) { const tier = report.tiers.find((t) => t.tier === tierName); assert.equal(tier.status, 'skipped', `${tierName}: ${JSON.stringify(tier.checks)}`); assert.match(tier.checks[0].detail, /admission tier did not pass/); @@ -318,6 +333,79 @@ test("with no explicit override, the outer runner honors the manifest's own decl assert.ok(elapsedMs < 2500, `expected the manifest's declared 300ms hook timeout to bound the run, took ${elapsedMs}ms`); }); +// ── aqe-provider tier: evidence first, grant second ─────────────────────── + +test('aqe-provider is skipped when no candidate is declared, even though its activity-routing prerequisite passes', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['aqe-provider'], + grantsFile, + haveFn: async () => true, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped'); + assert.match(tier.checks[0].detail, /not declared/); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('aqe-provider genuinely passes a non-injectable public-runtime stdin/stdout probe before its explicit grant', async () => { + const grantsFile = tempGrantsFile(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-aqe-provider-')); + const manifestSource = writeProbeAdapter(tempDir, { + runHookSource: 'process.stdin.resume(); process.stdin.on(\'end\', () => process.stdout.write(JSON.stringify({ summary: \'activity OK\', provider: \'probe\' })));\n', + aqeHookSource: 'let input = \'\'; process.stdin.setEncoding(\'utf8\'); process.stdin.on(\'data\', (chunk) => { input += chunk; }); process.stdin.on(\'end\', () => { if (input !== \'Reply with exactly: OK\' || process.env.AK_AQE_PROVIDER !== \'probe\' || process.env.AK_AQE_MODEL !== \'probe-model\' || !process.env.AK_AQE_PROJECT_CWD) { process.stderr.write(\'bad AQE provider probe contract\'); process.exit(3); return; } process.stdout.write(\'OK\'); });\n', + }); + + const report = await runTieredConformance({ + manifestSource, + readManifest: readManifestFromTempFile, + tiers: ['aqe-provider'], + grantsFile, + haveFn: async () => true, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2)); + assert.match(tier.evidence, /runAdmittedAqeProvider/); + assert.match(tier.evidence, /contentHash=/); + + assert.deepEqual( + grantedCapabilitiesFor('probe', report.hash, { file: grantsFile }), + {}, + 'passing records evidence; it never self-grants', + ); + const record = grantsFor('probe', { file: grantsFile }); + assert.equal(record.tiers['aqe-provider'].status, 'passed'); + grantCapability('probe', 'aqeProvider', { hash: report.hash }, { file: grantsFile }); + assert.deepEqual( + grantedCapabilitiesFor('probe', report.hash, { file: grantsFile }), + { aqeProvider: true }, + ); +}); + +test('aqe-provider fails honestly and records no pass when the real hook violates the stdout protocol', async () => { + const grantsFile = tempGrantsFile(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-aqe-provider-bad-output-')); + const manifestSource = writeProbeAdapter(tempDir, { + runHookSource: 'process.stdin.resume(); process.stdin.on(\'end\', () => process.stdout.write(JSON.stringify({ summary: \'activity OK\', provider: \'probe\' })));\n', + aqeHookSource: 'process.stdin.resume(); process.stdin.on(\'end\', () => process.stdout.write(\'WRONG\'));\n', + }); + + const report = await runTieredConformance({ + manifestSource, + readManifest: readManifestFromTempFile, + tiers: ['aqe-provider'], + grantsFile, + haveFn: async () => true, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'failed', JSON.stringify(tier.checks, null, 2)); + const record = grantsFor('probe', { file: grantsFile }); + assert.equal(record.tiers['aqe-provider'].status, 'failed'); + assert.deepEqual(grantedCapabilitiesFor('probe', report.hash, { file: grantsFile }), {}); +}); + // ── session-driving tier: honest skipped/gated, never faked ──────────────── test('session-driving tier is skipped when the manifest does not declare canDriveSession', async () => { @@ -667,7 +755,7 @@ test('statusline tier is skipped/gated (never passed) when the admission prerequ // ── full sequence + persistence summary ───────────────────────────────── -test('running all five tiers together against acme yields three genuinely-passed tiers (admission, activity-routing, primary-eligible) plus an honestly gated statusline, with nothing faked', async () => { +test('running all six tiers together against acme yields three genuinely-passed tiers, an absent-candidate AQE skip, and an honestly gated statusline', async () => { const grantsFile = tempGrantsFile(); const report = await runTieredConformance({ manifestSource: VALID_MANIFEST_PATH, @@ -675,11 +763,12 @@ test('running all five tiers together against acme yields three genuinely-passed grantsFile, haveFn: async () => true, }); - assert.equal(report.tiers.length, 5); + assert.equal(report.tiers.length, 6); const byTier = Object.fromEntries(report.tiers.map((t) => [t.tier, t.status])); assert.equal(byTier.admission, 'passed'); assert.equal(byTier['session-driving'], 'skipped'); // acme declares canDriveSession:false assert.equal(byTier['activity-routing'], 'passed'); + assert.equal(byTier['aqe-provider'], 'skipped'); // acme has no AQE provider candidate // primary-eligible now genuinely passes in the same run (F-1 fix): its // real exercise is unconditional, not gated on a pre-existing grant — no // grant was seeded anywhere in this test. diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs index 8cdfee7..54db114 100644 --- a/tests/kit/host-adapters-cli.test.mjs +++ b/tests/kit/host-adapters-cli.test.mjs @@ -908,7 +908,7 @@ test('conformance: the banner reports "nothing will be spawned" for a manifest d const text = cap.text(); assert.match(text, /SELF-TEST \(ADR-0031 §5\)/); - assert.match(text, /declares no lifecycle\/execution hooks — nothing will be spawned/); + assert.match(text, /declares no lifecycle, execution, or AQE-provider hooks — nothing will be spawned/); }); test('conformance: the banner falls back to a generic warning when the manifest cannot be pre-disclosed, and the harness still runs and reports the real failure', async () => { @@ -1140,8 +1140,11 @@ test('grant: REFUSED when the gating tier is not recorded passed — exit 1, not assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); }); -test("grant: 'aqeProvider' is rejected as never ak-grantable (ADR-0031 §4), before any manifest read", async () => { +test("grant: 'aqeProvider' is refused when the current manifest has no AQE provider candidate", async () => { const grantsFile = tmpGrantsFile(); + const raw = validManifest(); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'aqe-provider', { hash, evidence: 'synthetic stale evidence' }, { file: grantsFile }); const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); const cap = capture(); @@ -1149,13 +1152,48 @@ test("grant: 'aqeProvider' is rejected as never ak-grantable (ADR-0031 §4), bef try { code = await run({ positionals: ['grant', 'hermes', 'aqeProvider'], env: ON_ENV, cfg, - reader: neverCalled('reader'), ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + reader: async () => raw, ask: neverCalled('ask'), isTTY: true, grantsFile, flags: { yes: true }, + consent: fileConsent(tmpConsentFile()), }); } finally { cap.restore(); } assert.equal(code, 1); - assert.match(cap.text(), /upstream-owned/); - assert.match(cap.text(), /ADR-0031 §4/); + assert.match(cap.text(), /does not declare manifest\.aqe\.provider/); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); +}); + +test("grant: 'aqeProvider' becomes live only after same-hash aqe-provider evidence and explicit confirmation", async () => { + const grantsFile = tmpGrantsFile(); + const raw = validManifest({ + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command: ['hermes', 'run'] } } }, + aqe: { + provider: { + hook: { command: ['hermes', 'aqe'] }, + billingMode: 'subscription', + models: ['hermes-default'], + defaultModel: 'hermes-default', + }, + }, + }); + const hash = hashManifest(validateAdapterManifest(raw)); + recordTierResult('hermes', 'aqe-provider', { hash, evidence: 'real AQE stdin/stdout probe returned OK' }, { file: grantsFile }); + const cfg = cfgWith([{ name: 'hermes', source: 'mem://hermes' }]); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['grant', 'hermes', 'aqeProvider'], env: ON_ENV, cfg, + reader: async () => raw, ask: async () => true, isTTY: true, grantsFile, flags: {}, + consent: fileConsent(tmpConsentFile()), + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /aqeProvider is live/); + assert.match(cap.text(), /Agentic-QE 3\.13\.12 externalProviders/); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { aqeProvider: true }); }); test('grant: any other non-TIER_GRANTS capability is rejected, before any manifest read', async () => { @@ -1532,7 +1570,7 @@ test('revoke-grant : an invalid/non-grantable capability is r recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'leads a run' }, { file: grantsFile }); grantCapability('hermes', 'canBePrimary', { hash }, { file: grantsFile }); - for (const bogus of ['aqeProvider', 'transcripts']) { + for (const bogus of ['transcripts']) { const cap = capture(); let code; try { @@ -1547,6 +1585,26 @@ test('revoke-grant : an invalid/non-grantable capability is r assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { canBePrimary: true }); }); +test('revoke-grant aqeProvider withdraws only the provider grant and leaves its passed tier evidence', async () => { + const grantsFile = tmpGrantsFile(); + const hash = 'a'.repeat(64); + recordTierResult('hermes', 'aqe-provider', { hash, evidence: 'real AQE provider probe returned OK' }, { file: grantsFile }); + grantCapability('hermes', 'aqeProvider', { hash }, { file: grantsFile }); + + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes', 'aqeProvider'], env: OFF_ENV, cfg: cfgWith([]), grantsFile, flags: {}, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.match(cap.text(), /revoked capability 'aqeProvider'/); + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), {}); + assert.equal(grantsFor('hermes', { file: grantsFile }).tiers['aqe-provider'].status, 'passed'); +}); + test('revoke-grant works even when the experimental flag is off (fail-safe, same as the whole-record form)', async () => { const grantsFile = tmpGrantsFile(); const raw = validManifest(); From cade3e22b1eb2e20421f1f168bf66f1af7849c2e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 14:43:37 -0700 Subject: [PATCH 02/21] feat(providers): project admitted providers into Agentic-QE --- package.json | 2 + src/commands/status.mjs | 26 +- src/commands/x/host.mjs | 35 +- src/commands/x/verify.mjs | 29 +- src/lib/providers.mjs | 295 +++++++++++++++-- src/lib/routing.mjs | 34 +- tests/kit/providers-external.test.mjs | 195 +++++++++++ tests/kit/providers.test.mjs | 4 +- .../aqe-external-provider-transport.test.mjs | 307 ++++++++++++++++++ 9 files changed, 876 insertions(+), 51 deletions(-) create mode 100644 tests/kit/providers-external.test.mjs create mode 100644 tests/live/aqe-external-provider-transport.test.mjs diff --git a/package.json b/package.json index 194feed..487ecfc 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "docs/adr/0015-managed-codex-native-statusline.md", "docs/adr/0032-model-lifecycle-intelligence.md", "docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md", + "tests/live/aqe-external-provider-transport.test.mjs", "tests/live/qe-court-participant-transport.test.mjs", "docs/ddd/model-lifecycle-intelligence.md" ], @@ -39,6 +40,7 @@ "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", "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", "typecheck": "tsc -p tsconfig.json", "lint": "eslint .", diff --git a/src/commands/status.mjs b/src/commands/status.mjs index d531339..003c736 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -23,7 +23,7 @@ import { drift as ruvnetBrainDrift, nightlyAgentPresent as rbNightlyPresent, NIG import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; -import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, credentialGaps, collectIntegrationFacts, MIN_RUFLO_PERSISTED_PROVIDER_VERSION } from '../lib/providers.mjs'; +import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, aqeExternalProviderState, credentialGaps, collectIntegrationFacts, MIN_RUFLO_PERSISTED_PROVIDER_VERSION, EXTERNAL_PROVIDERS_MIN_AQE } from '../lib/providers.mjs'; import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../lib/adapters/lifecycle-registry.mjs'; import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-registry.mjs'; import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; @@ -891,6 +891,30 @@ export async function collect({ } } } + const externalRoot = paths.repoRoot(cwd); + if (externalRoot) { + const externalDisk = readJson(aqeRouterFile(externalRoot), {}) ?? {}; + const external = aqeExternalProviderState(externalDisk, { projectRoot: externalRoot }); + if (external.desired.length || external.stale.length) { + const defaultDrift = external.desired.includes(cfg.providers?.aqeProvider) + && externalDisk.defaultProvider !== cfg.providers.aqeProvider; + if (!external.supported) { + rows.push(row('providers', 'warn', + `external AQE providers admitted but installed agentic-qe needs >=${EXTERNAL_PROVIDERS_MIN_AQE}`)); + } else if (!external.ok || defaultDrift) { + const facts = [ + external.missing.length ? `missing ${external.missing.join(', ')}` : '', + external.drifted.length ? `drifted/conflicting ${external.drifted.join(', ')}` : '', + external.stale.length ? `stale owned ${external.stale.join(', ')}` : '', + defaultDrift ? `default is not ${cfg.providers.aqeProvider}` : '', + ].filter(Boolean).join('; '); + rows.push(row('providers', 'warn', `external AQE projection out of sync (${facts})`, 'sync reconciles only ak-owned entries')); + } else { + rows.push(row('providers', 'ok', + `external AQE providers projected: ${external.desired.join(', ')} (declared/admitted; served inference not yet proven)`)); + } + } + } // A kit.json provider/model entry is registration intent. Ruflo >=3.38.8 // can honor explicit OpenRouter/Ollama provider+model selection, but the // registry does not retarget every agent and it is not execution evidence. diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index bf39dc8..4e78c99 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -6,13 +6,13 @@ // Two independent axes: ruflo host CLIs (claude/codex) and the LLM the routers use. import readline from 'node:readline/promises'; import { - HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, AQE_CHAIN_PROVIDER_TYPES, detectHosts, + HOSTS, API_PROVIDERS, AQE_PROVIDER_TYPES, detectHosts, settingsTarget, isDefault, applyHosts, applyProviders, undoProviders, hostInstallState, hostAuthState, installHost, applyAqeRouter, undoAqeRouter, bothHostsEnabled, DUAL_ROLE_TIP, JUDGE_BIAS_TIP, QE_COURT_TIP, suggestedFallbackFor, seedActivityRoutesIfMultiHost, printActivityRoutingTable, retireCodexMcp, undoCodexMcp, ensureRufloMcpInCodex, undoRufloMcpInCodex, detectAqeProviders, aqeProviderCredential, credentialGaps, fallbackSource, - collectIntegrationFacts, + collectIntegrationFacts, aqeSelectableProviderTypes, aqeSelectableChainProviderTypes, } from '../../lib/providers.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'; @@ -60,7 +60,7 @@ export const options = { /** Billing is the non-obvious axis of the aqe provider list. Three categories, * and claude-code is the ONLY same-vendor subscription alternative to a metered * key (codex/gemini OAuth live on the host axis, not as aqe provider types). */ -export const AQE_BILLING_HINT = 'billing: claude-code = your Claude subscription ($0), ollama/onnx = local ($0), all others = metered API key'; +export const AQE_BILLING_HINT = 'billing: claude-code/codex = host subscription, ollama/onnx = local, built-in APIs = metered; external billing is adapter-declared and unverified'; export const help = `ak host — frontier-host + LLM-provider detection and wiring @@ -107,7 +107,8 @@ Options (pick, all optional — omit for interactive): alternate --aqe-provider set aqe's primary LLM (or 'none' to unset) billing: claude-code = Claude sub ($0), - ollama/onnx = local ($0), all others = metered key + ollama/onnx = local ($0); external billing is + adapter-declared and shown as unverified --aqe-fallback '' ordered aqe chain, e.g. 'claude-code:claude-opus-5; openai:gpt-5.6' (metered providers work too, e.g. add @@ -226,15 +227,15 @@ async function status({ flags, cwd }) { // agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain const ap = cfg.providers.aqeProvider; - console.log(bold('\nagentic-qe LLM provider') + dim(' (AQE_LLM_PROVIDER)')); - console.log(` ${(ap ?? dim('aqe default (unset)')).padEnd(24)} ${dim(`supported: ${AQE_PROVIDER_TYPES.join(', ')}`)}`); + console.log(bold('\nagentic-qe LLM provider') + dim(' (built-ins: env; external: project llm-config)')); + console.log(` ${(ap ?? dim('aqe default (unset)')).padEnd(24)} ${dim(`supported: ${aqeSelectableProviderTypes().join(', ')}`)}`); console.log(` ${dim(AQE_BILLING_HINT)}`); const chain = cfg.providers.aqeFallback ?? []; if (chain.length) { const rendered = chain.map((e) => { const cred = aqeProviderCredential(e.provider); const models = e.models?.length ? `(${e.models.join(',')})` : dim('(no models)'); - return `${e.provider}${models}${cred.present ? '' : yellow(' ⚠ no credential')}`; + return `${e.provider}${models}${cred.known && !cred.present ? yellow(' ⚠ no credential') : ''}`; }).join(' → '); console.log(` ${dim('fallback chain:')} ${rendered} ${dim('· .agentic-qe/llm-config.json')}`); for (const g of credentialGaps(chain)) { @@ -249,10 +250,12 @@ async function status({ flags, cwd }) { // uncredentialed one is displayed as a configured fallback (#54). const creds = detectAqeProviders(); console.log(bold('\naqe provider credentials') + dim(' (keys read from env; never persisted)')); - for (const p of AQE_PROVIDER_TYPES) { + for (const p of aqeSelectableProviderTypes()) { const c = creds[p]; - const state = c.present ? (c.billing === 'local' ? 'local' : c.billing === 'subscription' ? 'subscription' : 'key present') - : `no key ${dim(`(${c.missing.join(', ')})`)}`; + const state = !c.known + ? `admitted · credential not introspectable · billing ${c.billing} (declared/unverified)` + : c.present ? (c.billing === 'local' ? 'local' : c.billing === 'subscription' ? 'subscription' : 'key present') + : `no key ${dim(`(${c.missing.join(', ')})`)}`; console.log(` ${p.padEnd(14)} ${state}${c.source && c.present && c.billing === 'metered' ? dim(` · ${c.source}`) : ''}`); } @@ -446,6 +449,8 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv } async function pick({ flags, cwd, pkgRoot }) { + const aqeProviderTypes = aqeSelectableProviderTypes(); + const aqeChainProviderTypes = aqeSelectableChainProviderTypes(); const cfg = loadKitConfig(); const trustBaseline = structuredClone(cfg); const hosts = await detectHosts(cwd); @@ -506,7 +511,7 @@ async function pick({ flags, cwd, pkgRoot }) { const hAns = (await rl.question(`Enable which ruflo host(s)? (comma-separated) [${dflt.join(',')}]: `)).trim(); enabled = (hAns || dflt.join(',')).split(',').map((s) => s.trim()).filter(Boolean); console.log(dim(` ${AQE_BILLING_HINT}`)); - const aAns = (await rl.question(`agentic-qe primary LLM provider — ${AQE_PROVIDER_TYPES.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); + const aAns = (await rl.question(`agentic-qe primary LLM provider — ${aqeProviderTypes.join('/')} (blank = leave aqe default): `)).trim().toLowerCase(); aqeProvider = aAns ? aAns : null; const suggestion = suggestedFallbackFor(enabled); const fAns = (await rl.question( @@ -548,16 +553,16 @@ async function pick({ flags, cwd, pkgRoot }) { && Object.values(oldPolicy).every((r) => r.provenance === 'seeded'); const reseedForPrimary = primaryHost !== prevPrimary && policyAllSeeded; // validate aqe primary provider - if (aqeProvider && !AQE_PROVIDER_TYPES.includes(aqeProvider)) { + if (aqeProvider && !aqeProviderTypes.includes(aqeProvider)) { const norm = aqeProvider === 'anthropic' ? 'claude' : aqeProvider; - if (AQE_PROVIDER_TYPES.includes(norm)) aqeProvider = norm; - else { warn(`unknown aqe provider '${aqeProvider}' — leaving aqe on its default (valid: ${AQE_PROVIDER_TYPES.join(', ')})`); aqeProvider = null; } + if (aqeProviderTypes.includes(norm)) aqeProvider = norm; + else { warn(`unknown aqe provider '${aqeProvider}' — leaving aqe on its default (valid: ${aqeProviderTypes.join(', ')})`); aqeProvider = null; } } // validate fallback chain providers (chain gate admits codex — #108 phase 3) aqeFallback = aqeFallback .map((e) => ({ ...e, provider: e.provider === 'anthropic' ? 'claude' : e.provider })) .filter((e) => { - const okp = AQE_CHAIN_PROVIDER_TYPES.includes(e.provider); + const okp = aqeChainProviderTypes.includes(e.provider); if (!okp) warn(`dropping unknown fallback provider '${e.provider}'`); else if (!e.models.length) warn(`fallback entry '${e.provider}' has no models — aqe may skip it; add e.g. ${e.provider}:`); return okp; diff --git a/src/commands/x/verify.mjs b/src/commands/x/verify.mjs index 3cd5b18..6f75b87 100644 --- a/src/commands/x/verify.mjs +++ b/src/commands/x/verify.mjs @@ -12,7 +12,7 @@ import { projectAqeDir } from '../../lib/paths.mjs'; import { findMemoryEntry } from '../../lib/project-memory.mjs'; import { projectMemoryEnv } from '../../lib/ruflo-memory.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { HOSTS, collectIntegrationFacts, aqeRouterFile } from '../../lib/providers.mjs'; +import { HOSTS, collectIntegrationFacts, aqeRouterFile, aqeExternalProviderState, EXTERNAL_PROVIDERS_MIN_AQE } from '../../lib/providers.mjs'; import { readJson } from '../../lib/settings.mjs'; import { runHarvest } from '../../lib/harvest.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; @@ -175,6 +175,33 @@ async function verifyProviders() { if (disk?._managedBy === 'agentic-kit' && diskOrder === want) ok(`aqe fallback chain on disk matches kit.json (${want})`); else { fail(`aqe fallback chain drift — disk="${diskOrder}" want="${want}" (run: ak sync)`); good = false; } } + const disk = readJson(aqeRouterFile(process.cwd()), {}) ?? {}; + const external = aqeExternalProviderState(disk, { projectRoot: process.cwd() }); + if (external.desired.length || external.stale.length) { + if (!external.supported) { + fail(`external AQE providers require agentic-qe >=${EXTERNAL_PROVIDERS_MIN_AQE}`); + good = false; + } else if (!external.ok) { + const detail = [ + external.missing.length ? `missing=${external.missing.join(',')}` : '', + external.drifted.length ? `drifted=${external.drifted.join(',')}` : '', + external.stale.length ? `stale=${external.stale.join(',')}` : '', + ].filter(Boolean).join(' '); + fail(`external AQE provider projection is not exact (${detail}; run: ak sync)`); + good = false; + } else { + ok(`external AQE declarations and ownership receipts match (${external.desired.join(', ')})`); + } + if (external.desired.includes(cfg.providers?.aqeProvider)) { + if (disk.defaultProvider === cfg.providers.aqeProvider) { + ok(`external AQE default is project-local (${cfg.providers.aqeProvider})`); + } else { + fail(`external AQE default drift — disk=${disk.defaultProvider ?? '(unset)'} want=${cfg.providers.aqeProvider}`); + good = false; + } + } + warn('external provider verification proves admission + exact AQE projection, not a served model response'); + } return good; } diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 7972ba2..d9ec7ad 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -25,6 +25,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { run, have } from './exec.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; import { installedVersion, cmpVersions } from './versions.mjs'; @@ -38,6 +39,7 @@ import { import { CURRENT_INTEGRATIONS_VERSION, validateEndpoint } from './adapters/config.mjs'; import { opencodeMcpStatus } from './opencode.mjs'; import { codexMcpStatus, rufloCodexMcpStatus } from './mcp.mjs'; +import { admittedAqeProviders, projectedAqeExternalProviders } from './adapters/aqe-provider.mjs'; import { DEFAULT_PRIMARY_HOST, ROUTING_SCHEMA_VERSION, @@ -51,7 +53,7 @@ export const HOSTS = HOST_REGISTRY .map((host) => ({ id: host.id, bin: host.install.bin, pkg: host.install.npmPackage, enableEnv: host.legacy?.enableEnv ?? null, - aqe: host.id === 'claude' ? host.legacy?.aqeProvider : null, + aqe: host.legacy?.aqeProvider ?? (host.id === 'codex' ? 'codex' : null), })); /** API-key LLM providers ruflo's router understands (`ruflo providers`). */ @@ -69,22 +71,40 @@ export const API_PROVIDERS = [ /** Valid `AQE_LLM_PROVIDER` values (grounded: aqe ALL_PROVIDER_TYPES in * dist/shared/llm/router/types.js). aqe force-selects any of these for its QE - * analysis, independent of ruflo's host. `claude-code` = Claude subscription; - * `ollama`/`onnx` = local ($0); the rest metered. Keep in sync with aqe's list. */ + * analysis, independent of ruflo's host. `claude-code`/`codex` = host + * subscriptions; `ollama`/`onnx` = local ($0); the rest metered. Keep in sync + * with aqe's list. */ export const AQE_PROVIDER_TYPES = [ - 'claude-code', 'claude', 'openai', 'gemini', 'openrouter', + 'claude-code', 'codex', 'claude', 'openai', 'gemini', 'openrouter', 'azure-openai', 'bedrock', 'cognitum', 'ollama', 'onnx', ]; -// Chain-rung gate: everything above PLUS `codex`. Grounded in installed aqe +// `codex` is a first-class direct selection and chain rung. Grounded in aqe // 3.13.12 (dist/shared/llm/router/config-store.js): BUILTIN_CONSTRUCTIBLE_ // PROVIDERS includes codex, PROVIDER_ENV_KEYS.codex = [] (ChatGPT-subscription // via the codex binary — no env key), and provider REACHABILITY requires a // fallbackChain entry: aqe's FALLBACK_PRIORITY contains neither codex nor // claude-code, so an enabled codex provider is inert unless chained (#108 -// phase 3). AQE_PROVIDER_TYPES itself stays narrow — it also gates -// AQE_LLM_PROVIDER, whose accepted values are aqe's separate ADR-123 list. -export const AQE_CHAIN_PROVIDER_TYPES = [...AQE_PROVIDER_TYPES, 'codex']; +// phase 3). AQE 3.13.12 also admits external ids registered from llm-config; +// those are exposed lazily by aqeSelectableProviderTypes(). +export const AQE_CHAIN_PROVIDER_TYPES = [...AQE_PROVIDER_TYPES]; + +/** Admitted external provider declarations currently projected by the runtime. + * Re-read on each call so providers registered after this module loaded become + * immediately selectable. */ +export function aqeExternalProviders({ projectRoot = path.resolve(process.cwd()) } = {}) { + return projectedAqeExternalProviders({ projectRoot }) ?? {}; +} + +/** Provider ids accepted by the interactive/non-interactive CLI right now. */ +export function aqeSelectableProviderTypes() { + return [...new Set([...AQE_PROVIDER_TYPES, ...Object.keys(aqeExternalProviders())])]; +} + +/** Provider ids accepted in fallback chains right now. */ +export function aqeSelectableChainProviderTypes() { + return aqeSelectableProviderTypes(); +} /** Credential descriptor per AQE_PROVIDER_TYPES member — the missing half of the * chain validation: a rung whose provider has no usable credential is inert, and @@ -105,11 +125,8 @@ const registryCredential = (id) => { }; export const AQE_PROVIDER_CREDENTIALS = { 'claude-code': { host: 'claude', billing: 'subscription' }, - // `codex` is deliberately absent from AQE_PROVIDER_TYPES (that list also gates - // AQE_LLM_PROVIDER validation, so widening it is a separate behavior change) — - // but routing's AQE_CONSTRUCTIBLE_PROVIDERS includes it and - // policyToAgentOverrides emits `provider: 'codex'`, so it needs a descriptor or - // those projected entries would be unverifiable. + // AQE 3.13.12 promotes `codex` to a built-in, directly selectable provider. + // It is authenticated by the Codex host login rather than an API-key env var. codex: { host: 'codex', billing: 'subscription' }, claude: { keyEnv: ['ANTHROPIC_API_KEY'], billing: 'metered' }, openai: { keyEnv: ['OPENAI_API_KEY'], billing: 'metered' }, @@ -128,7 +145,19 @@ export const AQE_PROVIDER_CREDENTIALS = { * except for the injected env. */ export function aqeProviderCredential(provider, { env = process.env, hostAuth = hostAuthState } = {}) { const d = AQE_PROVIDER_CREDENTIALS[provider]; - if (!d) return { known: false, present: false, billing: 'unknown', source: null, missing: [] }; + if (!d) { + const declaration = aqeExternalProviders()[provider]; + if (!declaration) return { known: false, present: false, billing: 'unknown', source: null, missing: [] }; + // Admission proves the executable declaration was trusted and is current; + // it does not prove the provider's downstream login/account is usable. + return { + known: false, + present: false, + billing: declaration.billingMode ?? 'metered-api', + source: 'admitted external adapter (credential not introspectable)', + missing: [], + }; + } if (d.host) { const auth = hostAuth(d.host, { env }); return { known: true, present: auth.mode !== 'none', billing: d.billing, source: auth.source, missing: auth.mode === 'none' ? [`${d.host} login`] : [] }; @@ -166,7 +195,7 @@ export function credentialGaps(chain = [], { env = process.env, hostAuth } = {}) /** Credential state for every aqe provider type — what `ak x host status` * renders, so a credentialed provider (e.g. openrouter) is never invisible. */ export function detectAqeProviders({ env = process.env } = {}) { - return Object.fromEntries(AQE_PROVIDER_TYPES.map((p) => [p, aqeProviderCredential(p, { env })])); + return Object.fromEntries(aqeSelectableProviderTypes().map((p) => [p, aqeProviderCredential(p, { env })])); } /** Provenance for an aqeFallback entry. A legacy entry written before stamping @@ -391,6 +420,146 @@ export function aqeSupportsAgentOverrides() { return !!v && cmpVersions(v, AGENT_OVERRIDES_MIN_AQE) >= 0; } +// agentic-qe #628 shipped external CLI providers in 3.13.12. Earlier versions +// ignore/reject this surface, so never write a declaration that cannot run. +export const EXTERNAL_PROVIDERS_MIN_AQE = '3.13.12'; +export function aqeSupportsExternalProviders(version = installedVersion('agentic-qe')) { + return !!version && cmpVersions(version, EXTERNAL_PROVIDERS_MIN_AQE) >= 0; +} + +const AQE_OWNERSHIP_KEY = '_agenticKit'; + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])); +} + +function declarationHash(value) { + return createHash('sha256').update(JSON.stringify(stableValue(value))).digest('hex'); +} + +function admittedProviderRecord(id) { + const records = admittedAqeProviders(); + return (Array.isArray(records) ? records : Object.values(records ?? {})) + .find((entry) => (entry.id ?? entry.providerId ?? entry.type) === id) ?? null; +} + +/** Compare the live admitted declarations with the exact values ak previously + * wrote. Foreign entries and user-edited owned entries are never overwritten or + * removed. Returned `active` ids are safe to reference from defaults/chains. */ +function reconcileExternalProviders(existing, desired = aqeExternalProviders()) { + const current = { ...(existing.externalProviders ?? {}) }; + const currentProviders = { ...(existing.providers ?? {}) }; + const priorReceipts = { ...(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}) }; + const receipts = { ...priorReceipts }; + const active = new Set(); + const conflicts = []; + const pruned = []; + const added = []; + const activationsAdded = []; + const activationsPruned = []; + + for (const [id, declaration] of Object.entries(desired)) { + const prior = priorReceipts[id]; + const currentDeclaration = current[id]; + const currentHash = currentDeclaration === undefined ? null : declarationHash(currentDeclaration); + if (currentDeclaration !== undefined && (!prior || currentHash !== prior.writtenHash)) { + conflicts.push(id); + // A changed ak entry becomes user-owned at the point of drift; keeping a + // stale receipt would allow a later sync to delete it accidentally. + delete receipts[id]; + continue; + } + current[id] = declaration; + const record = admittedProviderRecord(id); + const nextReceipt = { + hostId: record?.hostId ?? record?.host ?? record?.manifestId ?? null, + contentHash: record?.contentHash ?? record?.integrity ?? null, + writtenHash: declarationHash(declaration), + }; + if (currentDeclaration === undefined) added.push(id); + + // AQE 3.13.12's MCP router asks whether any providers are enabled BEFORE + // it loads externalProviders (the load is what registers them). A minimal + // providers[id].enabled record breaks that bootstrap cycle. Own only a + // record we created from absence; a user-owned record is preserved and is + // usable only when the user already enabled it explicitly. + const currentActivation = currentProviders[id]; + const priorActivationHash = prior?.providerWrittenHash; + const activationHash = currentActivation === undefined ? null : declarationHash(currentActivation); + if (currentActivation === undefined) { + currentProviders[id] = { enabled: true }; + nextReceipt.providerWrittenHash = declarationHash(currentProviders[id]); + activationsAdded.push(id); + active.add(id); + } else if (currentActivation?.enabled === true) { + if (priorActivationHash && activationHash === priorActivationHash) { + nextReceipt.providerWrittenHash = priorActivationHash; + } + active.add(id); + } else { + conflicts.push(`${id} (providers.${id}.enabled is not true)`); + } + receipts[id] = nextReceipt; + } + + for (const [id, receipt] of Object.entries(priorReceipts)) { + if (id in desired) continue; + const currentDeclaration = current[id]; + if (currentDeclaration !== undefined && declarationHash(currentDeclaration) === receipt.writtenHash) { + delete current[id]; + pruned.push(id); + } + const currentActivation = currentProviders[id]; + if (receipt.providerWrittenHash && currentActivation !== undefined + && declarationHash(currentActivation) === receipt.providerWrittenHash) { + delete currentProviders[id]; + activationsPruned.push(id); + } + // If it changed, relinquish ownership and preserve it. + delete receipts[id]; + } + + return { + externalProviders: current, + providers: currentProviders, + receipts, + active, + conflicts, + pruned, + added, + activationsAdded, + activationsPruned, + }; +} + +/** Honest, non-mutating projection state for status/verify. */ +export function aqeExternalProviderState(disk = {}, { projectRoot = path.resolve(process.cwd()) } = {}) { + const desired = aqeExternalProviders({ projectRoot }); + const receipts = disk[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}; + const missing = []; + const drifted = []; + const stale = []; + for (const [id, declaration] of Object.entries(desired)) { + if (!(id in (disk.externalProviders ?? {}))) missing.push(id); + else if (declarationHash(disk.externalProviders[id]) !== declarationHash(declaration) + || receipts[id]?.writtenHash !== declarationHash(declaration)) drifted.push(id); + const activation = disk.providers?.[id]; + if (activation === undefined) missing.push(`${id} activation`); + else if (activation.enabled !== true + || (receipts[id]?.providerWrittenHash + && declarationHash(activation) !== receipts[id].providerWrittenHash)) { + drifted.push(`${id} activation`); + } + } + for (const id of Object.keys(receipts)) if (!(id in desired)) stale.push(id); + return { + supported: aqeSupportsExternalProviders(), desired: Object.keys(desired), missing, drifted, stale, + ok: aqeSupportsExternalProviders() && missing.length === 0 && drifted.length === 0 && stale.length === 0, + }; +} + export function aqeRouterFile(cwd = process.cwd()) { return path.join(paths.projectAqeDir(cwd), 'llm-config.json'); } @@ -423,6 +592,7 @@ function buildChain(entries) { export function applyAqeRouter(cfg, cwd = process.cwd()) { const chain = cfg.providers?.aqeFallback ?? []; const policy = cfg.routing?.routes ?? {}; + const selectedProvider = cfg.providers?.aqeProvider ?? null; const hasChain = chain.length > 0; const hasPolicy = Object.keys(policy).length > 0; // Same repo-root resolution as settingsTarget — the three scope gates must @@ -431,22 +601,76 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (!root) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; const file = aqeRouterFile(root); const existing = readJson(file, {}) ?? {}; + const desiredExternal = aqeExternalProviders({ projectRoot: root }); + const hasExternal = Object.keys(desiredExternal).length > 0; + const hasOwnedExternal = Object.keys(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}).length > 0; const priorOverrides = existing.agentOverrides ?? {}; - const projected = configuredPolicyToAgentOverrides(policy); + let 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) { + if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal && 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 = []; let wrote = false; + let externalError = null; + let externalActive = new Set(); + + const externalSupported = aqeSupportsExternalProviders(); + if (hasExternal || hasOwnedExternal) { + // A downgrade must remove only unchanged entries we previously wrote, + // plus their dangling references. Keeping declarations that this AQE + // version cannot understand would strand every router startup on drift. + const reconciled = reconcileExternalProviders( + existing, + externalSupported ? desiredExternal : {}, + ); + externalActive = reconciled.active; + if (Object.keys(reconciled.externalProviders).length) next.externalProviders = reconciled.externalProviders; + else delete next.externalProviders; + if (Object.keys(reconciled.providers).length) next.providers = reconciled.providers; + else delete next.providers; + const ownership = { ...(existing[AQE_OWNERSHIP_KEY] ?? {}) }; + if (Object.keys(reconciled.receipts).length) ownership.externalProviders = reconciled.receipts; + else delete ownership.externalProviders; + if (Object.keys(ownership).length) next[AQE_OWNERSHIP_KEY] = ownership; + else delete next[AQE_OWNERSHIP_KEY]; + if (reconciled.conflicts.length) { + externalError = `refused conflicting foreign/user-edited external provider ids: ${reconciled.conflicts.join(', ')}`; + } + details.push(`externalProviders: ${externalActive.size} managed` + + (reconciled.added.length ? ` (${reconciled.added.length} added)` : '') + + (reconciled.pruned.length ? ` (${reconciled.pruned.length} stale owned pruned)` : '') + + (reconciled.activationsAdded.length ? ` (${reconciled.activationsAdded.length} MCP activation added)` : '') + + (reconciled.activationsPruned.length ? ` (${reconciled.activationsPruned.length} stale activation pruned)` : '') + + (reconciled.conflicts.length ? ` (⚠ conflicts preserved: ${reconciled.conflicts.join(', ')})` : '')); + if (hasExternal && !externalSupported) { + externalError = `external providers need agentic-qe >=${EXTERNAL_PROVIDERS_MIN_AQE}`; + details.push(`externalProviders: disabled (${externalError})`); + } + if (reconciled.pruned.includes(next.defaultProvider)) delete next.defaultProvider; + if (next.fallbackChain?.entries) { + next.fallbackChain = { + ...next.fallbackChain, + entries: next.fallbackChain.entries.filter((entry) => !reconciled.pruned.includes(entry.provider)), + }; + if (next.fallbackChain.entries.length === 0) delete next.fallbackChain; + } + wrote = reconciled.added.length > 0 || reconciled.pruned.length > 0 + || reconciled.activationsAdded.length > 0 || reconciled.activationsPruned.length > 0 + || Object.keys(desiredExternal).some((id) => existing.externalProviders?.[id] + && declarationHash(existing.externalProviders[id]) !== declarationHash(desiredExternal[id])); + } + projected = Object.fromEntries(Object.entries(projected).filter(([, entry]) => + !(entry.provider in desiredExternal) || externalActive.has(entry.provider))); let chainError = null; if (hasChain) { - const valid = chain.filter((e) => e?.provider && AQE_CHAIN_PROVIDER_TYPES.includes(e.provider)); + const selectable = new Set(aqeSelectableChainProviderTypes()); + const valid = chain.filter((e) => e?.provider && selectable.has(e.provider) + && (!(e.provider in desiredExternal) || externalActive.has(e.provider))); if (valid.length === 0) { // A bad chain must NOT block the independent agentOverrides projection — the // Activity routing is validated separately. Record it and carry on. @@ -454,8 +678,10 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { details.push(`chain: ⚠ ${chainError}`); } else { next.defaultProvider = cfg.providers.aqeProvider ?? valid[0].provider; - next.providers = { ...(existing.providers ?? {}) }; - for (const e of valid) next.providers[e.provider] = { ...(existing.providers?.[e.provider] ?? {}), enabled: true }; + next.providers = { ...(next.providers ?? existing.providers ?? {}) }; + for (const e of valid) { + if (!(e.provider in desiredExternal)) next.providers[e.provider] = { ...(existing.providers?.[e.provider] ?? {}), enabled: true }; + } next.fallbackChain = buildChain(valid); const emptyModels = valid.filter((e) => !e.models || e.models.length === 0).map((e) => e.provider); // Warn, never refuse: the user may export the key later, and silently @@ -468,7 +694,20 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { } } - if ((hasPolicy || staleOverrides.length) && aqeSupportsAgentOverrides()) { + // External provider selection is project-local by contract: AQE discovers it + // from this file only. managedEnv deliberately never exports an external id + // into project or user host settings. + if (selectedProvider && selectedProvider in desiredExternal) { + if (externalActive.has(selectedProvider)) { + next.defaultProvider = selectedProvider; + details.push(`defaultProvider: ${selectedProvider} (project-local external)`); + wrote = true; + } else { + externalError ??= `external default '${selectedProvider}' is not safely managed`; + } + } + + if ((Object.keys(projected).length || 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) @@ -486,21 +725,25 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (referenced.length) { next.providers = { ...(next.providers ?? existing.providers ?? {}) }; for (const provider of referenced) { - next.providers[provider] = { ...(next.providers[provider] ?? {}), enabled: true }; + if (!(provider in desiredExternal)) next.providers[provider] = { ...(next.providers[provider] ?? {}), enabled: true }; } } details.push(`agentOverrides: ${Object.keys(projected).length} agents` + (referenced.length ? ` (providers enabled: ${referenced.join(', ')})` : '') + (staleOverrides.length ? ` (${staleOverrides.length} stale ak entries pruned)` : '')); wrote = true; - } else if (hasPolicy) { + } else if (hasPolicy && !aqeSupportsAgentOverrides()) { details.push('agentOverrides: skipped (needs agentic-qe ≥ 3.13.1)'); + } else if (hasPolicy && Object.keys(projected).length === 0) { + details.push('agentOverrides: skipped (no safely constructible providers)'); } - if (!wrote) return { ok: !chainError, changed: false, detail: details.join('; ') || 'nothing to apply' }; + wrote ||= JSON.stringify(stableValue(next)) !== JSON.stringify(stableValue(existing)); + if (!wrote) return { ok: !chainError && !externalError, changed: false, detail: details.join('; ') || 'nothing to apply' }; + next._managedBy = AQE_MANAGED_TAG; fs.mkdirSync(path.dirname(file), { recursive: true }); writeJsonWithBackup(file, next); - return { ok: !chainError, changed: true, detail: details.join('; ') }; + return { ok: !chainError && !externalError, changed: true, detail: details.join('; ') }; } /** Reversible teardown of ak's router management. Restores the pre-ak file from diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index 39ae947..aaad54e 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -9,6 +9,7 @@ import { vendorOf } from './qeCourt.mjs'; import { routableHostIds, primaryHostIds, validateActivityHost, effectiveHostRegistry, effectiveRoutableHostIds, } from './adapters/index.mjs'; +import { admittedAqeProviders } from './adapters/aqe-provider.mjs'; // ── Vocabulary ─────────────────────────────────────────────────────────────── // Canonical development activities ak routes (ADR-0002). Array order = display order. @@ -26,6 +27,17 @@ export const AK_ORIGINATED = new Set(['packaging', 'release']); // host or silently projected into AQE's separate provider vocabulary. An // admitted external host (P2, ADR-0031) gets no entry either, same reasoning. export const HOST_PROVIDER = { claude: 'claude-code', codex: 'codex' }; + +/** Live host -> AQE provider mapping. Built-ins are stable; external mappings + * are read from the admitted registry for every call so an adapter loaded after + * this module was imported is selectable without restarting the process. */ +export function aqeProviderForHost(host) { + if (HOST_PROVIDER[host]) return HOST_PROVIDER[host]; + const providers = admittedAqeProviders(); + const records = Array.isArray(providers) ? providers : Object.values(providers ?? {}); + const record = records.find((entry) => (entry.hostId ?? entry.host ?? entry.manifestId) === host); + return record?.id ?? record?.providerId ?? record?.type ?? null; +} // Frozen at import time — built-ins only. Display strings and built-in // listings ONLY (formatModelHelp, model catalogs below): every VALIDATION // path (isRoutableHost, validateRoute, materializeRunPlan) consults the lazy @@ -42,6 +54,16 @@ export const AQE_CONSTRUCTIBLE_PROVIDERS = [ 'openrouter', 'gemini', 'azure-openai', 'bedrock', 'cognitum', ]; +/** Runtime-constructible provider ids, including admitted external CLI + * providers. This is intentionally a function rather than an import-time list. */ +export function aqeConstructibleProviderTypes() { + const records = admittedAqeProviders(); + const external = (Array.isArray(records) ? records : Object.values(records ?? {})) + .map((entry) => entry.id ?? entry.providerId ?? entry.type) + .filter(Boolean); + return [...new Set([...AQE_CONSTRUCTIBLE_PROVIDERS, ...external])]; +} + // Subscription/local providers — the ONLY targets auto-seed may use (ADR-0003 // cost safety: seeding must never route work to a metered provider). export const SUBSCRIPTION_PROVIDERS = new Set(['claude-code', 'codex', 'ollama', 'onnx']); @@ -482,8 +504,8 @@ export function policyToAgentOverrides(policy = {}, { agentMap = AGENT_ACTIVITY_ for (const [agent, act] of Object.entries(agentMap)) { const r = routes[act]; if (!r) continue; - const provider = HOST_PROVIDER[r.host]; - if (!AQE_CONSTRUCTIBLE_PROVIDERS.includes(provider)) continue; + const provider = aqeProviderForHost(r.host); + if (!aqeConstructibleProviderTypes().includes(provider)) continue; overrides[agent] = { provider, model: r.model }; } return overrides; @@ -497,8 +519,8 @@ export function configuredPolicyToAgentOverrides(policy = {}, { agentMap = AGENT 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; + const provider = aqeProviderForHost(route.host); + if (!aqeConstructibleProviderTypes().includes(provider)) continue; overrides[agent] = { provider, model: route.model }; } return overrides; @@ -638,7 +660,7 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = ' export function routedVendors(policy = {}) { const routes = resolveRoutes(policy); return new Set(Object.values(routes) - .map((r) => HOST_PROVIDER[r.host]) + .map((r) => aqeProviderForHost(r.host)) .filter(Boolean) .map((provider) => vendorOf(provider)) .filter(Boolean)); @@ -667,7 +689,7 @@ export function validateRoute(route = {}) { const { host, model } = route; const errs = []; if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${effectiveRoutableHostIds().join('|')})`); - else if (HOST_PROVIDER[host] && !AQE_CONSTRUCTIBLE_PROVIDERS.includes(HOST_PROVIDER[host])) errs.push(`host "${host}" maps to a non-constructible provider`); + else if (aqeProviderForHost(host) && !aqeConstructibleProviderTypes().includes(aqeProviderForHost(host))) errs.push(`host "${host}" maps to a non-constructible provider`); if (model != null && (typeof model !== 'string' || model.trim() === '')) errs.push('model must be a non-empty string'); return errs; } diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs new file mode 100644 index 0000000..1169807 --- /dev/null +++ b/tests/kit/providers-external.test.mjs @@ -0,0 +1,195 @@ +import { afterEach, 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 { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; +import { registerAdmittedAqeProvider, resetAdmittedAqeProviders } from '../../src/lib/adapters/aqe-provider.mjs'; +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; +import { + applyAqeRouter, aqeExternalProviderState, aqeRouterFile, aqeSelectableProviderTypes, managedEnv, +} from '../../src/lib/providers.mjs'; +import { configuredPolicyToAgentOverrides } from '../../src/lib/routing.mjs'; + +const dirs = []; +afterEach(() => { + resetAdmittedAqeProviders(); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tmp(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +function fakeAqe(version) { + const root = tmp('ak-aqe-version-'); + const pkg = path.join(root, 'agentic-qe'); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: 'agentic-qe', version })); + _setGlobalRootForTest(root); +} + +function registerHermes() { + const baseDir = tmp('ak-aqe-hermes-'); + fs.writeFileSync(path.join(baseDir, 'provider.mjs'), 'process.stdin.pipe(process.stdout);\n'); + fs.writeFileSync(path.join(baseDir, 'execution.mjs'), 'process.stdin.pipe(process.stdout);\n'); + const manifest = validateAdapterManifest({ + name: 'hermes', version: '1.0.0', contract: 1, + host: { + id: 'hermes', label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, configProjection: 'ruflo', observability: [], + }, + detection: { bin: 'hermes' }, driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { + command: [process.execPath, 'execution.mjs'], files: ['execution.mjs'], + } } }, + aqe: { provider: { + hook: { command: [process.execPath, 'provider.mjs'], files: ['provider.mjs'] }, + billingMode: 'subscription', models: ['default'], defaultModel: 'default', + } }, + trust: { changes: [] }, + }); + const integrity = hashAdapterContent(manifest, { baseDir }); + registerAdmittedAqeProvider(manifest, { baseDir, integrity, contentHash: integrity.hash }); +} + +function project() { + const dir = tmp('ak-aqe-project-'); + fs.mkdirSync(path.join(dir, '.git')); + return dir; +} + +const cfg = () => ({ + aqe: true, + integrations: { hosts: { claude: true, codex: false } }, + routing: { routes: { testing: { host: 'hermes', model: 'default', provenance: 'user' } } }, + providers: { aqeProvider: 'hermes', aqeFallback: [{ provider: 'hermes', models: ['default'] }] }, +}); + +test('admitted providers become lazily selectable and project host routes', () => { + assert.equal(aqeSelectableProviderTypes().includes('hermes'), false); + registerHermes(); + assert.equal(aqeSelectableProviderTypes().includes('hermes'), true); + assert.deepEqual(configuredPolicyToAgentOverrides(cfg().routing.routes)['qe-test-architect'], { + provider: 'hermes', model: 'default', + }); +}); + +test('AQE 3.13.12 projection writes a project-only default and ownership receipt', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + const result = applyAqeRouter(cfg(), dir); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, true); + assert.equal(disk.defaultProvider, 'hermes'); + assert.equal(disk.externalProviders.hermes.kind, 'cli'); + assert.deepEqual(disk.providers.hermes, { enabled: true }); + assert.match(disk._agenticKit.externalProviders.hermes.writtenHash, /^[a-f0-9]{64}$/); + assert.match(disk._agenticKit.externalProviders.hermes.providerWrittenHash, /^[a-f0-9]{64}$/); + assert.equal(managedEnv(cfg()).AQE_LLM_PROVIDER, undefined, 'external default never leaks into settings env'); +}); + +test('foreign same-id declarations are preserved and refused', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true }); + const foreign = { kind: 'cli', command: ['foreign-provider'] }; + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ externalProviders: { hermes: foreign } })); + const result = applyAqeRouter(cfg(), dir); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, false); + assert.deepEqual(disk.externalProviders.hermes, foreign); + assert.match(result.detail, /conflicts preserved/); +}); + +test('stale owned declarations are pruned but edited declarations become user-owned', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + applyAqeRouter(cfg(), dir); + resetAdmittedAqeProviders(); + let result = applyAqeRouter({ ...cfg(), routing: { routes: {} }, providers: { aqeProvider: null, aqeFallback: [] } }, dir); + let disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.match(result.detail, /stale owned pruned/); + assert.equal(disk.externalProviders, undefined); + assert.equal(disk.providers, undefined, 'unchanged ak-owned MCP activation is pruned'); + assert.equal(disk.defaultProvider, undefined, 'stale ak-owned default is not left dangling'); + + registerHermes(); + applyAqeRouter(cfg(), dir); + disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + disk.externalProviders.hermes.displayName = 'User override'; + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify(disk)); + resetAdmittedAqeProviders(); + result = applyAqeRouter({ ...cfg(), routing: { routes: {} }, providers: { aqeProvider: null, aqeFallback: [] } }, dir); + disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, true); + assert.equal(disk.externalProviders.hermes.displayName, 'User override'); + assert.equal(disk._agenticKit, undefined, 'receipt relinquished after user edit'); +}); + +test('user-owned provider activation is preserved and explicit disablement is refused', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true }); + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ providers: { + hermes: { enabled: true, defaultModel: 'user-model' }, + } })); + let result = applyAqeRouter(cfg(), dir); + let disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, true, result.detail); + assert.deepEqual(disk.providers.hermes, { enabled: true, defaultModel: 'user-model' }); + assert.equal(disk._agenticKit.externalProviders.hermes.providerWrittenHash, undefined, + 'ak does not claim a user-owned activation record'); + + disk.providers.hermes.enabled = false; + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify(disk)); + result = applyAqeRouter(cfg(), dir); + disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, false); + assert.equal(disk.providers.hermes.enabled, false); + assert.match(result.detail, /enabled is not true/); +}); + +test('projection state detects declaration drift and a missing ownership receipt', () => { + fakeAqe('3.13.12'); registerHermes(); + const state = aqeExternalProviderState({ + externalProviders: { hermes: { kind: 'cli', command: ['foreign-provider'] } }, + }); + assert.equal(state.ok, false); + assert.deepEqual(state.drifted, ['hermes']); +}); + +test('external projection refuses AQE versions before 3.13.12', () => { + fakeAqe('3.13.11'); registerHermes(); + const dir = project(); + const result = applyAqeRouter(cfg(), dir); + assert.equal(result.ok, false); + assert.match(result.detail, />=3\.13\.12/); + assert.equal(fs.existsSync(aqeRouterFile(dir)), false); +}); + +test('AQE downgrade prunes only unchanged owned declarations and dangling references', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg(), dir).ok, true); + fakeAqe('3.13.11'); + const result = applyAqeRouter(cfg(), dir); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, false); + assert.match(result.detail, /stale owned pruned/); + assert.equal(disk.externalProviders, undefined); + assert.equal(disk.providers, undefined); + assert.equal(disk.defaultProvider, undefined); + assert.equal(disk.fallbackChain, undefined); + assert.equal(disk._agenticKit, undefined); +}); diff --git a/tests/kit/providers.test.mjs b/tests/kit/providers.test.mjs index 4a34358..45028c4 100644 --- a/tests/kit/providers.test.mjs +++ b/tests/kit/providers.test.mjs @@ -201,7 +201,7 @@ test('managedEnv leaves AQE_LLM_PROVIDER unset when no aqe provider is pinned', }); test('managedEnv writes AQE_LLM_PROVIDER for any supported provider (not just claude-code)', () => { - for (const provider of ['claude-code', 'openai', 'gemini', 'ollama']) { + for (const provider of ['claude-code', 'codex', 'openai', 'gemini', 'ollama']) { const cfg = defaultCfg(); cfg.providers.aqeProvider = provider; assert.equal(managedEnv(cfg).AQE_LLM_PROVIDER, provider, `${provider} wired`); @@ -388,7 +388,7 @@ test('AQE_PROVIDER_TYPES mirrors aqe ALL_PROVIDER_TYPES (incl. local onnx)', () // Guards against drift from aqe's dist/shared/llm/router/types.js. Order- // independent set comparison; the point is coverage, not sequence. const expected = [ - 'claude', 'claude-code', 'openai', 'ollama', 'openrouter', + 'claude', 'claude-code', 'codex', 'openai', 'ollama', 'openrouter', 'gemini', 'azure-openai', 'bedrock', 'cognitum', 'onnx', ]; assert.deepEqual([...AQE_PROVIDER_TYPES].sort(), [...expected].sort()); diff --git a/tests/live/aqe-external-provider-transport.test.mjs b/tests/live/aqe-external-provider-transport.test.mjs new file mode 100644 index 0000000..a9ecd9b --- /dev/null +++ b/tests/live/aqe-external-provider-transport.test.mjs @@ -0,0 +1,307 @@ +// Release proof for Agentic-QE ADR-127 / issue #628. Unlike the unit seams, +// this opt-in live test invokes the installed Agentic-QE CLI and MCP server +// through the exact project-local declaration agentic-kit writes. +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; +import { resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; +import { + resetAdmittedAqeProviders, +} from '../../src/lib/adapters/aqe-provider.mjs'; +import { recordConsent } from '../../src/lib/adapters/consent.mjs'; +import { + adapterGrantsPath, grantCapability, recordTierResult, +} from '../../src/lib/adapters/grants.mjs'; +import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; +import { applyAqeRouter, aqeRouterFile } from '../../src/lib/providers.mjs'; + +const PROVIDER_ID = 'aqe-live-proof'; +const MODEL_ID = 'proof-model'; +const COMPLETION = 'LIVE_AQE_628_OK'; +const AQE_BIN = process.env.AQE_BIN ?? 'aqe'; +const REQUIRED_AQE = [3, 13, 12]; + +function versionTuple(text) { + const match = String(text).match(/(\d+)\.(\d+)\.(\d+)/); + return match ? match.slice(1).map(Number) : null; +} + +function versionAtLeast(actual, required) { + return actual.some((part, index) => part !== required[index] + && part > required[index] && actual.slice(0, index).every((value, prior) => value === required[prior])) + || actual.every((part, index) => part === required[index]); +} + +function manifest() { + return { + name: PROVIDER_ID, + version: '1.0.0', + contract: 1, + host: { + id: PROVIDER_ID, + label: 'AQE live proof provider', + install: { bin: 'node', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, + canBePrimary: false, + canRouteActivities: true, + commandStatusline: false, + transcripts: false, + usage: false, + nativeMcpConfig: false, + nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + }, + detection: { bin: 'node' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { + run: { + hook: { + command: [process.execPath, 'execution-hook.mjs'], + files: ['execution-hook.mjs'], + timeoutMs: 5_000, + }, + }, + }, + aqe: { + provider: { + hook: { + command: [process.execPath, 'aqe-hook.mjs'], + files: ['aqe-hook.mjs'], + timeoutMs: 10_000, + }, + billingMode: 'subscription', + models: [MODEL_ID], + defaultModel: MODEL_ID, + maxConcurrency: 1, + stripEnv: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], + displayName: 'Agentic-kit #628 live proof', + }, + }, + trust: { changes: [] }, + }; +} + +function writeFixture(adapterDir) { + fs.mkdirSync(adapterDir, { recursive: true }); + fs.writeFileSync(path.join(adapterDir, 'execution-hook.mjs'), ` +process.stdin.resume(); +process.stdin.on('end', () => process.stdout.write('OK')); +`); + fs.writeFileSync(path.join(adapterDir, 'aqe-hook.mjs'), ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) prompt += chunk; +const proof = [ + '${COMPLETION}', + 'provider=' + process.env.AK_AQE_PROVIDER, + 'model=' + process.env.AK_AQE_MODEL, + 'cwd=' + process.env.AK_AQE_PROJECT_CWD, + 'prompt-bytes=' + Buffer.byteLength(prompt), +].join('|'); +process.stdout.write([ + "import { describe, it } from 'node:test';", + "import assert from 'node:assert/strict';", + '', + '// ' + proof, + "describe('add', () => {", + " it('adds two numbers', () => assert.equal(1 + 2, 3));", + '});', +].join('\\n')); +`); + const validated = validateAdapterManifest(manifest()); + fs.writeFileSync(path.join(adapterDir, 'manifest.json'), `${JSON.stringify(validated, null, 2)}\n`); + return validated; +} + +function runAqe(args, { cwd, env, input } = {}) { + return spawnSync(AQE_BIN, args, { + cwd, + env, + input, + encoding: 'utf8', + timeout: 90_000, + maxBuffer: 10 * 1024 * 1024, + }); +} + +function parseJson(label, text) { + try { + return JSON.parse(text); + } catch (error) { + assert.fail(`${label} did not return JSON: ${error.message}\nstdout: ${text}`); + } +} + +async function mcpGenerate({ cwd, env }) { + const child = spawn(AQE_BIN, ['mcp'], { + cwd, + env: { ...env, AQE_MEMORY_BACKEND: 'memory' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdoutBuffer = ''; + let stderrTail = ''; + const pending = new Map(); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderrTail = `${stderrTail}${chunk}`.slice(-16_384); + }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdoutBuffer += chunk; + for (;;) { + const newline = stdoutBuffer.indexOf('\n'); + if (newline < 0) break; + const line = stdoutBuffer.slice(0, newline).trim(); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (!line) continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + if (message.id !== undefined && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } + } + }); + + const request = (id, method, params) => new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`AQE MCP ${method} timed out\nstderr: ${stderrTail}`)); + }, 90_000); + pending.set(id, (message) => { + clearTimeout(timer); + resolve(message); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + + try { + const initialized = await request(1, 'initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'agentic-kit-live-proof', version: '1.0.0' }, + }); + assert.equal(initialized.error, undefined, JSON.stringify(initialized.error)); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} })}\n`); + const generation = await request(2, 'tools/call', { + name: 'test_generate_enhanced', + arguments: { + sourceCode: 'export function add(a, b) { return a + b; }', + language: 'javascript', + testType: 'unit', + framework: 'node-test', + aiEnhancement: true, + }, + }); + assert.equal(generation.error, undefined, JSON.stringify(generation.error)); + return { result: generation.result, stderr: stderrTail }; + } finally { + child.stdin.end(); + let exitTimer; + const exited = await Promise.race([ + new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))), + new Promise((resolve) => { + exitTimer = setTimeout(() => resolve(null), 7_000); + exitTimer.unref(); + }), + ]); + clearTimeout(exitTimer); + if (!exited) { + child.kill('SIGTERM'); + await new Promise((resolve) => child.once('exit', resolve)); + } + assert.ok(exited, `AQE MCP did not terminate after stdin EOF\nstderr: ${stderrTail}`); + } +} + +test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { + timeout: 240_000, +}, async (t) => { + const version = runAqe(['--version']); + assert.equal(version.status, 0, `AQE is required for this live proof: ${version.stderr}`); + const actualVersion = versionTuple(version.stdout); + assert.ok(actualVersion, `unrecognized AQE version: ${version.stdout}`); + assert.ok(versionAtLeast(actualVersion, REQUIRED_AQE), + `AQE >=${REQUIRED_AQE.join('.')} required; found ${actualVersion.join('.')}`); + + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-628-live-')); + const projectRoot = path.join(temp, 'project'); + const adapterDir = path.join(projectRoot, 'adapter'); + const xdg = path.join(temp, 'xdg'); + const home = path.join(temp, 'home'); + const priorXdg = process.env.XDG_CONFIG_HOME; + fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); + fs.mkdirSync(home, { recursive: true }); + const validated = writeFixture(adapterDir); + const manifestFile = path.join(adapterDir, 'manifest.json'); + const integrity = hashAdapterContent(validated, { baseDir: adapterDir }); + const configFile = path.join(xdg, 'agentic-kit', 'kit.json'); + const consentFile = path.join(xdg, 'agentic-kit', 'adapter-consent.json'); + const grantsFile = path.join(xdg, 'agentic-kit', 'adapter-grants.json'); + t.after(() => { + if (priorXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = priorXdg; + resetAdmittedAqeProviders(); + resetAdmitted(); + fs.rmSync(temp, { recursive: true, force: true }); + }); + + const cfg = loadKitConfig(path.join(temp, 'missing-kit.json')); + cfg.hostAdapters = [{ name: PROVIDER_ID, source: manifestFile, contract: 1 }]; + cfg.integrations.hosts[PROVIDER_ID] = true; + cfg.providers.aqeProvider = PROVIDER_ID; + cfg.providers.aqeFallback = [{ provider: PROVIDER_ID, models: [MODEL_ID] }]; + saveKitConfig(cfg, configFile); + recordConsent(PROVIDER_ID, integrity.hash, { file: consentFile }); + recordTierResult(PROVIDER_ID, 'aqe-provider', { + hash: integrity.hash, + evidence: 'release proof exercises installed Agentic-QE CLI and MCP transports', + }, { file: grantsFile }); + grantCapability(PROVIDER_ID, 'aqeProvider', { hash: integrity.hash }, { file: grantsFile }); + + process.env.XDG_CONFIG_HOME = xdg; + const bootstrap = await bootstrapHostAdapters({ + cfg, + env: { ...process.env, AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + }); + assert.equal(bootstrap.warnings.length, 0, JSON.stringify(bootstrap.warnings)); + assert.equal(bootstrap.admitted.length, 1); + const projection = applyAqeRouter(cfg, projectRoot); + assert.equal(projection.ok, true, projection.detail); + assert.equal(projection.changed, true, projection.detail); + assert.ok(fs.existsSync(aqeRouterFile(projectRoot))); + const projectedConfig = JSON.parse(fs.readFileSync(aqeRouterFile(projectRoot), 'utf8')); + assert.deepEqual(projectedConfig.providers[PROVIDER_ID], { enabled: true }); + + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: xdg, + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + AQE_CONFIG_ROOT: projectRoot, + AQE_PROJECT_ROOT: projectRoot, + AQE_LLM_PROVIDER: PROVIDER_ID, + }; + const providers = runAqe(['llm', 'providers', '--json'], { cwd: projectRoot, env }); + assert.equal(providers.status, 0, providers.stderr); + const providerList = parseJson('aqe llm providers', providers.stdout); + assert.match(JSON.stringify(providerList), new RegExp(PROVIDER_ID)); + + const mcp = await mcpGenerate({ cwd: projectRoot, env }); + const mcpText = JSON.stringify(mcp.result); + assert.match(mcpText, new RegExp(COMPLETION), mcp.stderr); + assert.match(mcpText, new RegExp(`provider=${PROVIDER_ID}`)); + assert.match(mcpText, new RegExp(`model=${MODEL_ID}`)); + assert.equal(adapterGrantsPath().startsWith(xdg), true); +}); From 4fac025ab8ec777d47eb9662e2315006030e900a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 14:43:43 -0700 Subject: [PATCH 03/21] docs: describe external AQE provider lifecycle --- MAINTAINER.md | 1 + README.md | 9 +- docs/ADAPTER-CONTRACT-DOSSIER.html | 24 ++-- docs/AUTHORING-HOST-ADAPTERS.md | 58 +++++++-- docs/HOST-ADAPTER-FREEZE-CHECKLIST.md | 6 +- docs/HOST-EXTENSIBILITY-EXPLAINER.html | 34 +++--- docs/HOST-PROVIDER-CONSISTENCY.html | 12 +- docs/HOST-SUPPORT.md | 47 ++++---- docs/PROVIDERS.md | 114 +++++++++++++++--- docs/adr/0029-host-adapter-extension-point.md | 50 +++++--- ...bility-graduation-and-upstream-requests.md | 45 ++++--- docs/adr/README.md | 19 +-- docs/ddd/routing-and-orchestration.md | 4 +- 13 files changed, 287 insertions(+), 136 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index 770ac43..9f85ac2 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -98,6 +98,7 @@ docs/ `docs/adr/0015-managed-codex-native-statusline.md`, `docs/adr/0032-model-lifecycle-intelligence.md`, `docs/adr/0033-retire-codex-mcp-and-bound-qe-court-participants.md`, +`tests/live/aqe-external-provider-transport.test.mjs`, `tests/live/qe-court-participant-transport.test.mjs`, and `docs/ddd/model-lifecycle-intelligence.md`. Generated workspace state under the shipped source trees is explicitly excluded. Nothing else ships — verify with diff --git a/README.md b/README.md index c495192..32aafe8 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ or catalogue evidence. A **binding** connects a host to a provider through a sup and transport. Those axes do not imply one another. OpenRouter is a provider behind a host, not another host. -Ollama can have independent bindings through Claude and Codex. OpenCode is an opt-in, explicitly -routable host through `ak run`, but it is never a primary host or AQE provider. Provider, model, +Ollama can have independent bindings through Claude and Codex. Built-in OpenCode is an opt-in, +explicitly routable host through `ak run`, but it is not primary and has no built-in AQE provider +identity. A separate external adapter may earn its own AQE 3.13.12+ identity. Provider, model, and billing claims state whether they are observed, configured, inferred, or unknown. Design record: [docs/adr/0016-capability-driven-integration-adapters.md](docs/adr/0016-capability-driven-integration-adapters.md). @@ -124,8 +125,8 @@ command always works.) | Verb | What it does | | ------ | -------------- | | **setup** | Installs/updates ruflo + agentic-qe + the **agentdb** CLI globally (handling npm ≥11.17's `allow-scripts` so natives build; agentdb is pinned to ruflo's bundled version so the shared learning store stays coherent), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP — a ~2 GB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into the machine-wide guidance files (`~/.claude/CLAUDE.md`, plus `~/.codex/AGENTS.md` on codex machines), offers one-time MCP registration (user scope, with a tool-family picker), and — inside a repo — initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** store→disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` entry in the current folder; without one it's skipped with a note. `--project` forces the same project setup in the current directory (e.g. a not-yet-`git init`-ed folder); it does not locate an ancestor repository. Project initialization runs `ruflo init --full --force` and can replace existing agent configuration, so read the [setup scope and project mutation contract](docs/SETUP.md) before using it on an existing project. `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. `--codex` enables + installs the Codex host during setup (ambidextrous dual-host mode; both hosts then run at once), and `--primary-host claude\|codex` picks which host leads (codex implies `--codex`). | -| **status** | Per-subsystem ✓/⚠/✗ (versions, the kit's own version, **ruvnet-brain** (present + release drift, or "not installed"), natives (agentdb copies **and** ruflo's own memory runtime — the one `npx ruflo memory` loads — load-tested for a native better-sqlite3, not just the agentdb dirs), **memory-pin** (warns when `CLAUDE_FLOW_DB_PATH` points off the live DB), security, learning, aqe/RVF, **agentdb** (CLI present + coherent with ruflo's bundled version, or a store-skew warning), MCP, **hosts** (claude/codex/opencode version + install method; the Claude/Codex **primary** marked and failed when absent), **providers** (host wiring + aqe fallback chain, or "drifted"/claude-only default), **routing** (the persisted activity host+model policy; only Claude/Codex routes project into AQE), daemons, guidance-file blocks (`~/.claude/CLAUDE.md`, project `AGENTS.md`, and `~/.codex/AGENTS.md` on codex machines), statusline), each drift row naming what `sync` would do about it — plus a **health-history** line that flags regressions since the last sync (learning shrank, native slots dropped, drift/security backslid). | -| **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex/opencode) that's entirely absent — never touching an external (mise/brew/native) install — and **re-applies provider wiring** (the `ENABLE_*` host env, OpenCode's native configuration, the aqe fallback chain, and ruflo API providers) whenever it has drifted — and, on a dual-host project, **seeds/heals the Claude/Codex default routing policy** (materializing eligible routes into agentic-qe's `agentOverrides`, e.g. after an aqe upgrade first makes it eligible). It also **installs/repins the standalone `agentdb` CLI** to ruflo's bundled version (keeping the shared cognitive store coherent) and appends a **health-history snapshot** so `status` can flag regressions across syncs. It also **re-runs the RuvNet Brain installer** to pull the latest release when the on-disk KB has drifted (or installs it if absent, when enabled). It also **self-updates the kit**: when a newer `@pacphi/agentic-kit` exists it installs it as the *last* step (the new code applies from the next `ak` run, never mid-sync). Prerelease installs (`4.0.0-alpha.*`) track the `next` npm dist-tag as well as `latest`, so alphas see their successors; stable installs only ever follow `latest`. `--no-upgrade` skips the self-update along with the package upgrades. Model refresh/diff/plan findings remain advisory: sync never contacts a model catalogue or applies a model plan. | +| **status** | Per-subsystem ✓/⚠/✗ (versions, the kit's own version, **ruvnet-brain** (present + release drift, or "not installed"), natives (agentdb copies **and** ruflo's own memory runtime — the one `npx ruflo memory` loads — load-tested for a native better-sqlite3, not just the agentdb dirs), **memory-pin** (warns when `CLAUDE_FLOW_DB_PATH` points off the live DB), security, learning, aqe/RVF, **agentdb** (CLI present + coherent with ruflo's bundled version, or a store-skew warning), MCP, **hosts** (claude/codex/opencode version + install method; the Claude/Codex **primary** marked and failed when absent), **providers** (host wiring, AQE fallback chain, and exact admitted external-provider projection/ownership receipts), **routing** (the persisted activity host+model policy; Claude, Codex, and an admitted `aqeProvider` route can project into AQE), daemons, guidance-file blocks (`~/.claude/CLAUDE.md`, project `AGENTS.md`, and `~/.codex/AGENTS.md` on codex machines), statusline), each drift row naming what `sync` would do about it — plus a **health-history** line that flags regressions since the last sync (learning shrank, native slots dropped, drift/security backslid). | +| **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex/opencode) that's entirely absent — never touching an external (mise/brew/native) install — and **re-applies provider wiring** (the `ENABLE_*` host env, OpenCode's native configuration, the AQE default/fallback/agent overrides, admitted Agentic-QE 3.13.12+ `externalProviders`, and ruflo API providers) whenever it has drifted. External-provider reconciliation preserves foreign entries, refuses same-id conflicts, and prunes only entries whose exact value still matches an agentic-kit ownership receipt. On a dual-host project, sync also **seeds/heals the Claude/Codex default routing policy**. It installs/repins the standalone `agentdb` CLI to ruflo's bundled version, appends a health-history snapshot, refreshes RuvNet Brain when enabled, and self-updates the kit last. `--no-upgrade` skips self-update and package upgrades. Model refresh/diff/plan findings remain advisory. | | **dashboard** | Opens an observation-only local web dashboard (`127.0.0.1:7431`, localhost-only, never detaches) with five primary areas: **About · Overview · Usage · Observability · System**. About is a plain-words directory of everything the kit installed and why (the same content as `ak about`); Overview covers readiness, hosts & routing, providers, runtime, and machine-wide intelligence; Usage turns local Claude/Codex/OpenCode transcripts into scorecards, limits, findings, per-session detail, and privacy-projected model lifecycle evidence. The Models inventory is lazy, paged, filterable, sortable, and internally scrollable; source-proven public catalogue names remain readable while private deployment identity stays keyed. Observability follows live and historical agent activity with masked evidence; System reports what the stack occupies on the machine (the same data as `ak system`). Deep links are hierarchical (`#about`, `#overview/summary`, `#usage/models`, `#usage/`, `#observability/live`, `#system/storage`). No dashboard action mutates agents or repositories; ruflo and agentic-qe stores are opt-in through repeatable `--live-source 'surface=path'`. The page is self-contained, offline-first, and protected by a per-session token. Full navigation, keyboard behavior, and per-view semantics: [Dashboard guide](docs/DASHBOARD.md) and [Observability guide](docs/OBSERVABILITY.md). **Auto-opens your browser** (`--no-open` for headless/SSH); `--port N` changes the port. Stop with Ctrl-C. (Also available as `ak x dashboard`.) | | **usage** | Reads provider-account analytics from local cache (`ak usage status`) or performs one explicit OpenRouter management-API refresh (`ak usage refresh openrouter`). Refresh requires `OPENROUTER_MANAGEMENT_KEY`, writes a credential-free mode-`0600` cache, and discards endpoint/user/key/session identifiers. `status` and dashboard reads make no network request. OpenRouter account rows have no grounded host/session/project correlation and are never merged into transcript totals. | | **models** | Builds a private, host-scoped model inventory from Claude, Codex, OpenCode, Ollama, bounded local usage evidence, and a dated bundled record of Anthropic's public model/lifecycle facts. `status`, `diff`, `explain`, and `plan` are cache-only and read-only; `refresh --online` is the sole online-catalogue boundary. Public facts never imply account or OpenRouter routability. Swap plans enumerate routes plus Agentic QE/Ruflo consumers and print a copyable canonical action without executing it. The CLI exposes exact local evidence deliberately; the Dashboard exposes source-proven public catalogue identity and pseudonymizes private identifiers. See [Model lifecycle intelligence](docs/MODELS.md). | diff --git a/docs/ADAPTER-CONTRACT-DOSSIER.html b/docs/ADAPTER-CONTRACT-DOSSIER.html index ca0dd2e..5298947 100644 --- a/docs/ADAPTER-CONTRACT-DOSSIER.html +++ b/docs/ADAPTER-CONTRACT-DOSSIER.html @@ -205,8 +205,9 @@

2 · Evidence base

Every load-bearing adapter fact holds unchanged. New: a native ACP server (third driving surface), a --tui statusline, three-tier hooks with consent allowlists. PyPI is stale; the npm bridge now ships three bins. - agentic-qeagentic-qe 3.13.10 (local = latest published) - Provider set closed at every layer; its real plugin system reaches QE domains only; the + agentic-qeagentic-qe 3.13.12 (external-provider contract from ADR-127) + Built-in providers remain upstream-owned, while admitted CLI subprocess providers can + now be projected through externalProviders; plugin domains remain separate; the vendorOf mirror is in sync; quality-gate anchors are a content-authoring task, not a flag. @@ -270,13 +271,14 @@

3.5 Conformance means black-box subprocess tests against the installed layou

4 · Hard constraints the design inherits

    -
  • AQE's provider set is closed at every layer — 11 declared types, 10 - constructible (onnx is declared-but-unconstructible); its real plugin system - (aqe plugin install) reaches QE domains only. No adapter may ever project into - any AQE surface. Phase 1's asymmetry decision already encodes this.
  • +
  • AQE's built-in provider set remains upstream-owned — 3.13.12 adds the + ADR-127 externalProviders contract for CLI subprocess providers without making + plugin domains into provider extensions. Agentic-kit may project only an admitted, hash-pinned + adapter through that public contract; it must not mutate AQE's built-in provider definitions.
  • Two AQE "host" surfaces exist and must not be conflated: LLM-execution - binding (spawns claude/codex; closed) vs. platform/MCP installers - (8 hardcoded; closed). ak's extension point extends neither — it extends ak's own host axis.
  • + binding (built-ins plus declared external CLI providers) vs. platform/MCP installers + (8 hardcoded). Agentic-kit's extension point projects only into the former after admission; + it does not extend AQE's platform installer catalog.
  • Driving surfaces are plural now. Hermes ships a native ACP (Agent Client Protocol) server alongside CLI oneshot — the contract must express how a host is driven (cli-subprocess, ACP, MCP) as declared capability data, not assume @@ -607,9 +609,11 @@

    An adapter is data plus consented subprocess hooks. No third-party code runs consent time, re-consented on change, executed with ak supervising (timeout, captured output, independent verify after).

  • Capability caps are structural, not runtime: the adapter manifest - schema cannot express canBePrimary, aqeProvider, or + schema cannot self-claim canBePrimary, host.legacy.aqeProvider, or managed-statusline claims — a cap enforced by shape is stronger than any runtime check, and - is precisely where ruflo's honor-system caps failed.
  • + is precisely where ruflo's honor-system caps failed. Since Agentic-QE 3.13.12, separate + aqe.provider candidate data may be declared, but it gains no authority until a real + aqe-provider tier and explicit hash-pinned grant both exist.
  • Fail-closed admission, isolated failure: a manifest that fails validation, hash check, or contract version is refused with a message naming adapter and reason; a refused or broken adapter never affects built-ins or other adapters (the F-01 merge diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md index 3292ca1..6c8bce7 100644 --- a/docs/AUTHORING-HOST-ADAPTERS.md +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -101,6 +101,7 @@ Field by field: | `detection` | How `ak` proves your CLI is present: the binary, the version arguments, and a regular-expression source for the version. | | `driving.surfaces` | Declare `cli-subprocess`. See below. | | `lifecycle` / `execution` | Your hooks ([section 3](#3-write-the-hooks)). Both are optional; a manifest with neither is a pure description. A file-backed hook must list its adapter-owned files in `hook.files`. | +| `aqe.provider` | Optional Agentic-QE 3.13.12+ external-provider candidate. Requires `cli-subprocess`, activity routing, and both `execution.run.hook` and `aqe.provider.hook`; activation still requires the passed tier and grant. | > **Capabilities describe what the adapter *delivers through `ak`*, not what your host can do in > principle.** A real Hermes adapter's first draft declared `nativeMcpConfig: true` and @@ -127,13 +128,46 @@ one with a working implementation.** Declare an `execution` block without `cli-s `cap-command-statusline`). You cannot write down the claim; you **earn** the capability through a conformance tier plus an explicit maintainer grant, recorded outside your manifest entirely (ADR-0031 §1). -- `host.legacy.aqeProvider` must be absent — `cap-aqe-provider` refuses any value. This one is not - earnable at all through `ak`: agentic-qe's provider set is a closed upstream enumeration, so being - an AQE provider type is upstream's to grant. See [section 9](#9-the-freeze-and-why-you-matter). +- `host.legacy.aqeProvider` must be absent — `cap-aqe-provider` refuses any value. Candidate data + belongs under `aqe.provider`; it becomes live only after the real `aqe-provider` tier passes at + the current content hash and a maintainer grants `aqeProvider`. Self-declaration is the attack surface, so it stays closed permanently. The *capability* is a ladder; the *declaration* is a wall. +### The AQE provider hook + +Agentic-QE 3.13.12 added `externalProviders`. A candidate adapter may describe the safe subset that +agentic-kit projects: + +```json +{ + "aqe": { + "provider": { + "hook": { + "command": ["node", "aqe-provider.mjs"], + "files": ["aqe-provider.mjs"], + "timeoutMs": 180000, + "passEnv": ["HERMES_API_KEY"] + }, + "billingMode": "subscription", + "models": ["default", "fast"], + "defaultModel": "default", + "maxConcurrency": 2, + "stripEnv": ["OPENAI_API_KEY"], + "displayName": "Hermes subscription" + } + } +} +``` + +The provider id is always `host.id`; adapters cannot choose a built-in/reserved id. The hook reads +one prompt from stdin and writes only the completion to stdout. Model and project identity arrive in +protected `AK_AQE_*` variables; only names in `passEnv` cross from the parent environment. Do not +print partial output before success: the bridge intentionally suppresses stdout for refusal, auth, +timeout, and failure because AQE treats non-empty stdout as a completion even on a non-zero exit. +Billing mode is declared provenance, not a verified charge or vendor fact. + ### One structural coupling worth knowing Declaring `execution` while `canRouteActivities` is `false` is a contradiction the schema refuses @@ -309,6 +343,7 @@ host adapter conformance — acme (56fa107674d2) admission passed host id 'acme', contract 1 session-driving skipped not declared — nothing to prove activity-routing passed registered for 'acme' +aqe-provider passed admitted provider hook returned the expected bounded probe primary-eligible passed 'acme' completed a direct (non-escalated) run to a succeeded result, and… statusline gated ak-local: awaiting maintainer grant for 'commandStatusline' (not an… ``` @@ -322,6 +357,7 @@ What each tier means for you: | `admission` | Your manifest validates, admits through the fail-closed gate, joins the registry, and your `detect` hook runs as a real subprocess. | **Can genuinely pass.** A failure here short-circuits every downstream tier, so nothing gets laundered. | | `session-driving` | Gates `canDriveSession`. | `skipped` if you don't declare it. **`gated` if you do** — `ak` has no external session-driving path, and being a native ruflo backend is upstream-owned. | | `activity-routing` | Gates `canRouteActivities`. A real one-worker `ak run` routed to your host returns a succeeded `WorkerResult`. | **Can genuinely pass.** | +| `aqe-provider` | Earns `aqeProvider`. The harness runs the real admitted stdin/stdout hook with its declared model and requires the bounded probe response. | **Can genuinely pass** when `aqe.provider` is declared; then a maintainer may grant `aqeProvider`. | | `primary-eligible` | Earns `canBePrimary`. Your host anchors a real run *and* receives a genuine ADR-0019 escalation onto itself — a second real subprocess. | **Can genuinely pass**, with no pre-existing grant. | | `statusline` | Earns `commandStatusline`. | **`gated`.** There is no admitted-host footer-render path yet, so even a granted capability has nothing real to drive. | @@ -399,19 +435,23 @@ Be clear-eyed about this, because the machinery is ahead of its consumers: admitted host's command-backed footer. The grant records what you earned; nothing renders it yet. Both gaps are disclosed at grant time, and both are `ak`-local work rather than upstream ceilings — -they will light up without needing anything from you. What works end-to-end today is -`activity-routing`: a real `ak run` worker on your host, supervised, with a structured result. +they will light up without needing anything from you. What works end-to-end today includes +`activity-routing` and, on Agentic-QE 3.13.12+, an earned `aqeProvider`: a real provider hook, +project-only declaration/default, fallback and activity-route projection, plus precise ownership +receipts. `ak x verify providers` proves that configuration but honestly does not claim a served +model response; release proof must also exercise a fresh AQE CLI/MCP process. ## 9. The freeze, and why you matter -Two ceilings are genuinely not `ak`'s to lift: +One ceiling is genuinely not `ak`'s to lift: - **Native ruflo backend** (`session-driving`). ruflo's backend enablement is per-host and defined inside ruflo, not through an outside registration surface. *Interim:* your host runs through `ak`'s own supervised execution — just not as a ruflo-native backend. -- **agentic-qe provider type** (`aqeProvider`). agentic-qe's provider set is a closed upstream - enumeration, extended only by an upstream code change. *Interim:* quality still runs through the - model provider underneath your host, so QE isn't blocked — only your host's own AQE identity is. + +Agentic-QE provider identity is no longer an upstream ceiling on AQE 3.13.12+. Issue #628 supplied +the external-provider registry. The remaining boundary is local and evidence-based: candidate data, +a passed `aqe-provider` tier, explicit grant, and current content hash. Neither is faked, hidden, or shimmed. Each gets a tracked capability request and an honest interim behaviour ([ADR-0031 §4](adr/0031-capability-graduation-and-upstream-requests.md)). diff --git a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md index d49892e..5260b61 100644 --- a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md +++ b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md @@ -79,8 +79,10 @@ design and are recorded as gated, not as failures: - `session-driving` until ruflo ships a backend-registration surface (filed: [ruvnet/ruflo#3046](https://github.com/ruvnet/ruflo/issues/3046)). -- An `aqeProvider` identity until agentic-qe ships a provider-plugin API (filed: - [proffesor-for-testing/agentic-qe#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628)). +- `aqeProvider` is no longer an upstream freeze ceiling: Agentic-QE 3.13.12 shipped + `externalProviders` for [#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628). + An adapter still must declare `aqe.provider`, pass the real `aqe-provider` tier at its current + content hash, and receive an explicit `aqeProvider` grant. - `statusline` runtime rendering (no `ak` render surface for a third-party TUI yet). - Grant *consumption* last-mile: selecting an external host as primary, and a `commandStatusline` runtime reader. diff --git a/docs/HOST-EXTENSIBILITY-EXPLAINER.html b/docs/HOST-EXTENSIBILITY-EXPLAINER.html index f079c33..0e1bf9f 100644 --- a/docs/HOST-EXTENSIBILITY-EXPLAINER.html +++ b/docs/HOST-EXTENSIBILITY-EXPLAINER.html @@ -471,7 +471,7 @@

    What the kit guarantees about an external host

    - + @@ -960,9 +960,9 @@

    8 · Some ceilings aren't ours to lift — the upstream path agentic-qe — quality substrate test gen · coverage · quality court - the provider list is a closed upstream - enum — a host can't self-add as one - ask: a provider-plugin API + 3.13.12 ships externalProviders + candidate → real tier → explicit grant + #628 satisfied · integration live @@ -993,8 +993,8 @@

    Which layer owns the blocker?

    - - + + @@ -1042,8 +1042,9 @@

    What it doesn't — and who owns it

    supervised host.
  • agentic-qe tools/skills aren't projected in (unlike Claude & Codex) — ak's choice; ak could run aqe's OpenCode installer and doesn't yet.
  • -
  • It isn't an AQE provider type (analysis can't run on it) — - upstream; agentic-qe's provider list is a closed enum.
  • +
  • Its built-in descriptor has no AQE provider identity — + a separate adapter may earn its own id through Agentic-QE 3.13.12+; + host name alone proves nothing.
  • No command status-line footer, no quota surface, no cross-host bridgeby design.
  • @@ -1053,12 +1054,10 @@

    What it doesn't — and who owns it

    The pattern, in one host

    OpenCode is a first-class ruflo + brain host, a - supervised (never-leading) execution host, and largely outside the - agentic-qe layer — and that last gap splits cleanly the way - section 8 describes: "no aqe tools projected in" is ak's to - close (a normal kit change), while "can't be an AQE provider type" is an upstream request - to agentic-qe. That's the entire model — host layer, lead vs. supervised, and the ak-vs-upstream - split — visible in a single assistant available today.

    + supervised (never-leading) execution host, and its built-in descriptor has no AQE + provider identity. A separately admitted adapter can now earn its own external identity through + Agentic-QE 3.13.12's supported registry. That does not infer provider, billing, or vendor from the + OpenCode host name.


    @@ -1067,9 +1066,10 @@

    What it doesn't — and who owns it

    stance, narrowly — nothing under a manifest is ever loaded into the kit's process). The graduation ladder — experimental external adapter, earned capabilities via conformance tiers, promotion to built-in, and the upstream capability-request path to ruflo and agentic-qe — is ADR-0031, accepted as -a governance decision with its machinery staged and its own implementation-status table. Upstream facts (agentic-qe's closed provider -enum; ruflo's ENABLE_* backend model) are grounded in a source-cited research sweep of -agentic-qe@3.13.10 and ruvnet/ruflo@45e65b5. Hermes and Gemini CLI are named +a governance decision with its machinery staged and its own implementation-status table. The AQE +fact was historical for agentic-qe@3.13.10; 3.13.12 satisfied #628 on 2026-08-26. +Ruflo's ENABLE_* backend model remains grounded against +ruvnet/ruflo@45e65b5. Hermes and Gemini CLI are named here as illustrative candidates for the external door, not as hosts the kit supports today.

    diff --git a/docs/HOST-PROVIDER-CONSISTENCY.html b/docs/HOST-PROVIDER-CONSISTENCY.html index 44025a9..17777af 100644 --- a/docs/HOST-PROVIDER-CONSISTENCY.html +++ b/docs/HOST-PROVIDER-CONSISTENCY.html @@ -330,16 +330,18 @@

    2 · The de-facto host tiers

    - + - +
    QuestionBuilt-in hostExternal adapter
    Can it lead a session (be primary)?yes*not by self-declaring — earned only
    Can it claim a billing / quota provider?yes*not by self-declaring
    Can it claim a billing / quota provider?yes*candidate data only; tier + grant required
    Can it claim a custom status line?yes*not by self-declaring
    Does any of its code run inside the kit?it is the kitno — subprocess only
    Can it install software silently?only disclosed stepsno — may not even name a package
    agentic-kit The kit's to fix — a normal kit PR. This is the on-ramp work, no one to wait on.
    Host wants to be a recognized AQE provider typeagentic-qeUpstream — the provider list is a closed enum. Request a provider-plugin API. Interim: QE works through the model provider underneath the host, so quality isn't blocked, only the host's own AQE identity is.
    agentic-kit + Agentic-QE 3.13.12+#628 supplied externalProviders. Declare aqe.provider, pass the real aqe-provider tier, then receive an explicit hash-pinned grant. Billing remains declared/unverified; vendor credit requires independent evidence.
    Host wants to be a native ruflo backend (drive the loop, an ENABLE_* target) ruflo Upstream — backends are defined in ruflo. Request a documented backend-registration path. Interim: the host runs through the kit's own supervised execution, just not as a ruflo-native backend.
    Primary, AQE provider, statusline, vendor-diversity credit, usage/quota/live-session attribution
    External execution host proposedExternal execution host experimental hermes (ADR-0030) Route activities via an externally maintained adapter; host-declared usage sidecarEverything tier 2 cannot, plus no interceptable permission event — hermes -z auto-approves by its own headless contract (disclosed, verified)Cannot self-claim primary/AQE/statusline authority. Those are earned by tier + grant; AQE provider projection is live on 3.13.12+. No interceptable permission event — hermes -z auto-approves by its own headless contract (disclosed, verified)

    - ADR-0020 already mandates tier 2's shape for OpenCode ("explicit, supervised, non-primary, - outside AQE provider routing, absent from vendor-diversity facts"). ADR-0029's capability - caps reproduce that shape for external hosts — tested policy, not new policy. What is + ADR-0020 already mandates tier 2's shape for built-in OpenCode ("explicit, supervised, + non-primary, outside AQE provider routing, absent from vendor-diversity facts"). ADR-0029's + self-claim caps reproduce that safe starting point for external hosts. The 2026-08-26 + Agentic-QE #628 integration adds an earned path: aqe.provider candidate, real + conformance tier, explicit hash-pinned grant. What is missing is the vocabulary: no surface tells the user which tier a host is in or why a capability is absent (see D-2).

    diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index 00bc601..3f5d119 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -14,13 +14,13 @@ that extend this set with a host not shipped in-tree — see external host picks up the same capability-driven treatment described here, but it is not one of the three built-ins this reference compares. It can never **self-declare** primary-host, AQE-provider, or status-line status — that ban is -permanent — while `canBePrimary` and `commandStatusline` are **earnable** through -a passed conformance tier plus an explicit maintainer grant. `aqeProvider` stays -upstream-owned and is never `ak`-grantable. See +permanent — while `canBePrimary`, `aqeProvider`, and `commandStatusline` are +**earnable** through a passed conformance tier plus an explicit maintainer grant. +The AQE path requires Agentic-QE 3.13.12 or newer. See [External host adapters](#external-host-adapters) below for what a grant does and does not buy today. -Evidence cutoff: **2026-08-04**. The comparison was checked against agentic-kit +Evidence cutoff: **2026-08-26**. The comparison was checked against agentic-kit `4.0.0-alpha.36`, Ruflo `3.34.0`, agentic-qe `3.13.x`, RuvNet Brain `4.0.7`, Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and upstream behavior changes quickly; open issues below are a risk snapshot, not a @@ -54,7 +54,7 @@ served it. See [Providers and hosts](PROVIDERS.md) for those axes in detail. | `ak run` execution | Native CLI adapter | Native CLI adapter | Managed supervised-server adapter | | Automatic activity routes | Yes | Yes | No; explicit routes only | | Ruflo support | Reference/native surface | Strong, with integration and parity gaps | Managed compatibility layer | -| AQE support | Default and fullest path | Strong platform path; one direct-provider gap in `ak` | Upstream platform assets, not an AQE inference provider | +| AQE support | Default and fullest path | Strong platform path; direct provider supported | Upstream platform assets; no built-in OpenCode provider | | RuvNet Brain | Native plugin, hooks, MCP, console | Native plugin, hooks, MCP, skills | Managed search MCP and guidance; no native Brain plugin | | Managed command status line | Yes | No; Codex's native built-in fields only | No | | Local transcript analytics | Yes | Yes | Yes | @@ -144,23 +144,22 @@ Current cross-host Ruflo risks include: | Agents and skills | Native/default assets | Native Codex-compatible assets | Upstream OpenCode agent/skill assets when that platform is initialized | | MCP server | Supported | Supported | Supported upstream | | Subscription inference provider | `claude-code` | AQE upstream includes `codex` | None | -| Direct `ak --aqe-provider` selection | `claude-code` | Not currently accepted by agentic-kit | Not applicable | +| Direct `ak --aqe-provider` selection | `claude-code` | `codex` | No built-in id; an admitted external adapter may earn its own id | | Activity `agentOverrides` | Yes | Yes | No | | Default route projection | Yes | Yes | No | | QE-Court routed seat | Full routed role | Supported, with the integrated stall risk below | May call QE tools, but cannot be an AQE provider-backed seat | | Subscription-provider embeddings | Not supported | Not supported | Not applicable | The OpenCode boundary is precise: AQE can provision OpenCode agents, skills, MCP, -and permissions, but OpenCode is not an AQE LLM-provider type. Agentic-kit -therefore does not infer an AQE provider from an OpenCode host/model route and -does not write that route into `agentOverrides`. +and permissions, but OpenCode is not a built-in AQE LLM-provider type. Agentic-kit +therefore does not infer an AQE provider from the built-in OpenCode host/model route. +An independently admitted adapter may declare a separate candidate under its own +`host.id`, pass the `aqe-provider` tier, and receive an `aqeProvider` grant; that is +earned provider support, not inference from the OpenCode name. -Codex has the reverse limitation. Current AQE includes a subscription-backed -`codex` provider, and agentic-kit can project Codex activity routes into AQE. -However, agentic-kit's direct provider allow-list still rejects -`ak host pick --aqe-provider codex`; use per-activity routing rather than claiming -that direct selector works. This is a documented implementation gap, not a host -or billing distinction. +Current AQE includes a subscription-backed `codex` provider. Agentic-kit accepts +`ak host pick --aqe-provider codex`, admits Codex fallback rungs, enables Codex +providers referenced by `agentOverrides`, and projects Codex activity routes. AQE risks relevant across hosts include its [MCP entrypoint double-spawn](https://github.com/proffesor-for-testing/agentic-qe/issues/528), @@ -223,8 +222,9 @@ MCP/plugin changes and keep generated guidance concise. ### OpenCode -OpenCode remains non-primary, is never automatically routed, cannot be an AQE -provider, and has no native Brain plugin or managed status line. Operational risks +Built-in OpenCode remains non-primary, is never automatically routed, has no AQE +provider identity, and has no native Brain plugin or managed status line. A separately admitted +adapter may earn its own external provider id; that does not change the built-in descriptor. Operational risks include local MCP servers dropping during `serve` ([opencode #38266](https://github.com/anomalyco/opencode/issues/38266)), nested permission prompts hanging ([#13715](https://github.com/anomalyco/opencode/issues/13715)), @@ -246,9 +246,10 @@ waits on a real external adapter clearing the conformance kit and soaking. | Tier | Status today | Gates | Why | | --- | --- | --- | --- | | `admission` | Genuinely passes | — | Manifest validation and consent are built | +| `session-driving` | **Gated** | — | Being a native Ruflo backend is upstream's to grant | | `activity-routing` | Genuinely passes | — | Real supervised subprocess worker via `ak run` | +| `aqe-provider` | Genuinely passes | `aqeProvider` | Real admitted stdin/stdout hook; projects through AQE 3.13.12+ | | `primary-eligible` | Genuinely passes | `canBePrimary` | Observes a real escalation | -| `session-driving` | **Gated** | — | Being a native Ruflo backend is upstream's to grant | | `statusline` | **Gated** | `commandStatusline` | `ak` has no render surface for it yet | The two gated tiers are honest ceilings, not failures — they report `gated` or @@ -274,15 +275,13 @@ entry — an ordinary pull request, not a command. These are intentionally visible rather than hidden behind an over-broad “supported” label: -1. AQE upstream supports a `codex` subscription provider, while agentic-kit's - direct `--aqe-provider` allow-list does not. Codex activity projection works; - direct selection does not. -2. OpenCode transcript, token, observed-cost, and provider-id parsing exists in +1. OpenCode transcript, token, observed-cost, and provider-id parsing exists in `usage-opencode.mjs`, while the integration registry still advertises `usage:false`. The dashboard analytics are real; the capability declaration is stale. -3. “OpenCode is outside AQE” means outside **AQE inference-provider routing**. - It does not mean AQE lacks OpenCode platform agents, skills, or MCP support. +2. “OpenCode is outside AQE” means the built-in OpenCode descriptor has no AQE + provider identity. It does not mean AQE lacks OpenCode platform agents, skills, + or MCP support, or that a separately admitted adapter cannot earn an external id. The governing decisions currently stand as follows: ADR-0017 and ADR-0018 are **Accepted** and were amended on 2026-08-04 and 2026-07-30 respectively; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 379a86a..610b33e 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -29,7 +29,9 @@ A **binding** connects a host to a provider through a projection and transport. provider can therefore have independent `ollama-via-claude` and `ollama-via-codex` bindings. OpenRouter is a provider behind a host, never automatically a third host. OpenCode is an opt-in activity-routing host through `ak run`, while remaining ineligible as a primary host or AQE -provider. Its configured selector does not establish provider, billing, or vendor-diversity facts. +provider unless an independently admitted adapter for that host declares and earns the separate +AQE-provider capability. A configured selector alone never establishes provider, billing, or +vendor-diversity facts. **Account analytics is separate from routing evidence.** `ak usage refresh openrouter` explicitly fetches OpenRouter's supported 30-completed-UTC-day activity view with @@ -125,19 +127,20 @@ is reported and skipped; it never takes down the hosts that already work. An external adapter can never **self-declare** that it is the primary host, an AQE provider, or the status-line owner. That ban is permanent — it is the safety invariant the whole extension -point rests on. But `canBePrimary` and `commandStatusline` are **earnable**: passing the gating +point rests on. `canBePrimary`, `aqeProvider`, and `commandStatusline` are **earnable**: passing the gating conformance tier is evidence, and `ak host adapters grant ` (alias `bless`) is a maintainer's explicit grant, refused unless that tier is recorded passed at the adapter's -current manifest hash. `aqeProvider` stays upstream-owned and is never `ak`-grantable. +current combined content hash. `ak host adapters conformance ` runs the tiered black-box kit: | Tier | Status today | Gates | | --- | --- | --- | | `admission` | Genuinely passes against a real adapter | — | +| `session-driving` | **Gated** — a native Ruflo backend is upstream's to grant | — | | `activity-routing` | Genuinely passes — real supervised subprocess worker | — | +| `aqe-provider` | Genuinely passes — real stdin/stdout provider hook through the admitted bridge | `aqeProvider` | | `primary-eligible` | Genuinely passes — observes a real escalation | `canBePrimary` | -| `session-driving` | **Gated** — a native Ruflo backend is upstream's to grant | — | | `statusline` | **Gated** — `ak` has no render surface for it yet | `commandStatusline` | The two gated tiers are honest ceilings, not failures: they report `gated`/`skipped` and never @@ -147,9 +150,42 @@ alongside granted capabilities, and `revoke-grant [capability]` withdraws Be precise about what a grant buys **today**. A granted capability goes live in the effective host registry from the next flagged invocation — the host's tier label reflects it and it joins -primary-eligibility. But no path yet *selects* an external host as primary (`ak host pick` stays -built-in-scoped), and `commandStatusline` has no runtime reader. So a granted `canBePrimary` is -visible and eligible, while a granted `commandStatusline` is currently inert. +primary-eligibility. A granted `aqeProvider` also becomes a live, project-scoped Agentic-QE +3.13.12+ external provider: its declaration is written to `.agentic-qe/llm-config.json`, and its +host routes may populate `agentOverrides`. No path yet *selects* an external host as primary +(`ak host pick` stays built-in-scoped), and `commandStatusline` has no runtime reader. + +The manifest candidate is data, not authority. It uses `aqe.provider` (never +`host.legacy.aqeProvider`) and requires `cli-subprocess`, activity routing, and both an execution +hook and a dedicated provider hook: + +```json +{ + "aqe": { + "provider": { + "hook": { + "command": ["node", "aqe-provider.mjs"], + "files": ["aqe-provider.mjs"], + "timeoutMs": 180000, + "passEnv": ["HERMES_API_KEY"] + }, + "billingMode": "subscription", + "models": ["default"], + "defaultModel": "default", + "maxConcurrency": 2, + "stripEnv": ["OPENAI_API_KEY"], + "displayName": "Hermes subscription" + } + } +} +``` + +`host.id` is the provider id. It must satisfy AQE's external-id grammar and cannot collide with a +built-in/reserved provider. The hook receives the prompt on stdin and writes only the completion to +stdout. Exit `77`/`78`, timeout, auth failure, and any other error produce no stdout because AQE +3.13.12 treats non-empty stdout as a completion even when a CLI exits non-zero. The bridge forwards +only declared environment variables, protects its own `AK_AQE_*` control variables, rechecks the +content hash before spawn, and keeps the hook in a supervised subprocess. Graduation has two destinations: a **blessed external adapter** stays out-of-tree holding exactly the capabilities its tiers earned, or a maintainer **promotes it to a built-in** by adopting its @@ -299,18 +335,15 @@ and `onnx` are local, and the API-provider spellings bill their corresponding credentials. There is no `openai`-subscription or `gemini`-subscription alias: `openai` and `gemini` remain API-metered provider types. -> [!IMPORTANT] -> agentic-kit does not yet expose AQE's direct `codex` provider through -> `--aqe-provider`; that allow-list currently rejects it. Codex activity routes -> can still project into AQE `agentOverrides`, so per-activity Claude/Codex -> routing works. This is a documented agentic-kit integration gap, not evidence -> that AQE itself lacks a Codex subscription provider. - ```bash ak host pick --aqe-provider claude-code # run QE on your subscription, no API bill +ak host pick --aqe-provider codex # direct Codex subscription provider ``` -`ak` writes `AQE_LLM_PROVIDER` for you. Add `OPENAI_API_KEY` to your env and agentic-qe's +For built-ins, `ak` writes `AQE_LLM_PROVIDER` for you. An admitted external id is selectable by +the same flag, but its default is written only to the project `.agentic-qe/llm-config.json` — never +to user or project host-settings environment. This prevents a project-scoped adapter identity from +leaking into unrelated repositories. Add `OPENAI_API_KEY` to your env and agentic-qe's router will **auto-enable** OpenAI as a fallback on its own — you don't have to list it. ## Level 3 — a deterministic fallback chain @@ -328,6 +361,48 @@ Each `provider:model,model` becomes an ordered chain entry (first = highest prio writes a complete, schema-correct chain, tags it `_managedBy: agentic-kit`, and **never** writes your API keys. +Agentic-QE **3.13.12 or newer** is required for an external id. The same admitted id may be the +default, a fallback rung, or the provider projected from an explicit external-host activity route. +Agentic-kit merges `externalProviders` without replacing foreign declarations and also writes the +minimal compatibility activation AQE 3.13.12's MCP bootstrap requires: +`providers[id] = { "enabled": true }`. Both values have exact ownership receipts. A same-id foreign +or user-edited declaration is preserved and reported as a conflict; an explicit foreign +`enabled:false` is refused rather than overridden. When a grant is revoked, a host is disabled, or +declared content changes, sync removes only a stale declaration/activation that still exactly +matches its receipt. A user-edited value is preserved and becomes user-owned. + +### External-provider release proof + +Three checks establish different facts; do not collapse them: + +1. `ak host adapters conformance ` must pass the real `aqe-provider` tier at the current + content hash, then `ak host adapters grant aqeProvider` must succeed. +2. `ak x verify providers` proves admission plus the exact project declaration, ownership receipt, + default, fallback, and override projection. It deliberately warns that this is not a served + model response. +3. Release proof starts fresh AQE CLI and MCP processes, lists the external id through + `aqe llm providers --json`, invokes the real `test_generate_enhanced` MCP tool, and requires the + served completion to carry the fixture's provider and model markers: + + ```bash + pnpm test:aqe-external-provider-live + ``` + + The generated command may also be exercised directly as a transport diagnostic: + + ```bash + printf 'Reply with exactly: OK' | agentic-kit x aqe-provider hermes \ + --model default --expect-hash --project-root "$PWD" + ``` + +The live fixture sets `AQE_LLM_PROVIDER` only in its child-process environment for deterministic +selection; agentic-kit never persists an external id into managed machine/user host settings. The +direct trampoline proves bounded stdin/stdout execution; it does not replace the fresh-process AQE +CLI/MCP proof. Neither configured `billingMode` nor a host name proves vendor diversity or a +bill. External billing is adapter-declared and reported as unverified; vendor identity requires +observed or independently configured provider evidence. QE-Court must not count two hosts as two +vendors without that evidence. + > Model IDs above are examples current as of July 2026 (Claude Opus 5, OpenAI GPT-5.6 — > or `gpt-5.3-codex` for agentic coding — Google Gemini 3.5 Flash). Use whatever IDs your > provider currently offers; `ak` writes the strings you give it verbatim. @@ -488,12 +563,15 @@ The native config stores each knob below lives in — and their precedence — a | Register a Ruflo LLM provider | `--provider ollama:qwen3.6:27b` | `ruflo providers configure -p ollama -m qwen3.6:27b -e http://127.0.0.1:11434` | | Select a direct Ruflo provider | per-agent/raw setting | agent `--provider` or `RUFLO_PROVIDER=ollama` / `openrouter` | | Set which LLM runs QE | `--aqe-provider gemini` | `AQE_LLM_PROVIDER=gemini` (env) | +| Select an admitted external AQE provider | `--aqe-provider hermes` | project `llm-config.json` `externalProviders` + `defaultProvider` | | Order QE's fallback chain | `--aqe-fallback '…'` | edit `.agentic-qe/llm-config.json` / `aqe llm-router config` | | Cap QE spend | (kit.json `maxBudgetUsd`) | `AQE_MAX_BUDGET_USD` / `--max-budget-usd` | -If you hand-edit `.agentic-qe/llm-config.json` yourself and *don't* use `ak`'s -`--aqe-fallback`, `ak` leaves your file alone — it only manages a chain it owns (the -`_managedBy` tag). Keys always stay in the environment; neither `ak` nor aqe persists them. +If you hand-edit `.agentic-qe/llm-config.json`, agentic-kit preserves foreign entries. It manages +its fallback chain and curated overrides under `_managedBy`, and each external declaration under +an exact-value ownership receipt. A same-id foreign or edited external declaration is preserved and +reported as a conflict rather than overwritten or pruned. Keys always stay in the environment; +neither agentic-kit nor AQE persists them. ## Undo, always diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index cf0f3f5..7a2a490 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -2,13 +2,17 @@ - **Status:** Accepted (experimental contract) - **Date:** 2026-08-15 -- **Updated:** 2026-08-24 +- **Updated:** 2026-08-26 - **Update note:** [ADR-0031](0031-capability-graduation-and-upstream-requests.md) amends this ADR's "permanent caps" framing. The block on *self-declaring* `canBePrimary` / `aqeProvider` / `commandStatusline` in the manifest is permanent (the safety invariant here), but the *capability* is earnable through a conformance tier plus a maintainer grant recorded outside the manifest — up to promotion to a first-party built-in. The schema, admission gate, consent model, and hook runner - now also pin declared hook-file bytes as described in §6. + now also pin declared hook-file bytes as described in §6. **2026-08-26:** Agentic-QE 3.13.12 + satisfied [#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628) with + `externalProviders`. The manifest may now carry non-authoritative `aqe.provider` candidate data; + the `host.legacy.aqeProvider` self-claim remains forbidden, while a passed `aqe-provider` tier and + explicit hash-pinned grant activate the projection. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (closed-registry clause superseded — see [Supersession](#supersession-of-adr-0016s-closed-registry-clause)), @@ -122,6 +126,13 @@ it. There is no function-valued field anywhere in the manifest — every value i which is itself part of the safety property: a manifest that cannot express a closure cannot smuggle one in. +The 2026-08-26 amendment adds optional `aqe.provider` candidate data. Its provider identity is fixed +to `host.id`; the block may declare a supervised stdin/stdout hook, billing mode, models, default +model, concurrency, environment allow/strip lists, and display name. It requires +`cli-subprocess`, `canRouteActivities: true`, and `execution.run.hook`. Candidate data does not +activate itself: the `aqe-provider` tier must pass and a maintainer must grant `aqeProvider` at the +same combined content hash before bootstrap exposes it to Agentic-QE 3.13.12+. + ### 2. Driving-surface vocabulary: `cli-subprocess` / `acp` / `mcp` Every declared hook names the driving surface that invokes it. Contract v1 recognizes three surface @@ -173,16 +184,20 @@ external-execution row, after an adversarial review of the surface): `provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s. -### 3. Capability caps are schema-structural, not runtime-checked +### 3. Capability self-claims are schema-structural, not runtime-checked -`canBePrimary`, `aqeProvider`, and `commandStatusline` are not fields the external-adapter manifest -schema accepts, at any value. An adapter author cannot express the claim "I am primary-eligible" — +`canBePrimary`, `host.legacy.aqeProvider`, and `commandStatusline` are not claims the +external-adapter manifest schema accepts. An adapter author cannot express the claim "I am +primary-eligible" — not because `ak` reads and rejects `true`, but because the accepted JSON Schema has no place to put it. This is the direct fix for the gap identified in "Why the mechanism changed" above: a capability cap enforced by field-absence cannot be bypassed by anything the adapter's own code does, because there is no code — only a document a schema either accepts or rejects before anything runs. -`canRouteActivities` remains expressible; it is the one capability an external adapter may claim, -matching the shape OpenCode already occupies as a non-primary, non-AQE, routable host. +`canRouteActivities` remains expressible. Since the 2026-08-26 amendment, `aqe.provider` may also +describe a candidate external CLI provider, but it is data rather than capability: it requires +`cli-subprocess`, activity routing, an execution hook, a dedicated provider hook, a passed real +`aqe-provider` tier, and an explicit maintainer grant at the same content hash before bootstrap +registers it. ### 4. Admission: fail-closed, per-adapter isolated, behind an experimental flag @@ -291,11 +306,11 @@ A matching one-line update-note has been added to ADR-0016 itself, pointing here of adapter-supplied code is not a bootstrapping restriction to be lifted once the mechanism matures; it is the property "Why the mechanism changed" argues for. A future contract version may add driving surfaces (`acp`, `mcp`) or hook verbs; it may not add in-process code execution. -- **No AQE projection, ever.** An admitted external host cannot become an `aqeProvider` at any - contract version — that field is schema-absent for the same reason `canBePrimary` and - `commandStatusline` are (§3), and AQE's own provider set is upstream's enumeration, not one `ak` - extends by admitting a host ([ADR-0028](0028-local-openai-compatible-providers.md) draws the - identical line for `local-openai`). +- **Historical decision — no AQE projection (superseded 2026-08-26).** The original contract + permanently excluded external AQE providers because AQE had no safe registration surface. AQE + 3.13.12's `externalProviders` implementation for #628 removes that upstream premise. The current + decision admits `aqe.provider` candidate data but preserves the original trust goal: admission + alone grants nothing; only a passed transport tier plus a maintainer grant activates it. - **No automatic routing or seeding.** An admitted host is never auto-seeded into `routing.routes`; it must be explicitly routed, matching [ADR-0018](0018-generalized-host-worker-execution.md)'s existing rule that automatic seeding stays Claude/Codex subscription-only. @@ -320,17 +335,17 @@ A matching one-line update-note has been added to ADR-0016 itself, pointing here - `AK_EXPERIMENTAL_HOST_ADAPTERS` being unset is the default, and the default behavior of every existing command is unchanged: an unset flag makes the whole surface inert, and a `hostAdapters` key with the flag unset round-trips through `kit.json` untouched. -- A capability an external adapter cannot express (`canBePrimary`, `aqeProvider`, - `commandStatusline`) is a permanent property of contract v1, not a temporary restriction lifted at - graduation — graduation (below) freezes the *contract*, not the caps. +- Capability self-claims remain inexpressible. `aqeProvider` is now earned outside the manifest from + `aqe.provider` candidate data; `canBePrimary` and `commandStatusline` remain earned the same way. + Graduation freezes the contract, not the right to bypass evidence or grants. ## Self-graded implementation status -Dated 2026-08-24, after the PR #131 follow-up implementation. Rows already covered by the amended +Dated 2026-08-26, after the Agentic-QE #628 integration. Rows already covered by the amended gate list are not repeated; this table grades the mechanism this ADR newly decides. **Working** means implemented and tested in this worktree. -| Mechanism | Grade (2026-08-24) | Evidence | +| Mechanism | Grade (2026-08-26) | Evidence | |---|---|---| | Manifest schema (contract: 1) | **Working** | `src/lib/adapters/manifest.mjs`; strict hook `files` inventory validation; manifest tests. | | Admission gate (fail-closed, per-adapter isolated) | **Working** | `src/lib/adapters/admission.mjs`; admission and integrity tests. | @@ -339,6 +354,7 @@ implemented and tested in this worktree. | Subprocess hook-runner (`cli-subprocess` surface) | **Working** | `src/lib/adapters/hook-runner.mjs`; bounded real-subprocess tests. | | Hash-pinned consent + edit-invalidation | **Working** | `src/lib/adapters/integrity.mjs`; manifest + declared hook-file digests, pre-spawn recheck, integrity tests. | | Capability-cap schema absence (§3) | **Working** | Schema refusal tests and maintainer-only grant allow-list. | +| AQE external-provider candidate + projection | **Working** | Strict `aqe.provider` validation; six-tier conformance includes a real `aqe-provider` probe; hash-pinned `aqeProvider` grants; Agentic-QE 3.13.12 gate; project-only default/fallback/agentOverrides projection; receipt-owned `externalProviders[id]` plus the minimal `providers[id].enabled=true` MCP-bootstrap activation; foreign-entry preservation, explicit-disable conflict refusal, and exact-value stale pruning. | | Gate item 1 — import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). | | Gate item 2 — uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). | | Gate item 3 — permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). | diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 3e3e0f5..64b0aa2 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -2,7 +2,12 @@ - **Status:** Accepted (governance decision; implementation active) - **Date:** 2026-08-16 -- **Updated:** 2026-08-24 +- **Updated:** 2026-08-26 +- **Update note:** Agentic-QE 3.13.12 satisfied the provider-registration request in + [#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628). The six-tier ladder now + includes a real `aqe-provider` transport exercise, and a passed tier plus explicit hash-pinned + `aqeProvider` grant activates project-scoped `externalProviders` projection. This supersedes the + closed-enum ceiling below without weakening the permanent ban on manifest self-claims. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md), [ADR-0018](0018-generalized-host-worker-execution.md), @@ -17,9 +22,10 @@ [ADR-0029](0029-host-adapter-extension-point.md) admitted external host adapters as a declarative manifest plus consented, subprocess-only hooks, behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1`. To make -the door safe, three capabilities were made **inexpressible** in the manifest schema — -`canBePrimary`, `aqeProvider`, and `commandStatusline` — and ADR-0029 described that block as -permanent. +the door safe, three capability self-claims were made **inexpressible** in the manifest schema — +`canBePrimary`, `host.legacy.aqeProvider`, and `commandStatusline` — and ADR-0029 described that +block as permanent. The 2026-08-26 amendment preserves that ban while allowing separate +`aqe.provider` candidate data with no authority on its own. Two things push past that framing: @@ -63,6 +69,7 @@ host, assert the real behaviour, against an installed layout — that gates one | `admission` | Registration through the fail-closed gate (ADR-0029, shipped) | manifest validates, admits, hooks run | | `session-driving` | `canDriveSession` — the host actually drives an interactive/oneshot session | a real session completes and is observed | | `activity-routing` | `canRouteActivities` — the host runs a supervised `ak run` worker to a structured result | a worker completes under the runner's contract (ADR-0018) | +| `aqe-provider` | Grants `aqeProvider` — the candidate runs through AQE's external CLI transport | the real admitted stdin/stdout hook returns the bounded probe with its declared model | | `primary-eligible` | Grants `canBePrimary` — the host can *lead*: anchor routing, be escalated toward | leads a run and receives an escalation, per ADR-0019 | | `statusline` | Grants `commandStatusline` — renders a command-backed footer through supervised hooks | a footer renders and refreshes | @@ -98,13 +105,11 @@ When a conformance tier cannot be met, the first question is **whose capability - **`ak`-local** (run execution, the trust CLI, remote manifest sources, quality-gate anchors) — the project's to build. A normal `ak` change; no one to wait on. -- **Upstream — agentic-qe** — being a recognized **AQE provider type** is not `ak`'s to grant. - agentic-qe's provider set is a closed, upstream-defined enumeration (verified against - `agentic-qe@3.13.10`: `ALL_PROVIDER_TYPES` plus a `createProvider` switch, extended only by an - upstream code change). The path: file a concrete capability request against agentic-qe (a - provider-plugin API), record the tier as `gated: agentic-qe#NNN`, and light it up when the upstream - release ships. *Interim:* quality still runs through the model provider underneath the host, so QE - is not blocked — only the host's own AQE identity is. +- **Upstream — agentic-qe (historical ceiling, satisfied 2026-08-26).** Against 3.13.10, being a + recognized provider required changing AQE's closed enum and factory, so #628 was correctly tracked + as upstream-gated. Agentic-QE 3.13.12 shipped `externalProviders`; agentic-kit now consumes that + supported surface. The current gate is local evidence and authority: `aqe.provider` candidate, + passed `aqe-provider` tier, explicit grant, current content hash, enabled host, and project scope. - **Upstream — ruflo** — being a native ruflo **backend** (an `ENABLE_*` target that drives the loop) is defined inside ruflo (grounded against `ruvnet/ruflo@45e65b5`: backend enablement is per-host `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` / `ENABLE_GEMINI_MCP`, not an outside registration). The path: @@ -167,9 +172,10 @@ the experimental flag. This table is the source of truth for what is real. | `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) | | External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B; integrity tightened 2026-08-24) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory; remote path-backed hooks are refused before admission because no bundle is retained; declared local hook files are rechecked immediately before spawn; an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` | | External lifecycle execution wired into setup/sync/uninstall | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. `setup`, `uninstall`, **and now `sync`** are fully live: `status.mjs`'s collector emits a subsystem-tagged row for an enabled admitted lifecycle host, so `sync`'s convergence plan reaches its admitted-host branch (wave D4 closed the earlier `sync`-only reachability gap) | -| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, waves C+D2) | `runTieredConformance` + `ak host adapters conformance`: `admission`, `activity-routing`, and now `primary-eligible` genuinely pass black-box against a real fixture — `primary-eligible` drives a real `executeRunPlan` where the host anchors a run and receives a genuine ADR-0019 escalation onto itself (a real second subprocess), recorded with no pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped` (external session driving and the statusline render path are not built) — the harness never fabricates a pass, and there is no injection seam through which a caller could substitute one. A failed `admission` tier short-circuits every downstream tier so no evidence is laundered. A grant-bearing tier that re-runs `failed` under the same adapter-content hash now auto-voids the stored tier **and** the live granted capability (wave D4, N-1) — the un-earn path mirrors the gated-downgrade; a `skipped` result never voids (prerequisite not evaluated ≠ disproof). Capabilities can also be withdrawn per-capability with `ak host adapters revoke-grant [capability]`. The content identity covers the validated manifest plus declared hook-file bytes; the explicit inventory and immediate pre-spawn recheck are the remaining contract-v1 boundary. `statusline` un-earn lands with its render path | +| Six-tier conformance harness (`admission` … `statusline`) | **Working** (updated 2026-08-26) | `admission`, `activity-routing`, `aqe-provider`, and `primary-eligible` genuinely pass black-box against real fixtures. `aqe-provider` invokes the admitted hook with a bounded prompt and declared model without requiring a pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped`. A failed admission short-circuits downstream tiers; a failed grant-bearing re-run at the same content hash voids its evidence and live capability, while `skipped` never does. | | Hook-file integrity and development conformance mode | **Working** (2026-08-24, PR #131 follow-up) | `hook.files` validates an explicit relative inventory; `hashAdapterContent` adds per-path SHA-256 digests; admission, consent, grants, and pre-spawn execution use the combined identity; `ak host adapters conformance --dev` runs real probes without persisting evidence or grants. | -| Capability-grant store + promotion command | **Working** (2026-08-16, waves D+D2) | `grants.mjs` (hash-pinned, evidence-gated, edit-invalidated like consent — the earned capability is enforced at **read** time, not only at grant time) plus `ak host adapters grant`/`bless`: the maintainer's explicit grant of a tier-earned capability, refused unless the gating tier is recorded `passed` at the current adapter-content hash. **Wave D2 makes a grant live:** at bootstrap the admitted-host overlay reads `grantedCapabilitiesFor` at the fresh current content hash and raises `canBePrimary`/`commandStatusline` on the effective-registry entry (through a local allow-list that can raise only those two, never `aqeProvider` or any other key), so `hostTierLabel` and `effectivePrimaryHostIds()` reflect it. Two consumption gaps remain, honestly disclosed at grant time: no path yet *selects* an external host as primary (`ak host pick` stays built-in-scoped), and `commandStatusline` has no runtime reader yet (its render path is a later wave) | +| Capability-grant store + promotion command | **Working** (updated 2026-08-26) | `grants.mjs` keeps consent, tier evidence, and `canBePrimary`/`aqeProvider`/`commandStatusline` grants hash-pinned and edit-invalidated. Bootstrap registers an AQE provider only when the adapter is admitted, enabled, current, and holds a live `aqeProvider` grant. Primary selection and statusline rendering remain separate consumption gaps; AQE projection is live. | +| Agentic-QE #628 projection | **Working** (2026-08-26) | Requires Agentic-QE >=3.13.12; supports direct `codex` plus admitted external ids in project default, fallback chain, and agent overrides; writes receipt-owned `externalProviders[id]` and the minimal `providers[id].enabled=true` required by AQE MCP bootstrap; never persists an external default to user settings; preserves foreign entries, refuses same-id/explicit-disable conflicts, and prunes only exact receipt-owned stale values. Status/verify distinguish configuration proof from served inference, billing, and vendor evidence. | | Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A; tightened 2026-08-24) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, and remote sources with script-like hook paths are refused because no bundle is retained. | | Upstream request tracking (`gated: #NNN` against a tier) | **Working** (2026-08-16, wave D) | `ak host adapters gate ` records a ref-format-validated upstream gate; `ak host adapters status` surfaces per-tier passed/gated state (stale-marked on a manifest edit) and the granted capabilities | | A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) | @@ -185,20 +191,19 @@ the experimental flag. This table is the source of truth for what is real. avoid. Self-declaration stays inexpressible; capability comes from earned evidence plus an explicit grant. - **Treat every ceiling as `ak`-local and build around upstream.** Rejected as dishonest and - unmaintainable: agentic-qe's provider enum and ruflo's backend model are upstream facts. Faking a - local shim (e.g. projecting an unknown host into agentic-qe's config) would fabricate an identity - the upstream tool never declared it understands. The upstream-request path (§4) is the honest - alternative. + unmaintainable when decided: AQE 3.13.10 did not understand external ids. The 2026-08-26 + implementation is different because AQE 3.13.12 now declares the `externalProviders` contract; + agentic-kit consumes that upstream surface rather than fabricating one. ## References - ADR-0029 (the extension point, schema, admission, consent, hook runner) and its amendment above. - ADR-0018 (supervised worker contract), ADR-0019 (bounded escalation) — the substance of the `activity-routing` and `primary-eligible` tiers. -- Upstream facts grounded in a source-cited research sweep: `agentic-qe@3.13.10` - (`ALL_PROVIDER_TYPES`, the `createProvider` switch, the closed provider enum) and +- Historical upstream facts were grounded against `agentic-qe@3.13.10` + (`ALL_PROVIDER_TYPES`, the `createProvider` switch, and its then-closed provider enum) and `ruvnet/ruflo@45e65b5` (`ENABLE_*` backend model). The two §4 capability requests have been filed — - the AQE provider-plugin request as + the AQE provider request as [proffesor-for-testing/agentic-qe#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628) and the ruflo backend-registration request as [ruvnet/ruflo#3046](https://github.com/ruvnet/ruflo/issues/3046), each inviting the maintainer to diff --git a/docs/adr/README.md b/docs/adr/README.md index 42c8629..c113924 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -210,9 +210,11 @@ valid Hermes value. for an in-process ESM module loaded by `import()`, this ADR accepts a declarative manifest driving a fixed set of consented, subprocess-only hooks — no third-party code ever runs inside the `ak` process, gated behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1`, admitted only after a hash-pinned consent -that invalidates the moment the manifest's content changes. `canBePrimary`, `aqeProvider`, and -`commandStatusline` are not fields the manifest schema accepts at all, so those three obligations -stay first-party by construction rather than by an adapter's own promise. It formally supersedes +that invalidates the moment the manifest's content changes. `canBePrimary`, +`commandStatusline`, and `host.legacy.aqeProvider` are not claims the manifest schema accepts, so +authority stays outside an adapter's own promise. Since the 2026-08-26 amendment, `aqe.provider` +may carry non-authoritative Agentic-QE 3.13.12+ candidate data; a real conformance tier and explicit +grant activate it. It formally supersedes ADR-0016's closed-registry clause, folds in the maintainer's full PR #131 gate list (import-time routable-host invariant, uninstall-through-undo, permission authorization by host, attribution surfaces policy, and kit.json's unknown-key warning — landed in wave 1 and Phase 0; registry↔directory @@ -220,13 +222,14 @@ test pins remain wave 3), and stays experimental until a real external adapter c conformance kit and a release of soak. **0031** amends 0029 on one point and adds the governance around it. The three capability caps -(`canBePrimary`, `aqeProvider`, `commandStatusline`) stay *inexpressible* in the manifest — that -block on self-declaration is permanent — but the capability itself becomes *earnable*: passing a +(`canBePrimary`, `host.legacy.aqeProvider`, `commandStatusline`) stay *inexpressible* as capability +self-claims — that block is permanent — but capability becomes *earnable*: passing a conformance tier plus an explicit maintainer grant (hash-pinned, outside the manifest) confers it, up to and including promotion to a first-party built-in with full parity. It also records the -upstream-request path: some ceilings are not `ak`'s to lift (being an AQE provider type is -agentic-qe's closed enum; being a native ruflo backend is ruflo's `ENABLE_*` model), so those become -tracked capability requests with honest interim behaviour rather than pretended support. Accepted as +upstream-request path. Its AQE ceiling was satisfied by Agentic-QE 3.13.12 `externalProviders` on +2026-08-26: the six-tier ladder now includes `aqe-provider`, and a grant activates project-scoped +default/fallback/agentOverride projection. Native ruflo backend status remains upstream-owned. +Accepted as a governance decision; the machinery (the trust CLI, external execution, tiered conformance, the grant store) is staged and self-graded in the ADR's implementation-status table. diff --git a/docs/ddd/routing-and-orchestration.md b/docs/ddd/routing-and-orchestration.md index 5632faf..30c8ac1 100644 --- a/docs/ddd/routing-and-orchestration.md +++ b/docs/ddd/routing-and-orchestration.md @@ -138,8 +138,8 @@ must distinguish per-token price from measured or expected per-task cost. 1. Only capability-qualified hosts receive activity routes. 2. Routing host and inference provider are separate axes. -3. One canonical policy feeds all eligible downstream projections; ineligible OpenCode routes are - never fabricated into AQE. +3. One canonical policy feeds all eligible downstream projections; a built-in OpenCode route with + no earned provider identity is never fabricated into AQE. 4. User-pinned routes are not overwritten by default refresh. 5. Primary-host selection changes leadership defaults, not host enablement symmetry. 6. Escalation is explicit, ordered, and per route. From d4f3f44125cc895289b5694e191b2eb9de856945 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 14:50:29 -0700 Subject: [PATCH 04/21] fix(adapters): harden external provider boundaries --- docs/AUTHORING-HOST-ADAPTERS.md | 3 ++ docs/PROVIDERS.md | 3 ++ src/lib/adapters/admission.mjs | 26 +++++++------ src/lib/adapters/manifest.mjs | 51 ++++++++++++++++++++++--- tests/kit/adapter-aqe-provider.test.mjs | 37 ++++++++++++++++++ 5 files changed, 102 insertions(+), 18 deletions(-) diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md index 6c8bce7..6e6fdd2 100644 --- a/docs/AUTHORING-HOST-ADAPTERS.md +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -167,6 +167,9 @@ protected `AK_AQE_*` variables; only names in `passEnv` cross from the parent en print partial output before success: the bridge intentionally suppresses stdout for refusal, auth, timeout, and failure because AQE treats non-empty stdout as a completion even on a non-zero exit. Billing mode is declared provenance, not a verified charge or vendor fact. +The boundary is deliberately finite: at most 128 model ids (256 UTF-8 bytes each), a control-free +128-byte display name, `maxConcurrency` from 1 through 64, and a provider-hook timeout no longer +than 24 hours. Oversized declarations are refused before consent or projection. ### One structural coupling worth knowing diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 610b33e..bf30325 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -186,6 +186,9 @@ stdout. Exit `77`/`78`, timeout, auth failure, and any other error produce no st 3.13.12 treats non-empty stdout as a completion even when a CLI exits non-zero. The bridge forwards only declared environment variables, protects its own `AK_AQE_*` control variables, rechecks the content hash before spawn, and keeps the hook in a supervised subprocess. +Candidate metadata is bounded before admission: at most 128 model ids of 256 UTF-8 bytes each, a +control-free display name of at most 128 bytes, concurrency from 1 through 64, and a provider-hook +timeout no longer than 24 hours. Graduation has two destinations: a **blessed external adapter** stays out-of-tree holding exactly the capabilities its tiers earned, or a maintainer **promotes it to a built-in** by adopting its diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index 1f47d6b..a46c8c7 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -149,6 +149,20 @@ export async function bootstrapHostAdapters({ cfg, env = process.env, readManifest = defaultReadManifest, consent, } = {}) { if (env?.AK_EXPERIMENTAL_HOST_ADAPTERS !== '1') return { active: false, admitted: [], warnings: [] }; + + // The AQE provider bridge is an exact snapshot of THIS bootstrap pass. + // Clear it before every flag-on early return as well as before rebuilding: + // removing the final adapter or losing access to the consent store must not + // leave a provider from a prior in-process bootstrap live. Flag-off remains + // a true zero-import no-op above. + let aqeProviderBridge; + try { + aqeProviderBridge = await import('./aqe-provider.mjs'); + aqeProviderBridge.resetAdmittedAqeProviders(); + } catch { + aqeProviderBridge = null; + } + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; if (entries.length === 0) return { active: false, admitted: [], warnings: [] }; @@ -181,18 +195,6 @@ export async function bootstrapHostAdapters({ const warnings = results.filter((result) => !result.admitted) .map(({ name, reason, detail }) => ({ name, reason, detail })); - // The AQE provider bridge is an exact snapshot of THIS bootstrap pass. - // Clear it before rebuilding so a second in-process bootstrap cannot leave - // a formerly-admitted/granted provider live after consent, enablement, or - // configuration changes. Flag-off remains a true zero-import no-op above. - let aqeProviderBridge; - try { - aqeProviderBridge = await import('./aqe-provider.mjs'); - aqeProviderBridge.resetAdmittedAqeProviders(); - } catch { - aqeProviderBridge = null; - } - if (admitted.length) { const { applyAdmitted } = await import('./admitted.mjs'); diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index 7e55cbe..8844c6a 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -20,6 +20,11 @@ const AQE_BUILTIN_OR_RESERVED_TYPES = new Set([ 'openrouter', 'azure-openai', 'bedrock', 'cognitum', 'ollama', 'onnx', ]); const AQE_BILLING_MODES = Object.freeze(['subscription', 'metered-api', 'metered-capped', 'local']); +const MAX_AQE_PROVIDER_MODELS = 128; +const MAX_AQE_MODEL_BYTES = 256; +const MAX_AQE_DISPLAY_NAME_BYTES = 128; +const MAX_AQE_PROVIDER_CONCURRENCY = 64; +const MAX_AQE_PROVIDER_TIMEOUT_MS = 24 * 60 * 60 * 1000; const AQE_BRIDGE_ENV = new Set(['PATH', 'HOME', 'XDG_CONFIG_HOME', 'APPDATA', 'AK_EXPERIMENTAL_HOST_ADAPTERS']); const ENV_CODE_INJECTION = new Set([ 'NODE_OPTIONS', 'BASH_ENV', 'ENV', 'PYTHONPATH', 'PYTHONHOME', 'RUBYOPT', @@ -45,6 +50,14 @@ export class ManifestRejected extends TypeError { } } +function hasUnsafeControl(value) { + return [...value].some((character) => { + const code = character.codePointAt(0); + return code <= 0x1f || (code >= 0x7f && code <= 0x9f) + || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069); + }); +} + // ── strict allowlists (Wave 4 security remediation, P0-A) ────────────────── // Consent hashes the VALIDATED manifest (see hashManifest in admission.mjs), // not the operator's raw file. Before this allowlist, an unrecognized @@ -262,8 +275,12 @@ function validateAqe(value, host, driving, execution) { throw new ManifestRejected('invalid-aqe-provider', error.message); } if (provider.hook.timeoutMs !== undefined - && (!Number.isInteger(provider.hook.timeoutMs) || provider.hook.timeoutMs <= 0)) { - throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.hook.timeoutMs must be a positive integer'); + && (!Number.isInteger(provider.hook.timeoutMs) || provider.hook.timeoutMs <= 0 + || provider.hook.timeoutMs > MAX_AQE_PROVIDER_TIMEOUT_MS)) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.hook.timeoutMs must be a positive integer <= ${MAX_AQE_PROVIDER_TIMEOUT_MS}`, + ); } validateHookFiles(provider.hook.files, 'aqe.provider.hook.files', 'invalid-aqe-provider'); const passEnv = validateEnvNames(provider.hook.passEnv, 'aqe.provider.hook.passEnv'); @@ -295,6 +312,19 @@ function validateAqe(value, host, driving, execution) { } catch (error) { throw new ManifestRejected('invalid-aqe-provider', error.message); } + if (provider.models.length > MAX_AQE_PROVIDER_MODELS) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.models may contain at most ${MAX_AQE_PROVIDER_MODELS} entries`, + ); + } + const oversized = provider.models.find((model) => Buffer.byteLength(model, 'utf8') > MAX_AQE_MODEL_BYTES); + if (oversized !== undefined) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.models entries may be at most ${MAX_AQE_MODEL_BYTES} UTF-8 bytes`, + ); + } models = [...provider.models]; } const defaultModel = provider.defaultModel ?? models[0]; @@ -307,12 +337,21 @@ function validateAqe(value, host, driving, execution) { } } if (provider.maxConcurrency !== undefined - && (!Number.isInteger(provider.maxConcurrency) || provider.maxConcurrency <= 0)) { - throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.maxConcurrency must be a positive integer'); + && (!Number.isInteger(provider.maxConcurrency) || provider.maxConcurrency <= 0 + || provider.maxConcurrency > MAX_AQE_PROVIDER_CONCURRENCY)) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.maxConcurrency must be a positive integer <= ${MAX_AQE_PROVIDER_CONCURRENCY}`, + ); } if (provider.displayName !== undefined - && (typeof provider.displayName !== 'string' || !provider.displayName.trim())) { - throw new ManifestRejected('invalid-aqe-provider', 'aqe.provider.displayName must be a non-empty string'); + && (typeof provider.displayName !== 'string' || !provider.displayName.trim() + || Buffer.byteLength(provider.displayName, 'utf8') > MAX_AQE_DISPLAY_NAME_BYTES + || hasUnsafeControl(provider.displayName))) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.displayName must be non-empty, control-free, and <= ${MAX_AQE_DISPLAY_NAME_BYTES} UTF-8 bytes`, + ); } return { diff --git a/tests/kit/adapter-aqe-provider.test.mjs b/tests/kit/adapter-aqe-provider.test.mjs index 571d5f6..970ae53 100644 --- a/tests/kit/adapter-aqe-provider.test.mjs +++ b/tests/kit/adapter-aqe-provider.test.mjs @@ -133,6 +133,28 @@ test('aqe.provider is strict, host-derived, normalized candidate data', () => { () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['PATH'] })), (error) => error.reason === 'invalid-aqe-provider', ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { maxConcurrency: 65 })), + (error) => error.reason === 'invalid-aqe-provider' && /<= 64/.test(error.message), + ); + const tooSlow = rawManifest(); + tooSlow.aqe.provider.hook.timeoutMs = 86_400_001; + assert.throws( + () => validateAdapterManifest(tooSlow), + (error) => error.reason === 'invalid-aqe-provider' && /<= 86400000/.test(error.message), + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { models: Array.from({ length: 129 }, (_, i) => `model-${i}`) })), + (error) => error.reason === 'invalid-aqe-provider' && /at most 128/.test(error.message), + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { models: ['x'.repeat(257)] })), + (error) => error.reason === 'invalid-aqe-provider' && /256 UTF-8 bytes/.test(error.message), + ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { displayName: 'unsafe\u001b[31mname' })), + (error) => error.reason === 'invalid-aqe-provider' && /control-free/.test(error.message), + ); const noCli = rawManifest(); noCli.driving.surfaces = ['mcp']; assert.throws( @@ -223,6 +245,21 @@ test('bootstrap activates only an admitted, enabled, hash-current aqeProvider gr assert.equal(active.warnings.length, 0); assert.equal(admittedAqeProviderFor('hermes')?.contentHash, integrity.hash); + await bootstrapHostAdapters({ + cfg: { hostAdapters: [] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(admittedAqeProviderFor('hermes'), null, + 'removing the final configured adapter clears the prior in-process provider snapshot'); + + await bootstrapHostAdapters({ + cfg, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(admittedAqeProviderFor('hermes')?.contentHash, integrity.hash); + await bootstrapHostAdapters({ cfg: { ...cfg, integrations: { hosts: { hermes: false } } }, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, From 215fd62b7ac3896e281157fbd6d96948891a7ea0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:07:39 -0700 Subject: [PATCH 05/21] fix(adapters): close QE-Court runtime charges --- docs/AUTHORING-HOST-ADAPTERS.md | 6 + docs/PROVIDERS.md | 8 +- src/lib/adapters/aqe-provider.mjs | 148 +++++++++++++++++++++--- src/lib/adapters/hook-runner.mjs | 25 +++- src/lib/adapters/manifest.mjs | 19 ++- tests/kit/adapter-aqe-provider.test.mjs | 128 ++++++++++++++++++-- tests/kit/adapter-hook-runner.test.mjs | 2 + 7 files changed, 301 insertions(+), 35 deletions(-) diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md index 6e6fdd2..0e0bb9a 100644 --- a/docs/AUTHORING-HOST-ADAPTERS.md +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -167,6 +167,12 @@ protected `AK_AQE_*` variables; only names in `passEnv` cross from the parent en print partial output before success: the bridge intentionally suppresses stdout for refusal, auth, timeout, and failure because AQE treats non-empty stdout as a completion even on a non-zero exit. Billing mode is declared provenance, not a verified charge or vendor fact. +On every call the bridge re-verifies and copies all declared adapter-owned hook files into a private +execution snapshot. Relative imports therefore resolve to the verified copies, not mutable source +files. List every adapter-owned imported file in `hook.files`; interpreter and other absolute- or +PATH-resolved native binaries are external system trust and are not covered by the adapter hash. +Forwarded secret values are redacted from failure diagnostics. Output over the supervised bound is +discarded and reported as failure, never accepted as a partial completion. The boundary is deliberately finite: at most 128 model ids (256 UTF-8 bytes each), a control-free 128-byte display name, `maxConcurrency` from 1 through 64, and a provider-hook timeout no longer than 24 hours. Oversized declarations are refused before consent or projection. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index bf30325..682d3dd 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -185,7 +185,13 @@ built-in/reserved provider. The hook receives the prompt on stdin and writes onl stdout. Exit `77`/`78`, timeout, auth failure, and any other error produce no stdout because AQE 3.13.12 treats non-empty stdout as a completion even when a CLI exits non-zero. The bridge forwards only declared environment variables, protects its own `AK_AQE_*` control variables, rechecks the -content hash before spawn, and keeps the hook in a supervised subprocess. +content hash before spawn, and keeps the hook in a supervised subprocess. Each invocation copies +the verified bytes of every declared adapter-owned hook file into a private execution snapshot, +closing the verify-to-spawn rewrite race while preserving relative imports among declared files. +The interpreter and other absolute/PATH-resolved native binaries remain externally managed system +trust, not part of the adapter content hash. Declare every adapter-owned imported file. Forwarded +secret values are redacted from bridge diagnostics, and a completion that exceeds the supervised +output bound fails closed instead of returning a truncated success. Candidate metadata is bounded before admission: at most 128 model ids of 256 UTF-8 bytes each, a control-free display name of at most 128 bytes, concurrency from 1 through 64, and a provider-hook timeout no longer than 24 hours. diff --git a/src/lib/adapters/aqe-provider.mjs b/src/lib/adapters/aqe-provider.mjs index 55ac0c9..49cf689 100644 --- a/src/lib/adapters/aqe-provider.mjs +++ b/src/lib/adapters/aqe-provider.mjs @@ -8,10 +8,13 @@ // through runAdapterHook's integrity, cwd, environment, timeout, output-cap, // and process-group controls. import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { runAdapterHook } from './hook-runner.mjs'; import { immutable } from './schema.mjs'; -import { verifyAdapterContent } from './integrity.mjs'; +import { AdapterIntegrityError, verifyAdapterContent } from './integrity.mjs'; const DEFAULT_MODEL = 'default'; const DEFAULT_MAX_CONCURRENCY = 2; @@ -65,6 +68,86 @@ function publicRecord(record) { }); } +function snapshotRelative(tokenValue, baseDir, inventory) { + if (!tokenValue || tokenValue.startsWith('-')) return null; + let relative; + if (path.isAbsolute(tokenValue)) { + const outside = path.relative(baseDir, tokenValue); + if (outside === '..' || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) return null; + relative = outside.replaceAll(path.sep, '/'); + } else { + relative = path.posix.normalize(tokenValue.replaceAll('\\', '/')).replace(/^\.\//, ''); + } + return inventory.has(relative) ? relative : null; +} + +function snapshotCommand(command, baseDir, snapshotDir, inventory) { + return command.map((token) => { + const equals = token.indexOf('='); + const prefix = equals >= 0 ? token.slice(0, equals + 1) : ''; + const value = equals >= 0 ? token.slice(equals + 1) : token; + const relative = snapshotRelative(value, baseDir, inventory); + return relative ? `${prefix}${path.join(snapshotDir, ...relative.split('/'))}` : token; + }); +} + +/** Copy the exact verified bytes into a private execution + * snapshot. Relative imports and declared file arguments resolve here, so a + * rename/write after verification cannot change the bytes this invocation + * executes. Interpreter and host binaries remain externally-managed system + * trust; the adapter-owned files are the snapshot boundary. */ +function materializeHookSnapshot(record) { + const declared = record.integrity.hookFiles ?? []; + if (declared.length === 0) return null; + if (typeof record.baseDir !== 'string' || !path.isAbsolute(record.baseDir)) { + throw new AdapterIntegrityError('hook-files-unavailable', 'AQE provider snapshot requires an absolute adapter directory'); + } + const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-snapshot-')); + try { + for (const file of declared) { + const source = path.resolve(record.baseDir, ...file.path.split('/')); + const outside = path.relative(record.baseDir, source); + if (outside === '..' || outside.startsWith(`..${path.sep}`) || path.isAbsolute(outside)) { + throw new AdapterIntegrityError('invalid-hook-file', `'${file.path}' escapes the adapter directory`); + } + const stat = fs.lstatSync(source); + if (!stat.isFile()) { + throw new AdapterIntegrityError('hook-file-not-regular', `'${file.path}' is not a regular file`); + } + const bytes = fs.readFileSync(source); + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== file.sha256) { + throw new AdapterIntegrityError( + 'hook-content-changed', + `declared hook content changed after consent (file '${file.path}')`, + ); + } + const target = path.join(snapshotDir, ...file.path.split('/')); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + fs.writeFileSync(target, bytes, { flag: 'wx', mode: stat.mode & 0o777 }); + } + const inventory = new Set(declared.map((file) => file.path)); + return { + dir: snapshotDir, + hook: { + ...record.provider.hook, + command: snapshotCommand(record.provider.hook.command, record.baseDir, snapshotDir, inventory), + }, + }; + } catch (error) { + fs.rmSync(snapshotDir, { recursive: true, force: true }); + throw error; + } +} + +function redactForwardedSecrets(value, env) { + let text = String(value ?? ''); + const secrets = [...new Set(Object.values(env).filter((entry) => typeof entry === 'string' && entry.length > 0))] + .sort((a, b) => b.length - a.length); + for (const secret of secrets) text = text.split(secret).join(''); + return text; +} + /** Register one already-admitted, currently-granted provider. This function * cannot perform admission or grant lookup itself; its sole production caller * is bootstrapHostAdapters, which owns those gates. It does re-verify the @@ -89,7 +172,8 @@ export function registerAdmittedAqeProvider(manifest, { throw new TypeError(`AQE provider '${id}' has a relative hook command but no retained adapter directory`); } const record = { - id, manifest, provider, baseDir, integrity, contentHash: expectedHash, + id, manifest, provider, baseDir, + integrity: immutable(structuredClone(integrity)), contentHash: expectedHash, }; providers.set(id, record); return publicRecord(record); @@ -181,31 +265,61 @@ async function executeRecord(record, { stdin, model, projectRoot, expectedHash, }); if (problem) return { ok: false, stdoutText: '', stderrText: '', exitCode: null, detail: problem }; - const result = await runAdapterHook({ - hook: record.provider.hook, - hostId: record.id, - verb: 'aqe-provider', - stdin, - timeoutMs, - env: selectedEnvironment(record, env, { model, projectRoot }), - cwd: record.baseDir ?? projectRoot, - manifest: record.manifest, - integrity: record.integrity, - baseDir: record.baseDir, - }); + const selectedEnv = selectedEnvironment(record, env, { model, projectRoot }); + const secretEnv = Object.fromEntries((record.provider.hook.passEnv ?? []) + .filter((name) => typeof env?.[name] === 'string') + .map((name) => [name, env[name]])); + let snapshot; + try { + snapshot = materializeHookSnapshot(record); + } catch (error) { + return { + ok: false, stdoutText: '', stderrText: '', exitCode: null, + detail: `AQE provider '${record.id}' snapshot failed: ${error?.message ?? String(error)}`, + }; + } + let result; + try { + result = await runAdapterHook({ + hook: snapshot?.hook ?? record.provider.hook, + hostId: record.id, + verb: 'aqe-provider', + stdin, + timeoutMs, + env: selectedEnv, + cwd: snapshot?.dir ?? record.baseDir ?? projectRoot, + // The snapshot hashes and copies the exact bytes it executes. Hooks + // without adapter-owned files retain the generic immediate verifier. + ...(snapshot ? {} : { + manifest: record.manifest, + integrity: record.integrity, + baseDir: record.baseDir, + }), + }); + } finally { + if (snapshot) fs.rmSync(snapshot.dir, { recursive: true, force: true }); + } + const stderrText = redactForwardedSecrets(result.stderrText, secretEnv); + const detail = redactForwardedSecrets(result.detail, secretEnv); + if (result.stdoutTruncated) { + return { + ok: false, stdoutText: '', stderrText, exitCode: null, + detail: `AQE provider '${record.id}' completion exceeded the supervised output limit`, + }; + } if (!result.ok) { return { ok: false, stdoutText: '', - stderrText: result.stderrText ?? '', + stderrText, exitCode: result.exitCode, - detail: (result.stderrText ?? '').trim() || result.detail || `AQE provider '${record.id}' failed`, + detail: stderrText.trim() || detail || `AQE provider '${record.id}' failed`, }; } return { ok: true, stdoutText: result.stdoutText ?? '', - stderrText: result.stderrText ?? '', + stderrText, exitCode: result.exitCode, detail: null, }; diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs index ec50a8b..6a51a5f 100644 --- a/src/lib/adapters/hook-runner.mjs +++ b/src/lib/adapters/hook-runner.mjs @@ -146,7 +146,7 @@ async function killGroup(child) { * verb:string, timeoutMs?:number, env?:Record, stdin?:string, * cwd?:string, manifest?:object, integrity?:{hash:string}, baseDir?:string|null}} options * @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string, - * exitCode:number|null, detail:string|null}>} + * stdoutTruncated:boolean, stderrTruncated:boolean, exitCode:number|null, detail:string|null}>} */ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env, stdin, cwd, manifest, integrity, baseDir, @@ -180,7 +180,8 @@ export async function runAdapterHook({ verifyAdapterContent(manifest, integrity, { baseDir }); } catch (error) { return { - ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, + ok: false, stdout: '', stdoutText: '', stderrText: '', + stdoutTruncated: false, stderrTruncated: false, exitCode: null, detail: `${hostId}:${verb} adapter hook integrity check failed: ${error?.message ?? String(error)}`, }; } @@ -201,7 +202,9 @@ export async function runAdapterHook({ }); } catch (error) { return { - ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error), + ok: false, stdout: '', stdoutText: '', stderrText: '', + stdoutTruncated: false, stderrTruncated: false, + exitCode: null, detail: describeFailure(hostId, verb, error), }; } @@ -249,13 +252,17 @@ export async function runAdapterHook({ stdout: mergeCapture(stdoutCaptured, stderrCaptured), stdoutText: boundedText(stdoutCaptured), stderrText: boundedText(stderrCaptured), + stdoutTruncated: stdoutCaptured.truncated, + stderrTruncated: stderrCaptured.truncated, detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`, }; } if (spawnError) { return { - ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError), + ok: false, stdout: '', stdoutText: '', stderrText: '', + stdoutTruncated: false, stderrTruncated: false, + exitCode: null, detail: describeFailure(hostId, verb, spawnError), }; } @@ -272,9 +279,15 @@ export async function runAdapterHook({ const stderrText = boundedText(stderrCaptured); return code === 0 ? { - ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null, + ok: true, stdout, stdoutText, stderrText, + stdoutTruncated: stdoutCaptured.truncated, + stderrTruncated: stderrCaptured.truncated, + exitCode: 0, detail: null, } : { - ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`, + ok: false, stdout, stdoutText, stderrText, + stdoutTruncated: stdoutCaptured.truncated, + stderrTruncated: stderrCaptured.truncated, + exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`, }; } diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index 8844c6a..833e199 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -234,6 +234,13 @@ function validateEnvNames(value, field) { if (invalid !== undefined) { throw new ManifestRejected('invalid-aqe-provider', `${field} contains invalid environment name '${invalid}'`); } + const canonical = value.map((name) => name.toUpperCase()); + if (new Set(canonical).size !== canonical.length) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `${field} contains names that collide on case-insensitive environments`, + ); + } return [...value]; } @@ -285,16 +292,20 @@ function validateAqe(value, host, driving, execution) { validateHookFiles(provider.hook.files, 'aqe.provider.hook.files', 'invalid-aqe-provider'); const passEnv = validateEnvNames(provider.hook.passEnv, 'aqe.provider.hook.passEnv'); const stripEnv = validateEnvNames(provider.stripEnv, 'aqe.provider.stripEnv'); - const unsafePass = passEnv?.find((name) => AQE_BRIDGE_ENV.has(name) - || ENV_CODE_INJECTION.has(name) || name.startsWith('AK_AQE_')); + const unsafePass = passEnv?.find((name) => { + const canonical = name.toUpperCase(); + return AQE_BRIDGE_ENV.has(canonical) + || ENV_CODE_INJECTION.has(canonical) || canonical.startsWith('AK_AQE_'); + }); if (unsafePass) { throw new ManifestRejected('invalid-aqe-provider', `aqe.provider.hook.passEnv may not forward bridge/runtime variable '${unsafePass}'`); } - const unsafeStrip = stripEnv?.find((name) => AQE_BRIDGE_ENV.has(name)); + const unsafeStrip = stripEnv?.find((name) => AQE_BRIDGE_ENV.has(name.toUpperCase())); if (unsafeStrip) { throw new ManifestRejected('invalid-aqe-provider', `aqe.provider.stripEnv may not remove bridge runtime variable '${unsafeStrip}'`); } - const conflict = passEnv?.find((name) => stripEnv?.includes(name)); + const stripped = new Set(stripEnv?.map((name) => name.toUpperCase())); + const conflict = passEnv?.find((name) => stripped.has(name.toUpperCase())); if (conflict) { throw new ManifestRejected('invalid-aqe-provider', `environment '${conflict}' cannot appear in both passEnv and stripEnv`); } diff --git a/tests/kit/adapter-aqe-provider.test.mjs b/tests/kit/adapter-aqe-provider.test.mjs index 970ae53..7c80daf 100644 --- a/tests/kit/adapter-aqe-provider.test.mjs +++ b/tests/kit/adapter-aqe-provider.test.mjs @@ -82,13 +82,13 @@ function rawManifest(id = 'hermes', aqe = {}) { }; } -function fixture() { +function fixture({ aqe = {}, aqeHookSource, aqeHookFiles, extraFiles = {} } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-')); fs.writeFileSync(path.join(dir, 'execution-hook.mjs'), ` process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('OK')); `); - fs.writeFileSync(path.join(dir, 'aqe-hook.mjs'), ` + fs.writeFileSync(path.join(dir, 'aqe-hook.mjs'), aqeHookSource ?? ` let prompt = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) prompt += chunk; @@ -101,7 +101,12 @@ process.stdout.write(JSON.stringify({ leaked: process.env.UNLISTED_SECRET ?? null })); `); - const manifest = validateAdapterManifest(rawManifest()); + for (const [file, content] of Object.entries(extraFiles)) { + fs.writeFileSync(path.join(dir, file), content); + } + const raw = rawManifest('hermes', aqe); + if (aqeHookFiles) raw.aqe.provider.hook.files = aqeHookFiles; + const manifest = validateAdapterManifest(raw); const integrity = hashAdapterContent(manifest, { baseDir: dir }); return { dir, manifest, integrity }; } @@ -129,6 +134,21 @@ test('aqe.provider is strict, host-derived, normalized candidate data', () => { () => validateAdapterManifest(injected), (error) => error.reason === 'invalid-aqe-provider', ); + for (const reserved of ['node_options', 'AK_AQE_MODEL', 'ak_aqe_provider', 'AK_AQE_PROJECT_CWD']) { + const reservedManifest = rawManifest(); + reservedManifest.aqe.provider.hook.passEnv = [reserved]; + assert.throws( + () => validateAdapterManifest(reservedManifest), + (error) => error.reason === 'invalid-aqe-provider', + `${reserved} must be rejected case-insensitively`, + ); + } + const caseCollision = rawManifest(); + caseCollision.aqe.provider.hook.passEnv = ['HERMES_TOKEN', 'hermes_token']; + assert.throws( + () => validateAdapterManifest(caseCollision), + (error) => error.reason === 'invalid-aqe-provider' && /case-insensitive/.test(error.message), + ); assert.throws( () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['PATH'] })), (error) => error.reason === 'invalid-aqe-provider', @@ -225,10 +245,6 @@ test('bootstrap activates only an admitted, enabled, hash-current aqeProvider gr resetAdmitted(); }); - recordTierResult('hermes', 'aqe-provider', { - hash: integrity.hash, evidence: 'real AQE stdin/stdout provider probe returned OK', - }); - grantCapability('hermes', 'aqeProvider', { hash: integrity.hash }); const consent = { recordedHashFor: () => integrity.hash, isTrusted: (_name, hash) => hash === integrity.hash, @@ -237,6 +253,30 @@ test('bootstrap activates only an admitted, enabled, hash-current aqeProvider gr hostAdapters: [{ name: 'hermes', source: manifestFile }], integrations: { hosts: { hermes: true } }, }; + const ungranted = await bootstrapHostAdapters({ + cfg, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(ungranted.admitted.length, 1); + assert.equal(admittedAqeProviderFor('hermes'), null, + 'admission without an explicit capability grant must not activate the provider'); + + const staleHash = 'f'.repeat(64); + recordTierResult('hermes', 'aqe-provider', { + hash: staleHash, evidence: 'stale AQE provider evidence', + }); + grantCapability('hermes', 'aqeProvider', { hash: staleHash }); + await bootstrapHostAdapters({ + cfg, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, + }); + assert.equal(admittedAqeProviderFor('hermes'), null, + 'a grant at a stale content hash must not activate the provider'); + + recordTierResult('hermes', 'aqe-provider', { + hash: integrity.hash, evidence: 'real AQE stdin/stdout provider probe returned OK', + }); + grantCapability('hermes', 'aqeProvider', { hash: integrity.hash }); const active = await bootstrapHostAdapters({ cfg, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, readManifest: async () => manifest, consent, @@ -268,6 +308,80 @@ test('bootstrap activates only an admitted, enabled, hash-current aqeProvider gr assert.equal(admittedAqeProviderFor('hermes'), null, 'a disabled host clears the live provider snapshot'); }); +test('provider executes a private snapshot of every declared adapter-owned file', async () => { + const { dir, manifest, integrity } = fixture({ + aqeHookFiles: ['aqe-hook.mjs', 'dependency.mjs'], + extraFiles: { 'dependency.mjs': "export default 'SAFE';\n" }, + aqeHookSource: ` +import fs from 'node:fs'; +import path from 'node:path'; +let prompt = ''; +process.stdin.setEncoding('utf8'); +for await (const chunk of process.stdin) prompt += chunk; +fs.writeFileSync(path.join(process.env.AK_AQE_PROJECT_CWD, 'dependency.mjs'), "export default 'MALICIOUS';\\n"); +const { default: value } = await import('./dependency.mjs'); +process.stdout.write(value); +`, + }); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity }); + const result = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'default', projectRoot: dir, + }); + assert.equal(result.ok, true, result.detail); + assert.equal(result.stdoutText, 'SAFE'); + assert.match(fs.readFileSync(path.join(dir, 'dependency.mjs'), 'utf8'), /MALICIOUS/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('provider failure redacts forwarded secret values from stderr and detail', async () => { + const secret = 'very-secret-provider-token'; + const { dir, manifest, integrity } = fixture({ + aqeHookSource: ` +process.stdin.resume(); +process.stdin.on('end', () => { + process.stderr.write(process.env.BRIDGE_TOKEN ?? 'missing'); + process.exit(9); +}); +`, + }); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity }); + const result = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'default', projectRoot: dir, env: { BRIDGE_TOKEN: secret }, + }); + assert.equal(result.ok, false); + assert.equal(result.stdoutText, ''); + assert.doesNotMatch(result.stderrText, new RegExp(secret)); + assert.doesNotMatch(result.detail, new RegExp(secret)); + assert.match(result.stderrText, //); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('oversized provider stdout fails closed instead of returning a partial completion', async () => { + const { dir, manifest, integrity } = fixture({ + aqeHookSource: ` +process.stdin.resume(); +process.stdin.on('end', () => process.stdout.write('x'.repeat(300 * 1024))); +`, + }); + try { + registerAdmittedAqeProvider(manifest, { baseDir: dir, integrity }); + const result = await runAdmittedAqeProvider('hermes', { + stdin: 'prompt', model: 'default', projectRoot: dir, + }); + assert.equal(result.ok, false); + assert.equal(result.stdoutText, ''); + assert.match(result.detail, /output limit/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('production bridge uses supervised stdin/model/env/cwd path and strips unrelated secrets', async () => { const { dir, manifest, integrity } = fixture(); try { diff --git a/tests/kit/adapter-hook-runner.test.mjs b/tests/kit/adapter-hook-runner.test.mjs index b29143a..c89ab61 100644 --- a/tests/kit/adapter-hook-runner.test.mjs +++ b/tests/kit/adapter-hook-runner.test.mjs @@ -85,6 +85,8 @@ test('combined stdout+stderr output is capped at 256KB with a truncation marker' hostId: 'claude', verb: 'discover', }); assert.equal(result.ok, true); + assert.equal(result.stdoutTruncated, true); + assert.equal(result.stderrTruncated, false); assert.ok(Buffer.byteLength(result.stdout, 'utf8') <= 256 * 1024 + 200); assert.ok(/truncated/i.test(result.stdout)); }); From fec9cc0a93d7a5b9249d365af1b4a8ddc48e16a0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:07:39 -0700 Subject: [PATCH 06/21] fix(providers): preserve external provider lifecycle --- src/lib/providers.mjs | 21 ++++++++++--- tests/kit/providers-external.test.mjs | 43 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index d9ec7ad..be4ee0e 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -466,9 +466,17 @@ function reconcileExternalProviders(existing, desired = aqeExternalProviders()) const currentHash = currentDeclaration === undefined ? null : declarationHash(currentDeclaration); if (currentDeclaration !== undefined && (!prior || currentHash !== prior.writtenHash)) { conflicts.push(id); - // A changed ak entry becomes user-owned at the point of drift; keeping a - // stale receipt would allow a later sync to delete it accidentally. - delete receipts[id]; + // A changed declaration becomes user-owned immediately. Its activation + // has independent ownership, though: retain only an exact activation + // receipt so a later revoke can remove the minimal record ak created + // without ever deleting the edited declaration. + const currentActivation = currentProviders[id]; + if (prior?.providerWrittenHash && currentActivation !== undefined + && declarationHash(currentActivation) === prior.providerWrittenHash) { + receipts[id] = { providerWrittenHash: prior.providerWrittenHash }; + } else { + delete receipts[id]; + } continue; } current[id] = declaration; @@ -607,7 +615,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const priorOverrides = existing.agentOverrides ?? {}; let projected = configuredPolicyToAgentOverrides(policy); const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP)); - const staleOverrides = Object.keys(priorOverrides) + let staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal && staleOverrides.length === 0) { return { ok: true, changed: false, detail: 'no aqe router config to apply' }; @@ -665,6 +673,11 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { } projected = Object.fromEntries(Object.entries(projected).filter(([, entry]) => !(entry.provider in desiredExternal) || externalActive.has(entry.provider))); + // Admission/version/conflict filtering can make a previously projected + // external route inactive after the first stale calculation. Recompute from + // the safe projection so ak-owned overrides never retain an unusable id. + staleOverrides = Object.keys(priorOverrides) + .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); let chainError = null; if (hasChain) { diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 1169807..527a444 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -61,6 +61,7 @@ function registerHermes() { }); const integrity = hashAdapterContent(manifest, { baseDir }); registerAdmittedAqeProvider(manifest, { baseDir, integrity, contentHash: integrity.hash }); + return { baseDir, manifest, integrity }; } function project() { @@ -99,6 +100,34 @@ test('AQE 3.13.12 projection writes a project-only default and ownership receipt assert.equal(managedEnv(cfg()).AQE_LLM_PROVIDER, undefined, 'external default never leaks into settings env'); }); +test('an owned declaration refreshes atomically when admitted provider content changes', () => { + fakeAqe('3.13.12'); + const admitted = registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg(), dir).ok, true); + const before = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + const beforeCommand = before.externalProviders.hermes.command; + const beforeReceipt = before._agenticKit.externalProviders.hermes; + + fs.appendFileSync(path.join(admitted.baseDir, 'provider.mjs'), '\n// admitted provider update\n'); + const nextIntegrity = hashAdapterContent(admitted.manifest, { baseDir: admitted.baseDir }); + registerAdmittedAqeProvider(admitted.manifest, { + baseDir: admitted.baseDir, + integrity: nextIntegrity, + contentHash: nextIntegrity.hash, + }); + const result = applyAqeRouter(cfg(), dir); + const after = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + const afterCommand = after.externalProviders.hermes.command; + const afterReceipt = after._agenticKit.externalProviders.hermes; + + assert.equal(result.ok, true, result.detail); + assert.notDeepEqual(afterCommand, beforeCommand); + assert.equal(afterCommand[afterCommand.indexOf('--expect-hash') + 1], nextIntegrity.hash); + assert.equal(afterReceipt.contentHash, nextIntegrity.hash); + assert.notEqual(afterReceipt.writtenHash, beforeReceipt.writtenHash); +}); + test('foreign same-id declarations are preserved and refused', () => { fakeAqe('3.13.12'); registerHermes(); const dir = project(); @@ -129,11 +158,21 @@ test('stale owned declarations are pruned but edited declarations become user-ow disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); disk.externalProviders.hermes.displayName = 'User override'; fs.writeFileSync(aqeRouterFile(dir), JSON.stringify(disk)); + + result = applyAqeRouter(cfg(), dir); + disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, false); + assert.equal(disk.externalProviders.hermes.displayName, 'User override'); + assert.deepEqual(Object.keys(disk._agenticKit.externalProviders.hermes), ['providerWrittenHash'], + 'declaration ownership is relinquished while exact activation ownership remains'); + resetAdmittedAqeProviders(); result = applyAqeRouter({ ...cfg(), routing: { routes: {} }, providers: { aqeProvider: null, aqeFallback: [] } }, dir); disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); assert.equal(result.ok, true); assert.equal(disk.externalProviders.hermes.displayName, 'User override'); + assert.equal(disk.providers?.hermes, undefined, + 'the unchanged ak-created activation is pruned independently of the edited declaration'); assert.equal(disk._agenticKit, undefined, 'receipt relinquished after user edit'); }); @@ -157,6 +196,8 @@ test('user-owned provider activation is preserved and explicit disablement is re disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); assert.equal(result.ok, false); assert.equal(disk.providers.hermes.enabled, false); + assert.equal(disk.agentOverrides?.['qe-test-architect'], undefined, + 'an inactive external provider is pruned from ak-managed agent overrides'); assert.match(result.detail, /enabled is not true/); }); @@ -191,5 +232,7 @@ test('AQE downgrade prunes only unchanged owned declarations and dangling refere assert.equal(disk.providers, undefined); assert.equal(disk.defaultProvider, undefined); assert.equal(disk.fallbackChain, undefined); + assert.equal(disk.agentOverrides?.['qe-test-architect'], undefined, + 'downgrade prunes ak-managed overrides that reference the unavailable provider'); assert.equal(disk._agenticKit, undefined); }); From 6d44d8cde65b275cbd0246f5cd196d92a6654199 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:07:39 -0700 Subject: [PATCH 07/21] fix(host): keep admitted providers selectable --- src/commands/x/host.mjs | 27 ++++++++++--- tests/kit/provider-cli.test.mjs | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 4e78c99..2c261bb 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -21,7 +21,7 @@ import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs'; import { hostTierLabel, hostAsymmetryNote } from '../../lib/hosts.mjs'; import { - routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, + routableHostIds, effectiveRoutableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, } from '../../lib/adapters/index.mjs'; import { newlyEnabledHostTrustManifest, trustManifestLines, @@ -459,7 +459,13 @@ async function pick({ flags, cwd, pkgRoot }) { // primary/AQE host because those are separate registry capabilities. // --host is the complete desired enabled-host set on BOTH tiers; excluding an // enabled host disables it (ak-managed wiring stripped, user config kept). + // Keep primary-host selection on the built-in routing set, but admit an + // explicitly named external host when the live adapter overlay proves it is + // routable. Provider-only retunes also carry already-enabled external ids + // through unchanged instead of mistaking them for unknown host tokens. const ROUTING = new Set(routableHostIds()); + const EFFECTIVE_ROUTING = new Set(effectiveRoutableHostIds()); + const MANAGED_HOSTS = new Set(HOSTS.map((host) => host.id)); const prevOpencode = !!cfg.integrations?.hosts?.opencode || cfg.integrations?.ownership?.opencode?.mcp === 'ak'; let enabled; @@ -528,7 +534,7 @@ async function pick({ flags, cwd, pkgRoot }) { // validate hosts against the two tiers. An unknown token is a hard error, // never a silent drop: `--host claude,opencdoe` must not "succeed" as // claude-only and destructively tear the opencode host down (codex-review r3). - const known = new Set(HOSTS.map((h) => h.id)); + const known = new Set([...MANAGED_HOSTS, ...EFFECTIVE_ROUTING]); const unknown = enabled.filter((h) => !known.has(h)); if (unknown.length) { fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`); @@ -537,8 +543,11 @@ async function pick({ flags, cwd, pkgRoot }) { // The routing set needs at least one primary-capable member; OpenCode remains // routable but cannot satisfy that primary-host invariant on its own. const routing = enabled.filter((h) => ROUTING.has(h)); - if (!routing.some((h) => PRIMARY_HOSTS.includes(h))) routing.unshift('claude'); - enabled = [...new Set(routing)]; + if (!routing.some((h) => PRIMARY_HOSTS.includes(h))) { + routing.unshift('claude'); + enabled.unshift('claude'); + } + enabled = [...new Set(enabled)]; // primary host — which host leads (default claude); must be a ROUTING host. let primaryHost = prevPrimary; if (flags['primary-host'] !== undefined) { @@ -585,11 +594,19 @@ async function pick({ flags, cwd, pkgRoot }) { models, maxBudgetUsd: cfg.providers.maxBudgetUsd ?? null, }; - cfg.integrations.hosts = { + const hostIntent = { claude: routing.includes('claude'), codex: routing.includes('codex'), opencode: enabled.includes('opencode'), }; + // External host ids are not primary candidates, but they are first-class + // integration intent. Retain every live admitted external id as an explicit + // boolean so a provider-only pick cannot deactivate its own bridge; an + // explicit --host set can still disable it by omission. + for (const id of EFFECTIVE_ROUTING) { + if (!MANAGED_HOSTS.has(id)) hostIntent[id] = enabled.includes(id); + } + cfg.integrations.hosts = hostIntent; cfg.routing.primaryHost = primaryHost; cfg.routing.routes = reseedForPrimary ? {} : { ...oldPolicy }; // Multi-host: seed per-activity routing from defaults (only when the policy is diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index bb966bb..54c1bbc 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -15,6 +15,10 @@ import path from 'node:path'; import { DUAL_ROLE_TIP, JUDGE_BIAS_TIP } from '../../src/lib/providers.mjs'; import { parseFallback, parseModels } from '../../src/commands/x/host.mjs'; import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; +import { recordConsent } from '../../src/lib/adapters/consent.mjs'; +import { grantCapability, recordTierResult } from '../../src/lib/adapters/grants.mjs'; // Tripwire (#137): a spawned `ak x host pick` whose cwd falls back to the test // process's cwd writes PROJECT-scoped config (.claude/settings.local.json, @@ -224,6 +228,43 @@ const kitJson = (home) => JSON.parse(fs.readFileSync(path.join(home, '.config', const ocJsonPath = (home) => path.join(home, '.config', 'opencode', 'opencode.json'); const ocJson = (home) => JSON.parse(fs.readFileSync(ocJsonPath(home), 'utf8')); +function configureExternalAqeProvider({ home, project }) { + const adapterDir = path.join(project, 'hermes-adapter'); + fs.mkdirSync(adapterDir, { recursive: true }); + const command = [process.execPath, '-e', 'process.stdin.pipe(process.stdout)']; + const manifest = validateAdapterManifest({ + name: 'hermes', version: '1.0.0', contract: 1, + host: { + id: 'hermes', label: 'Hermes', + install: { bin: 'node', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, configProjection: 'ruflo', observability: [], + }, + detection: { bin: 'node' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command } } }, + aqe: { provider: { hook: { command }, models: ['default'], defaultModel: 'default' } }, + trust: { changes: [] }, + }); + const manifestFile = path.join(adapterDir, 'manifest.json'); + fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`); + const hash = hashAdapterContent(manifest, { baseDir: adapterDir }).hash; + const configDir = path.join(home, '.config', 'agentic-kit'); + const cfg = kitJson(home); + cfg.hostAdapters = [{ name: 'hermes', source: manifestFile, contract: 1 }]; + cfg.integrations.hosts.hermes = true; + fs.writeFileSync(path.join(configDir, 'kit.json'), `${JSON.stringify(cfg, null, 2)}\n`); + recordConsent('hermes', hash, { file: path.join(configDir, 'adapter-consent.json') }); + const grantsFile = path.join(configDir, 'adapter-grants.json'); + recordTierResult('hermes', 'aqe-provider', { hash, evidence: 'provider CLI regression' }, { file: grantsFile }); + grantCapability('hermes', 'aqeProvider', { hash }, { file: grantsFile }); +} + test('pick --host claude,opencode enables + wires opencode (config, plugin, agents, skill), preserving user config', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false } }); try { @@ -344,6 +385,32 @@ test('a provider retune retires stale legacy Codex MCP while preserving current } }); +test('external provider selection accepts the effective host and provider-only retunes preserve its enablement', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb); + const env = { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }; + + const explicit = akPick(['x', 'host', 'pick', '--host', 'claude,hermes', '--yes'], sb, { env }); + assert.equal(explicit.status, 0, `explicit external host failed\nstdout: ${explicit.stdout}\nstderr: ${explicit.stderr}`); + assert.doesNotMatch(explicit.stdout + explicit.stderr, /unknown host\(s\): hermes/); + assert.equal(kitJson(sb.home).integrations.hosts.hermes, true, + 'an admitted external host is valid in an explicit complete host set'); + + const providerOnly = akPick(['x', 'host', 'pick', '--aqe-provider', 'hermes', '--yes'], sb, { env }); + assert.equal(providerOnly.status, 0, + `provider-only external selection failed\nstdout: ${providerOnly.stdout}\nstderr: ${providerOnly.stderr}`); + assert.doesNotMatch(providerOnly.stdout + providerOnly.stderr, /unknown host\(s\): hermes/); + const cfg = kitJson(sb.home); + assert.equal(cfg.providers.aqeProvider, 'hermes', 'external provider selection is persisted'); + assert.equal(cfg.integrations.hosts.hermes, true, + 'provider-only selection does not deactivate the bridge that makes the external provider live'); + assert.equal(cfg.routing.primaryHost, 'claude', 'external host admission does not broaden primary-host selection'); + } finally { + rm(sb.home, sb.project); + } +}); + test('host off clears the OpenCode catalog override after a successful teardown', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false }, From 2fa68047894e002bddaad01bee42dc3864e6d2c9 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:26:11 -0700 Subject: [PATCH 08/21] fix(adapters): reauthorize external providers at execution --- docs/AUTHORING-HOST-ADAPTERS.md | 16 +++++--- docs/PROVIDERS.md | 17 +++++--- src/lib/adapters/admission.mjs | 26 +++++++++++- src/lib/adapters/aqe-provider.mjs | 54 ++++++++++++++++++++----- src/lib/adapters/manifest.mjs | 7 ++++ tests/kit/adapter-aqe-provider.test.mjs | 52 +++++++++++++++++++++++- 6 files changed, 148 insertions(+), 24 deletions(-) diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md index 0e0bb9a..53d5bca 100644 --- a/docs/AUTHORING-HOST-ADAPTERS.md +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -168,14 +168,20 @@ print partial output before success: the bridge intentionally suppresses stdout timeout, and failure because AQE treats non-empty stdout as a completion even on a non-zero exit. Billing mode is declared provenance, not a verified charge or vendor fact. On every call the bridge re-verifies and copies all declared adapter-owned hook files into a private -execution snapshot. Relative imports therefore resolve to the verified copies, not mutable source -files. List every adapter-owned imported file in `hook.files`; interpreter and other absolute- or -PATH-resolved native binaries are external system trust and are not covered by the adapter hash. +execution snapshot. Declared command paths and relative imports therefore resolve to the verified +copies, not mutable source files. This is not an OS sandbox: do not use absolute paths to reach the +adapter bundle from hook code. List every adapter-owned imported file in `hook.files`; interpreter +and other absolute- or PATH-resolved native binaries are external system trust and are not covered +by the adapter hash. The bridge also rechecks live host intent, consent, and the hash-pinned grant +immediately before spawn. Forwarded secret values are redacted from failure diagnostics. Output over the supervised bound is discarded and reported as failure, never accepted as a partial completion. The boundary is deliberately finite: at most 128 model ids (256 UTF-8 bytes each), a control-free -128-byte display name, `maxConcurrency` from 1 through 64, and a provider-hook timeout no longer -than 24 hours. Oversized declarations are refused before consent or projection. +128-byte display name, `maxConcurrency` from 1 through 64, a provider-hook timeout no longer than +24 hours, and canonical uppercase `stripEnv` names. Oversized or case-ambiguous declarations are +refused before consent or projection. On Windows, projection adds the matching spelling observed +in the parent environment because AQE 3.13.12 deletes exact object keys; the adapter hook's own +environment remains minimal and allowlisted regardless. ### One structural coupling worth knowing diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 682d3dd..013a0b5 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -186,15 +186,22 @@ stdout. Exit `77`/`78`, timeout, auth failure, and any other error produce no st 3.13.12 treats non-empty stdout as a completion even when a CLI exits non-zero. The bridge forwards only declared environment variables, protects its own `AK_AQE_*` control variables, rechecks the content hash before spawn, and keeps the hook in a supervised subprocess. Each invocation copies -the verified bytes of every declared adapter-owned hook file into a private execution snapshot, -closing the verify-to-spawn rewrite race while preserving relative imports among declared files. -The interpreter and other absolute/PATH-resolved native binaries remain externally managed system -trust, not part of the adapter content hash. Declare every adapter-owned imported file. Forwarded +the verified bytes of every declared adapter-owned hook file into a private execution snapshot; +declared command-file arguments and relative imports resolve to those copies. This is byte pinning, +not an OS sandbox: a consented hook can still deliberately access an absolute path, so adapter-owned +imports must be relative. The interpreter and other absolute/PATH-resolved native binaries remain +externally managed system trust, not part of the adapter content hash. Declare every adapter-owned +imported file. The bridge re-reads host enablement, consent, and the exact-hash grant immediately +before spawn, so revocation while it waits for a prompt fails closed. Forwarded secret values are redacted from bridge diagnostics, and a completion that exceeds the supervised output bound fails closed instead of returning a truncated success. Candidate metadata is bounded before admission: at most 128 model ids of 256 UTF-8 bytes each, a control-free display name of at most 128 bytes, concurrency from 1 through 64, and a provider-hook -timeout no longer than 24 hours. +timeout no longer than 24 hours. `stripEnv` names must use canonical uppercase spelling; this keeps +the contract unambiguous, and projection also includes the exact spelling observed in the current +parent environment for AQE 3.13.12's exact-key deletion on Windows. Independently of that +defense-in-depth filter, the stable trampoline forwards only explicitly allowlisted variables to +the adapter hook. Graduation has two destinations: a **blessed external adapter** stays out-of-tree holding exactly the capabilities its tiers earned, or a maintainer **promotes it to a built-in** by adopting its diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index a46c8c7..8e3bf10 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -143,10 +143,11 @@ async function defaultReadManifest(source) { * for tests; production relies on the defaults (a plain fs+JSON.parse reader, * and a dynamic import of the sibling consent store in ./consent.mjs). * @param {{ cfg?: any, env?: NodeJS.ProcessEnv, readManifest?: (source: string) => Promise, - * consent?: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} [args] + * consent?: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean }, + * currentConfig?:()=>any|Promise }} [args] */ export async function bootstrapHostAdapters({ - cfg, env = process.env, readManifest = defaultReadManifest, consent, + cfg, env = process.env, readManifest = defaultReadManifest, consent, currentConfig, } = {}) { if (env?.AK_EXPERIMENTAL_HOST_ADAPTERS !== '1') return { active: false, admitted: [], warnings: [] }; @@ -211,8 +212,10 @@ export async function bootstrapHostAdapters({ // both the validated manifest and any declared hook bytes, so a file edit // cannot leave a capability grant live under the old content hash. let grantsByName; + let grantedCapabilitiesForCurrentHash; try { const { grantedCapabilitiesFor } = await import('./grants.mjs'); + grantedCapabilitiesForCurrentHash = grantedCapabilitiesFor; // Object.create(null), not {} (F-6): admitted host ids come from // consented adapter names, which are attacker-influenceable in // principle — a plain object literal's prototype chain would make @@ -262,6 +265,25 @@ export async function bootstrapHostAdapters({ baseDir: baseDirForSource(sourceByName.get(result.name)), integrity: result.integrity, contentHash: result.contentHash, + // Bootstrap admission is a snapshot; execution authority is not. + // The hidden AQE trampoline may wait on stdin while another + // process revokes consent/grant or disables the host. Re-read all + // three gates after prompt collection and snapshot capture, + // immediately before spawn. Any read/shape failure denies. + authorize: async () => { + try { + const liveCfg = currentConfig + ? await currentConfig() + : (await import('../config.mjs')).loadKitConfig(); + if (liveCfg?.integrations?.hosts?.[result.name] !== true) return false; + const liveHash = result.contentHash; + if (consentStore.recordedHashFor(result.name) !== liveHash + || !consentStore.isTrusted(result.name, liveHash)) return false; + return grantedCapabilitiesForCurrentHash(result.name, liveHash)?.aqeProvider === true; + } catch { + return false; + } + }, }); } catch (error) { warnings.push({ diff --git a/src/lib/adapters/aqe-provider.mjs b/src/lib/adapters/aqe-provider.mjs index 49cf689..8297961 100644 --- a/src/lib/adapters/aqe-provider.mjs +++ b/src/lib/adapters/aqe-provider.mjs @@ -58,6 +58,12 @@ function providerMetadata(id, provider) { }; } +function projectedStripEnv(declared, env) { + const canonical = new Set(declared.map((name) => name.toUpperCase())); + const observed = Object.keys(env ?? {}).filter((name) => canonical.has(name.toUpperCase())); + return [...new Set([...declared, ...observed])]; +} + function publicRecord(record) { return immutable({ id: record.id, @@ -91,11 +97,12 @@ function snapshotCommand(command, baseDir, snapshotDir, inventory) { }); } -/** Copy the exact verified bytes into a private execution - * snapshot. Relative imports and declared file arguments resolve here, so a - * rename/write after verification cannot change the bytes this invocation - * executes. Interpreter and host binaries remain externally-managed system - * trust; the adapter-owned files are the snapshot boundary. */ +/** Copy the exact verified bytes into a private execution snapshot. Declared + * command-file arguments and relative imports resolve here, so a rename/write + * after verification cannot change those bytes for this invocation. This is + * byte pinning, not an OS sandbox: a consented hook can still deliberately + * access an absolute path. Interpreter and host binaries remain externally + * managed system trust. */ function materializeHookSnapshot(record) { const declared = record.integrity.hookFiles ?? []; if (declared.length === 0) return null; @@ -154,9 +161,10 @@ function redactForwardedSecrets(value, env) { * content immediately so a file edit between admission and registration * cannot create a live bridge. * @param {any} manifest - * @param {{baseDir?:string|null, integrity?:any, contentHash?:string}} [options] */ + * @param {{baseDir?:string|null, integrity?:any, contentHash?:string, + * authorize?:()=>boolean|Promise}} [options] */ export function registerAdmittedAqeProvider(manifest, { - baseDir = null, integrity, contentHash, + baseDir = null, integrity, contentHash, authorize, } = {}) { const { id, provider } = requireManifest(manifest); if (!integrity || typeof integrity.hash !== 'string' || !integrity.hash) { @@ -171,9 +179,13 @@ export function registerAdmittedAqeProvider(manifest, { if (baseDir == null && (argv0.includes('/') || argv0.includes('\\')) && !path.isAbsolute(argv0)) { throw new TypeError(`AQE provider '${id}' has a relative hook command but no retained adapter directory`); } + if (authorize !== undefined && typeof authorize !== 'function') { + throw new TypeError(`AQE provider '${id}' authorization check must be a function`); + } const record = { id, manifest, provider, baseDir, integrity: immutable(structuredClone(integrity)), contentHash: expectedHash, + authorize: authorize ?? null, }; providers.set(id, record); return publicRecord(record); @@ -196,7 +208,7 @@ export function admittedAqeProviderFor(id) { } /** AQE v3.13.12 externalProviders declarations for the currently-live set. */ -export function projectedAqeExternalProviders({ projectRoot = process.cwd() } = {}) { +export function projectedAqeExternalProviders({ projectRoot = process.cwd(), env = process.env } = {}) { if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) { throw new TypeError('AQE external-provider projection requires an absolute project root'); } @@ -218,7 +230,12 @@ export function projectedAqeExternalProviders({ projectRoot = process.cwd() } = defaultModel: meta.defaultModel, modelFlag: '--model', maxConcurrency: meta.maxConcurrency, - stripEnv: [...meta.stripEnv], + // AQE 3.13.12 deletes exact object keys. Include the actual spelling + // observed in this parent as well as the contract's canonical uppercase + // name so Windows mixed-case environment storage is removed before the + // stable trampoline starts. The trampoline independently forwards only + // allowlisted variables to the untrusted adapter hook. + stripEnv: projectedStripEnv(meta.stripEnv, env), displayName: meta.displayName, ...(meta.timeoutMs === undefined ? {} : { timeoutMs: meta.timeoutMs }), }; @@ -280,6 +297,20 @@ async function executeRecord(record, { } let result; try { + if (record.authorize) { + let authorized = false; + try { + authorized = await record.authorize(); + } catch { + authorized = false; + } + if (authorized !== true) { + return { + ok: false, stdoutText: '', stderrText: '', exitCode: null, + detail: `AQE provider '${record.id}' authorization is no longer current`, + }; + } + } result = await runAdapterHook({ hook: snapshot?.hook ?? record.provider.hook, hostId: record.id, @@ -288,8 +319,9 @@ async function executeRecord(record, { timeoutMs, env: selectedEnv, cwd: snapshot?.dir ?? record.baseDir ?? projectRoot, - // The snapshot hashes and copies the exact bytes it executes. Hooks - // without adapter-owned files retain the generic immediate verifier. + // The snapshot hashes/copies the command and relative-import bytes it + // executes. Hooks without adapter-owned files retain the immediate + // generic verifier. ...(snapshot ? {} : { manifest: record.manifest, integrity: record.integrity, diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index 833e199..c97a6bf 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -292,6 +292,13 @@ function validateAqe(value, host, driving, execution) { validateHookFiles(provider.hook.files, 'aqe.provider.hook.files', 'invalid-aqe-provider'); const passEnv = validateEnvNames(provider.hook.passEnv, 'aqe.provider.hook.passEnv'); const stripEnv = validateEnvNames(provider.stripEnv, 'aqe.provider.stripEnv'); + const nonCanonicalStrip = stripEnv?.find((name) => name !== name.toUpperCase()); + if (nonCanonicalStrip) { + throw new ManifestRejected( + 'invalid-aqe-provider', + `aqe.provider.stripEnv must use canonical uppercase environment names (found '${nonCanonicalStrip}')`, + ); + } const unsafePass = passEnv?.find((name) => { const canonical = name.toUpperCase(); return AQE_BRIDGE_ENV.has(canonical) diff --git a/tests/kit/adapter-aqe-provider.test.mjs b/tests/kit/adapter-aqe-provider.test.mjs index 7c80daf..fe93988 100644 --- a/tests/kit/adapter-aqe-provider.test.mjs +++ b/tests/kit/adapter-aqe-provider.test.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'; import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; import { resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; -import { grantCapability, recordTierResult } from '../../src/lib/adapters/grants.mjs'; +import { grantCapability, recordTierResult, revokeCapability } from '../../src/lib/adapters/grants.mjs'; import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; import { admittedAqeProviderFor, @@ -153,6 +153,10 @@ test('aqe.provider is strict, host-derived, normalized candidate data', () => { () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['PATH'] })), (error) => error.reason === 'invalid-aqe-provider', ); + assert.throws( + () => validateAdapterManifest(rawManifest('hermes', { stripEnv: ['openai_api_key'] })), + (error) => error.reason === 'invalid-aqe-provider' && /canonical uppercase/.test(error.message), + ); assert.throws( () => validateAdapterManifest(rawManifest('hermes', { maxConcurrency: 65 })), (error) => error.reason === 'invalid-aqe-provider' && /<= 64/.test(error.message), @@ -225,6 +229,12 @@ test('live provider receipts are immutable and projection exposes no manifest in assert.equal(projected.hermes.modelFlag, '--model'); assert.equal(projected.hermes.timeoutMs, 7500); assert.deepEqual(projected.hermes.stripEnv, ['OPENAI_API_KEY']); + + const mixedCaseEnv = projectedAqeExternalProviders({ + projectRoot: process.cwd(), env: { OpenAi_Api_Key: 'must-not-reach-trampoline' }, + }); + assert.deepEqual(mixedCaseEnv.hermes.stripEnv, ['OPENAI_API_KEY', 'OpenAi_Api_Key'], + 'projection includes the exact observed spelling for AQE 3.13.12 exact-key deletion'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -336,6 +346,46 @@ process.stdout.write(value); } }); +test('provider rechecks live host, consent, and grant authority immediately before spawn', async (t) => { + const priorXdg = process.env.XDG_CONFIG_HOME; + const grantHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-live-grant-')); + process.env.XDG_CONFIG_HOME = grantHome; + const { dir, manifest, integrity } = fixture(); + const manifestFile = path.join(dir, 'manifest.json'); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const cfg = { + hostAdapters: [{ name: 'hermes', source: manifestFile }], + integrations: { hosts: { hermes: true } }, + }; + const consent = { + recordedHashFor: () => integrity.hash, + isTrusted: (_name, hash) => hash === integrity.hash, + }; + t.after(() => { + process.env.XDG_CONFIG_HOME = priorXdg; + fs.rmSync(grantHome, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + resetAdmittedAqeProviders(); + resetAdmitted(); + }); + + recordTierResult('hermes', 'aqe-provider', { + hash: integrity.hash, evidence: 'real AQE stdin/stdout provider probe returned OK', + }); + grantCapability('hermes', 'aqeProvider', { hash: integrity.hash }); + await bootstrapHostAdapters({ + cfg, env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, consent, currentConfig: () => cfg, + }); + assert.equal(revokeCapability('hermes', 'aqeProvider'), true); + const result = await runAdmittedAqeProvider('hermes', { + stdin: 'must not execute', model: 'default', projectRoot: dir, + }); + assert.equal(result.ok, false); + assert.equal(result.stdoutText, ''); + assert.match(result.detail, /authorization is no longer current/); +}); + test('provider failure redacts forwarded secret values from stderr and detail', async () => { const secret = 'very-secret-provider-token'; const { dir, manifest, integrity } = fixture({ From 9ff7fdd9743c4c150cbc84fc068dae42f6cbb480 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:26:11 -0700 Subject: [PATCH 09/21] fix(providers): close external ownership lifecycle gaps --- src/lib/providers.mjs | 96 +++++++++++++++++++++++---- tests/kit/providers-external.test.mjs | 79 ++++++++++++++++++++-- 2 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index be4ee0e..ccee1b6 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -439,6 +439,32 @@ function declarationHash(value) { return createHash('sha256').update(JSON.stringify(stableValue(value))).digest('hex'); } +function plainRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : null; +} + +function exactlyOwnedExternalDefault(config) { + const provider = config?.defaultProvider; + const receipt = plainRecord(config?.[AQE_OWNERSHIP_KEY]?.externalDefaultProvider); + return typeof provider === 'string' && receipt?.provider === provider + && receipt.writtenHash === declarationHash(provider) + ? provider + : null; +} + +function setExternalDefaultOwnership(config, provider) { + const ownership = { ...(plainRecord(config[AQE_OWNERSHIP_KEY]) ?? {}) }; + ownership.externalDefaultProvider = { provider, writtenHash: declarationHash(provider) }; + config[AQE_OWNERSHIP_KEY] = ownership; +} + +function clearExternalDefaultOwnership(config) { + const ownership = { ...(plainRecord(config[AQE_OWNERSHIP_KEY]) ?? {}) }; + delete ownership.externalDefaultProvider; + if (Object.keys(ownership).length) config[AQE_OWNERSHIP_KEY] = ownership; + else delete config[AQE_OWNERSHIP_KEY]; +} + function admittedProviderRecord(id) { const records = admittedAqeProviders(); return (Array.isArray(records) ? records : Object.values(records ?? {})) @@ -451,10 +477,17 @@ function admittedProviderRecord(id) { function reconcileExternalProviders(existing, desired = aqeExternalProviders()) { const current = { ...(existing.externalProviders ?? {}) }; const currentProviders = { ...(existing.providers ?? {}) }; - const priorReceipts = { ...(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}) }; + // Ownership metadata is advisory proof, never trusted input. A null/array/ + // primitive receipt proves nothing and must be dropped rather than crashing + // sync or authorizing deletion of user values. + const rawReceipts = plainRecord(existing[AQE_OWNERSHIP_KEY]?.externalProviders) ?? {}; + const priorReceipts = Object.fromEntries(Object.entries(rawReceipts) + .filter(([, receipt]) => plainRecord(receipt))); const receipts = { ...priorReceipts }; const active = new Set(); const conflicts = []; + const unavailable = new Set(); + const retired = []; const pruned = []; const added = []; const activationsAdded = []; @@ -466,6 +499,7 @@ function reconcileExternalProviders(existing, desired = aqeExternalProviders()) const currentHash = currentDeclaration === undefined ? null : declarationHash(currentDeclaration); if (currentDeclaration !== undefined && (!prior || currentHash !== prior.writtenHash)) { conflicts.push(id); + unavailable.add(id); // A changed declaration becomes user-owned immediately. Its activation // has independent ownership, though: retain only an exact activation // receipt so a later revoke can remove the minimal record ak created @@ -508,12 +542,14 @@ function reconcileExternalProviders(existing, desired = aqeExternalProviders()) active.add(id); } else { conflicts.push(`${id} (providers.${id}.enabled is not true)`); + unavailable.add(id); } receipts[id] = nextReceipt; } for (const [id, receipt] of Object.entries(priorReceipts)) { if (id in desired) continue; + retired.push(id); const currentDeclaration = current[id]; if (currentDeclaration !== undefined && declarationHash(currentDeclaration) === receipt.writtenHash) { delete current[id]; @@ -535,6 +571,8 @@ function reconcileExternalProviders(existing, desired = aqeExternalProviders()) receipts, active, conflicts, + unavailable: [...unavailable], + retired, pruned, added, activationsAdded, @@ -612,15 +650,25 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const desiredExternal = aqeExternalProviders({ projectRoot: root }); const hasExternal = Object.keys(desiredExternal).length > 0; const hasOwnedExternal = Object.keys(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}).length > 0; + const hasExternalDefaultReceipt = plainRecord( + existing[AQE_OWNERSHIP_KEY]?.externalDefaultProvider, + ) !== null; const priorOverrides = existing.agentOverrides ?? {}; let projected = configuredPolicyToAgentOverrides(policy); const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP)); let staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); - if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal && staleOverrides.length === 0) { + if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal + && !hasExternalDefaultReceipt && staleOverrides.length === 0) { return { ok: true, changed: false, detail: 'no aqe router config to apply' }; } const next = { ...existing }; + // Exact receipts never regain authority. If a user changes the default away + // from the value ak wrote, relinquish ownership immediately; changing it + // back later is still a user write and cannot resurrect this receipt. + if (hasExternalDefaultReceipt && !exactlyOwnedExternalDefault(existing)) { + clearExternalDefaultOwnership(next); + } const details = []; let wrote = false; let externalError = null; @@ -640,7 +688,8 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { else delete next.externalProviders; if (Object.keys(reconciled.providers).length) next.providers = reconciled.providers; else delete next.providers; - const ownership = { ...(existing[AQE_OWNERSHIP_KEY] ?? {}) }; + const ownership = { ...(plainRecord(next[AQE_OWNERSHIP_KEY]) ?? {}) }; + if (!exactlyOwnedExternalDefault(existing)) delete ownership.externalDefaultProvider; if (Object.keys(reconciled.receipts).length) ownership.externalProviders = reconciled.receipts; else delete ownership.externalProviders; if (Object.keys(ownership).length) next[AQE_OWNERSHIP_KEY] = ownership; @@ -658,14 +707,23 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { externalError = `external providers need agentic-qe >=${EXTERNAL_PROVIDERS_MIN_AQE}`; details.push(`externalProviders: disabled (${externalError})`); } - if (reconciled.pruned.includes(next.defaultProvider)) delete next.defaultProvider; - if (next.fallbackChain?.entries) { + const unavailableExternal = new Set([...reconciled.unavailable, ...reconciled.retired]); + const fallbackIsManaged = next.fallbackChain?.id === AQE_MANAGED_TAG; + const managedFallbackOwnedDefault = fallbackIsManaged && next.fallbackChain?.entries?.some( + (entry) => entry.provider === next.defaultProvider, + ); + if (fallbackIsManaged && next.fallbackChain?.entries) { next.fallbackChain = { ...next.fallbackChain, - entries: next.fallbackChain.entries.filter((entry) => !reconciled.pruned.includes(entry.provider)), + entries: next.fallbackChain.entries.filter((entry) => !unavailableExternal.has(entry.provider)), }; if (next.fallbackChain.entries.length === 0) delete next.fallbackChain; } + if (unavailableExternal.has(next.defaultProvider) + && (managedFallbackOwnedDefault || exactlyOwnedExternalDefault(existing) === next.defaultProvider)) { + delete next.defaultProvider; + clearExternalDefaultOwnership(next); + } wrote = reconciled.added.length > 0 || reconciled.pruned.length > 0 || reconciled.activationsAdded.length > 0 || reconciled.activationsPruned.length > 0 || Object.keys(desiredExternal).some((id) => existing.externalProviders?.[id] @@ -690,12 +748,19 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { chainError = 'no valid providers in fallback chain'; details.push(`chain: ⚠ ${chainError}`); } else { - next.defaultProvider = cfg.providers.aqeProvider ?? valid[0].provider; + const requestedDefault = cfg.providers.aqeProvider; + const requestedUnavailable = requestedDefault in desiredExternal && !externalActive.has(requestedDefault); + next.defaultProvider = requestedUnavailable ? valid[0].provider : requestedDefault ?? valid[0].provider; next.providers = { ...(next.providers ?? existing.providers ?? {}) }; for (const e of valid) { if (!(e.provider in desiredExternal)) next.providers[e.provider] = { ...(existing.providers?.[e.provider] ?? {}), enabled: true }; } next.fallbackChain = buildChain(valid); + if (next.defaultProvider in desiredExternal && externalActive.has(next.defaultProvider)) { + setExternalDefaultOwnership(next, next.defaultProvider); + } else if (exactlyOwnedExternalDefault(existing)) { + clearExternalDefaultOwnership(next); + } const emptyModels = valid.filter((e) => !e.models || e.models.length === 0).map((e) => e.provider); // Warn, never refuse: the user may export the key later, and silently // dropping a rung is worse than writing one that is currently inert (#54). @@ -713,6 +778,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (selectedProvider && selectedProvider in desiredExternal) { if (externalActive.has(selectedProvider)) { next.defaultProvider = selectedProvider; + setExternalDefaultOwnership(next, selectedProvider); details.push(`defaultProvider: ${selectedProvider} (project-local external)`); wrote = true; } else { @@ -720,32 +786,36 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { } } - if ((Object.keys(projected).length || staleOverrides.length) && aqeSupportsAgentOverrides()) { + const agentOverridesSupported = aqeSupportsAgentOverrides(); + if ((agentOverridesSupported && Object.keys(projected).length) || staleOverrides.length) { // 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. next.agentOverrides = { ...priorOverrides }; for (const agent of staleOverrides) delete next.agentOverrides[agent]; - Object.assign(next.agentOverrides, projected); + if (agentOverridesSupported) Object.assign(next.agentOverrides, projected); // An override naming a provider is inert until that provider is ENABLED in // this same file: aqe enables from env keys or the `providers` map, and a // subscription host-CLI provider (codex, claude-code) has no env key at // all — so ak-projected codex overrides sat dead and warned on every aqe // startup (#108 phase 3). Enable exactly the providers the projection // references — merge-not-clobber, writing nothing beyond `enabled`. - const referenced = [...new Set(Object.values(projected).map((entry) => entry.provider))]; + const referenced = agentOverridesSupported + ? [...new Set(Object.values(projected).map((entry) => entry.provider))] + : []; if (referenced.length) { next.providers = { ...(next.providers ?? existing.providers ?? {}) }; for (const provider of referenced) { if (!(provider in desiredExternal)) next.providers[provider] = { ...(next.providers[provider] ?? {}), enabled: true }; } } - details.push(`agentOverrides: ${Object.keys(projected).length} agents` + details.push(`agentOverrides: ${agentOverridesSupported ? Object.keys(projected).length : 0} agents` + (referenced.length ? ` (providers enabled: ${referenced.join(', ')})` : '') - + (staleOverrides.length ? ` (${staleOverrides.length} stale ak entries pruned)` : '')); + + (staleOverrides.length ? ` (${staleOverrides.length} stale ak entries pruned)` : '') + + (!agentOverridesSupported ? ' (new projection skipped; needs agentic-qe ≥ 3.13.1)' : '')); wrote = true; - } else if (hasPolicy && !aqeSupportsAgentOverrides()) { + } else if (hasPolicy && !agentOverridesSupported) { details.push('agentOverrides: skipped (needs agentic-qe ≥ 3.13.1)'); } else if (hasPolicy && Object.keys(projected).length === 0) { details.push('agentOverrides: skipped (no safely constructible providers)'); diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 527a444..0ca93d1 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -70,11 +70,15 @@ function project() { return dir; } -const cfg = () => ({ +const cfg = ({ + provider = 'hermes', + chain = [{ provider: 'hermes', models: ['default'] }], + routes = { testing: { host: 'hermes', model: 'default', provenance: 'user' } }, +} = {}) => ({ aqe: true, integrations: { hosts: { claude: true, codex: false } }, - routing: { routes: { testing: { host: 'hermes', model: 'default', provenance: 'user' } } }, - providers: { aqeProvider: 'hermes', aqeFallback: [{ provider: 'hermes', models: ['default'] }] }, + routing: { routes }, + providers: { aqeProvider: provider, aqeFallback: chain }, }); test('admitted providers become lazily selectable and project host routes', () => { @@ -97,6 +101,8 @@ test('AQE 3.13.12 projection writes a project-only default and ownership receipt assert.deepEqual(disk.providers.hermes, { enabled: true }); assert.match(disk._agenticKit.externalProviders.hermes.writtenHash, /^[a-f0-9]{64}$/); assert.match(disk._agenticKit.externalProviders.hermes.providerWrittenHash, /^[a-f0-9]{64}$/); + assert.equal(disk._agenticKit.externalDefaultProvider.provider, 'hermes'); + assert.match(disk._agenticKit.externalDefaultProvider.writtenHash, /^[a-f0-9]{64}$/); assert.equal(managedEnv(cfg()).AQE_LLM_PROVIDER, undefined, 'external default never leaks into settings env'); }); @@ -133,14 +139,69 @@ test('foreign same-id declarations are preserved and refused', () => { const dir = project(); fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true }); const foreign = { kind: 'cli', command: ['foreign-provider'] }; - fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ externalProviders: { hermes: foreign } })); + const userChain = { id: 'user-chain', entries: [{ provider: 'hermes', enabled: true }] }; + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ + externalProviders: { hermes: foreign }, + providers: { hermes: { enabled: true, source: 'user' } }, + defaultProvider: 'hermes', + fallbackChain: userChain, + })); const result = applyAqeRouter(cfg(), dir); const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); assert.equal(result.ok, false); assert.deepEqual(disk.externalProviders.hermes, foreign); + assert.equal(disk.defaultProvider, 'hermes'); + assert.deepEqual(disk.fallbackChain, userChain); + assert.deepEqual(disk.providers.hermes, { enabled: true, source: 'user' }); assert.match(result.detail, /conflicts preserved/); }); +test('an external-default receipt cannot reacquire ownership after user drift', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg({ chain: [] }), dir).ok, true); + const file = aqeRouterFile(dir); + let disk = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(disk._agenticKit.externalDefaultProvider.provider, 'hermes'); + + disk.defaultProvider = 'openai'; + fs.writeFileSync(file, JSON.stringify(disk)); + assert.equal(applyAqeRouter(cfg({ provider: null, chain: [] }), dir).ok, true); + disk = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(disk.defaultProvider, 'openai'); + assert.equal(disk._agenticKit?.externalDefaultProvider, undefined, + 'user drift relinquishes external-default ownership immediately'); + + disk.defaultProvider = 'hermes'; + disk.externalProviders.hermes.command = ['/tmp/user-edited-provider']; + fs.writeFileSync(file, JSON.stringify(disk)); + const conflict = applyAqeRouter(cfg({ provider: null, chain: [] }), dir); + disk = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(conflict.ok, false); + assert.equal(disk.defaultProvider, 'hermes', 'returning to the old value is still user-owned'); + assert.deepEqual(disk.externalProviders.hermes.command, ['/tmp/user-edited-provider']); +}); + +test('malformed ownership receipts are relinquished without deleting user values or throwing', () => { + fakeAqe('3.13.12'); + const dir = project(); + fs.mkdirSync(path.dirname(aqeRouterFile(dir)), { recursive: true }); + const userDeclaration = { kind: 'cli', command: ['user-provider'] }; + fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ + _managedBy: 'agentic-kit', + _agenticKit: { externalProviders: { dead: null } }, + externalProviders: { dead: userDeclaration }, + providers: { dead: { enabled: true, source: 'user' } }, + })); + + const result = applyAqeRouter({ providers: {}, routing: { routes: {} } }, dir); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(result.ok, true, result.detail); + assert.deepEqual(disk.externalProviders.dead, userDeclaration); + assert.deepEqual(disk.providers.dead, { enabled: true, source: 'user' }); + assert.equal(disk._agenticKit, undefined, 'a malformed receipt proves no ownership and is dropped'); +}); + test('stale owned declarations are pruned but edited declarations become user-owned', () => { fakeAqe('3.13.12'); registerHermes(); const dir = project(); @@ -163,6 +224,12 @@ test('stale owned declarations are pruned but edited declarations become user-ow disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); assert.equal(result.ok, false); assert.equal(disk.externalProviders.hermes.displayName, 'User override'); + assert.equal(disk.defaultProvider, undefined, + 'the ak-managed default cannot keep selecting a refused edited declaration'); + assert.equal(disk.fallbackChain, undefined, + 'the ak-managed fallback cannot keep selecting a refused edited declaration'); + assert.deepEqual(disk.providers.hermes, { enabled: true }, + 'the exact activation receipt remains until revoke while routing references are withdrawn'); assert.deepEqual(Object.keys(disk._agenticKit.externalProviders.hermes), ['providerWrittenHash'], 'declaration ownership is relinquished while exact activation ownership remains'); @@ -223,7 +290,7 @@ test('AQE downgrade prunes only unchanged owned declarations and dangling refere fakeAqe('3.13.12'); registerHermes(); const dir = project(); assert.equal(applyAqeRouter(cfg(), dir).ok, true); - fakeAqe('3.13.11'); + fakeAqe('3.13.0'); const result = applyAqeRouter(cfg(), dir); const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); assert.equal(result.ok, false); @@ -233,6 +300,6 @@ test('AQE downgrade prunes only unchanged owned declarations and dangling refere assert.equal(disk.defaultProvider, undefined); assert.equal(disk.fallbackChain, undefined); assert.equal(disk.agentOverrides?.['qe-test-architect'], undefined, - 'downgrade prunes ak-managed overrides that reference the unavailable provider'); + 'downgrade below agentOverrides support still prunes ak-managed external references'); assert.equal(disk._agenticKit, undefined); }); From 09dc5c080fe93ed698f37ae6c921859aa5bdebf1 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:26:11 -0700 Subject: [PATCH 10/21] fix(host): refresh external providers during selection --- src/commands/x/host.mjs | 47 +++++++++++------ tests/kit/provider-cli.test.mjs | 93 ++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 17 deletions(-) diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 2c261bb..b7a1fd9 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -19,6 +19,7 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs'; +import { bootstrapHostAdapters } from '../../lib/adapters/admission.mjs'; import { hostTierLabel, hostAsymmetryNote } from '../../lib/hosts.mjs'; import { routableHostIds, effectiveRoutableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, @@ -449,8 +450,8 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv } async function pick({ flags, cwd, pkgRoot }) { - const aqeProviderTypes = aqeSelectableProviderTypes(); - const aqeChainProviderTypes = aqeSelectableChainProviderTypes(); + let aqeProviderTypes = aqeSelectableProviderTypes(); + let aqeChainProviderTypes = aqeSelectableChainProviderTypes(); const cfg = loadKitConfig(); const trustBaseline = structuredClone(cfg); const hosts = await detectHosts(cwd); @@ -561,6 +562,35 @@ async function pick({ flags, cwd, pkgRoot }) { const policyAllSeeded = Object.keys(oldPolicy).length > 0 && Object.values(oldPolicy).every((r) => r.provenance === 'seeded'); const reseedForPrimary = primaryHost !== prevPrimary && policyAllSeeded; + + const hostIntent = { + claude: routing.includes('claude'), + codex: routing.includes('codex'), + opencode: enabled.includes('opencode'), + }; + // External host ids are not primary candidates, but they are first-class + // integration intent. Retain every live admitted external id as an explicit + // boolean so a provider-only pick cannot deactivate its own bridge; an + // explicit --host set can still disable it by omission. + for (const id of EFFECTIVE_ROUTING) { + if (!MANAGED_HOSTS.has(id)) hostIntent[id] = enabled.includes(id); + } + cfg.integrations.hosts = hostIntent; + + // Admission ran once at process bootstrap against the persisted pre-pick + // config. Re-run it against the final in-memory host intent before provider + // validation/projection: an admitted+granted provider can then be enabled + // and selected atomically, while a provider disabled by this command is + // removed from the AQE bridge before applyAqeRouter computes its projection. + if (process.env.AK_EXPERIMENTAL_HOST_ADAPTERS === '1') { + const refreshed = await bootstrapHostAdapters({ cfg, env: process.env }); + for (const entry of refreshed.warnings) { + warn(`host adapter '${entry.name}' refresh refused (${entry.reason}): ${entry.detail}`); + } + aqeProviderTypes = aqeSelectableProviderTypes(); + aqeChainProviderTypes = aqeSelectableChainProviderTypes(); + } + // validate aqe primary provider if (aqeProvider && !aqeProviderTypes.includes(aqeProvider)) { const norm = aqeProvider === 'anthropic' ? 'claude' : aqeProvider; @@ -594,19 +624,6 @@ async function pick({ flags, cwd, pkgRoot }) { models, maxBudgetUsd: cfg.providers.maxBudgetUsd ?? null, }; - const hostIntent = { - claude: routing.includes('claude'), - codex: routing.includes('codex'), - opencode: enabled.includes('opencode'), - }; - // External host ids are not primary candidates, but they are first-class - // integration intent. Retain every live admitted external id as an explicit - // boolean so a provider-only pick cannot deactivate its own bridge; an - // explicit --host set can still disable it by omission. - for (const id of EFFECTIVE_ROUTING) { - if (!MANAGED_HOSTS.has(id)) hostIntent[id] = enabled.includes(id); - } - cfg.integrations.hosts = hostIntent; cfg.routing.primaryHost = primaryHost; cfg.routing.routes = reseedForPrimary ? {} : { ...oldPolicy }; // Multi-host: seed per-activity routing from defaults (only when the policy is diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 54c1bbc..06fcace 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -228,7 +228,7 @@ const kitJson = (home) => JSON.parse(fs.readFileSync(path.join(home, '.config', const ocJsonPath = (home) => path.join(home, '.config', 'opencode', 'opencode.json'); const ocJson = (home) => JSON.parse(fs.readFileSync(ocJsonPath(home), 'utf8')); -function configureExternalAqeProvider({ home, project }) { +function configureExternalAqeProvider({ home, project }, { enabled = true } = {}) { const adapterDir = path.join(project, 'hermes-adapter'); fs.mkdirSync(adapterDir, { recursive: true }); const command = [process.execPath, '-e', 'process.stdin.pipe(process.stdout)']; @@ -257,7 +257,7 @@ function configureExternalAqeProvider({ home, project }) { const configDir = path.join(home, '.config', 'agentic-kit'); const cfg = kitJson(home); cfg.hostAdapters = [{ name: 'hermes', source: manifestFile, contract: 1 }]; - cfg.integrations.hosts.hermes = true; + cfg.integrations.hosts.hermes = enabled; fs.writeFileSync(path.join(configDir, 'kit.json'), `${JSON.stringify(cfg, null, 2)}\n`); recordConsent('hermes', hash, { file: path.join(configDir, 'adapter-consent.json') }); const grantsFile = path.join(configDir, 'adapter-grants.json'); @@ -265,6 +265,19 @@ function configureExternalAqeProvider({ home, project }) { grantCapability('hermes', 'aqeProvider', { hash }, { file: grantsFile }); } +function fakeAqeInstall(home, version = '3.13.12') { + const prefix = path.join(home, 'npm-prefix'); + // npm's global tree is prefix/lib/node_modules on POSIX and + // prefix/node_modules on Windows. Materialize both so the fixture also works + // when npm itself is absent and globalRoot() takes its spawn-free fallback. + for (const root of [path.join(prefix, 'lib', 'node_modules'), path.join(prefix, 'node_modules')]) { + const pkg = path.join(root, 'agentic-qe'); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: 'agentic-qe', version })); + } + return prefix; +} + test('pick --host claude,opencode enables + wires opencode (config, plugin, agents, skill), preserving user config', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false } }); try { @@ -411,6 +424,82 @@ test('external provider selection accepts the effective host and provider-only r } }); +test('one pick can enable a disabled admitted provider and select it atomically', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb, { enabled: false }); + const env = { + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + npm_config_prefix: fakeAqeInstall(sb.home), + }; + + const result = akPick([ + 'x', 'host', 'pick', '--host', 'claude,hermes', '--aqe-provider', 'hermes', '--yes', + ], sb, { env }); + assert.equal(result.status, 0, + `atomic external selection failed\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.doesNotMatch(result.stdout + result.stderr, /unknown aqe provider 'hermes'/); + + const cfg = kitJson(sb.home); + assert.equal(cfg.integrations.hosts.hermes, true, 'the external host is enabled'); + assert.equal(cfg.providers.aqeProvider, 'hermes', 'the newly enabled provider is selected'); + assert.equal(cfg.routing.primaryHost, 'claude', 'external admission does not broaden primary selection'); + + const router = JSON.parse(fs.readFileSync( + path.join(sb.project, '.agentic-qe', 'llm-config.json'), 'utf8', + )); + assert.ok(router.externalProviders.hermes, 'the provider declaration is projected in the same command'); + assert.equal(router.providers.hermes.enabled, true, 'the provider activation is projected in the same command'); + assert.equal(router.defaultProvider, 'hermes', 'the external default is projected in the same command'); + } finally { + rm(sb.home, sb.project); + } +}); + +test('one pick disabling an external host prunes every owned AQE reference', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb); + const env = { + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + npm_config_prefix: fakeAqeInstall(sb.home), + }; + + const selected = akPick([ + 'x', 'host', 'pick', '--aqe-provider', 'hermes', + '--aqe-fallback', 'hermes:default', '--route', 'testing:hermes:default', '--yes', + ], sb, { env }); + assert.equal(selected.status, 0, + `external projection failed\nstdout: ${selected.stdout}\nstderr: ${selected.stderr}`); + const before = JSON.parse(fs.readFileSync( + path.join(sb.project, '.agentic-qe', 'llm-config.json'), 'utf8', + )); + assert.ok(before.externalProviders.hermes, 'precondition: declaration projected'); + assert.equal(before.providers.hermes.enabled, true, 'precondition: activation projected'); + assert.equal(before.defaultProvider, 'hermes', 'precondition: external default projected'); + assert.equal(before.fallbackChain.entries[0].provider, 'hermes', 'precondition: fallback projected'); + assert.ok(Object.values(before.agentOverrides).some((entry) => entry.provider === 'hermes'), + 'precondition: external agent override projected'); + + const disabled = akPick(['x', 'host', 'pick', '--host', 'claude', '--yes'], sb, { env }); + assert.equal(disabled.status, 0, + `external disable failed\nstdout: ${disabled.stdout}\nstderr: ${disabled.stderr}`); + assert.equal(kitJson(sb.home).integrations.hosts.hermes, false, 'host intent is disabled'); + + const after = JSON.parse(fs.readFileSync( + path.join(sb.project, '.agentic-qe', 'llm-config.json'), 'utf8', + )); + assert.equal(after.externalProviders?.hermes, undefined, 'owned declaration is pruned'); + assert.equal(after.providers?.hermes, undefined, 'owned activation is pruned'); + assert.equal(after.defaultProvider, undefined, 'owned external default is pruned'); + assert.equal(after.fallbackChain, undefined, 'owned external fallback is pruned'); + assert.equal(Object.values(after.agentOverrides ?? {}).some((entry) => entry.provider === 'hermes'), false, + 'owned external agent overrides are pruned'); + } finally { + rm(sb.home, sb.project); + } +}); + test('host off clears the OpenCode catalog override after a successful teardown', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false }, From a35f917a723e843958ef7cafa0078a2898499d2e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:26:11 -0700 Subject: [PATCH 11/21] docs: record hardened provider boundary --- docs/adr/0029-host-adapter-extension-point.md | 7 +++++-- .../0031-capability-graduation-and-upstream-requests.md | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index 7a2a490..1419fe6 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -12,7 +12,10 @@ satisfied [#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628) with `externalProviders`. The manifest may now carry non-authoritative `aqe.provider` candidate data; the `host.legacy.aqeProvider` self-claim remains forbidden, while a passed `aqe-provider` tier and - explicit hash-pinned grant activate the projection. + explicit hash-pinned grant activate the projection. The production bridge copies verified + command/relative-import bytes into a private per-call snapshot and rechecks host intent, consent, + and the exact-hash grant immediately before spawn. This is byte pinning, not an OS sandbox; + absolute file access by consented hook code remains outside the snapshot boundary. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (closed-registry clause superseded — see [Supersession](#supersession-of-adr-0016s-closed-registry-clause)), @@ -354,7 +357,7 @@ implemented and tested in this worktree. | Subprocess hook-runner (`cli-subprocess` surface) | **Working** | `src/lib/adapters/hook-runner.mjs`; bounded real-subprocess tests. | | Hash-pinned consent + edit-invalidation | **Working** | `src/lib/adapters/integrity.mjs`; manifest + declared hook-file digests, pre-spawn recheck, integrity tests. | | Capability-cap schema absence (§3) | **Working** | Schema refusal tests and maintainer-only grant allow-list. | -| AQE external-provider candidate + projection | **Working** | Strict `aqe.provider` validation; six-tier conformance includes a real `aqe-provider` probe; hash-pinned `aqeProvider` grants; Agentic-QE 3.13.12 gate; project-only default/fallback/agentOverrides projection; receipt-owned `externalProviders[id]` plus the minimal `providers[id].enabled=true` MCP-bootstrap activation; foreign-entry preservation, explicit-disable conflict refusal, and exact-value stale pruning. | +| AQE external-provider candidate + projection | **Working** | Strict `aqe.provider` validation; six-tier conformance includes a real `aqe-provider` probe; live pre-spawn host/consent/grant reauthorization; private verified-byte execution snapshots for declared command paths and relative imports; Agentic-QE 3.13.12 gate; project-only default/fallback/agentOverrides projection; independent exact ownership receipts for declarations, activations, and external defaults; foreign-entry preservation, explicit-disable/conflict refusal, and same-command stale-reference pruning. | | Gate item 1 — import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). | | Gate item 2 — uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). | | Gate item 3 — permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). | diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 64b0aa2..9af87e7 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -7,7 +7,10 @@ [#628](https://github.com/proffesor-for-testing/agentic-qe/issues/628). The six-tier ladder now includes a real `aqe-provider` transport exercise, and a passed tier plus explicit hash-pinned `aqeProvider` grant activates project-scoped `externalProviders` projection. This supersedes the - closed-enum ceiling below without weakening the permanent ban on manifest self-claims. + closed-enum ceiling below without weakening the permanent ban on manifest self-claims. The + bridge reauthorizes host intent, consent, and grant immediately before each spawn; projection + owns declaration, activation, and external-default values independently so conflict, disable, + revocation, or downgrade cannot leave an agentic-kit route pointing at an unavailable bridge. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md), [ADR-0018](0018-generalized-host-worker-execution.md), @@ -174,8 +177,8 @@ the experimental flag. This table is the source of truth for what is real. | External lifecycle execution wired into setup/sync/uninstall | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. `setup`, `uninstall`, **and now `sync`** are fully live: `status.mjs`'s collector emits a subsystem-tagged row for an enabled admitted lifecycle host, so `sync`'s convergence plan reaches its admitted-host branch (wave D4 closed the earlier `sync`-only reachability gap) | | Six-tier conformance harness (`admission` … `statusline`) | **Working** (updated 2026-08-26) | `admission`, `activity-routing`, `aqe-provider`, and `primary-eligible` genuinely pass black-box against real fixtures. `aqe-provider` invokes the admitted hook with a bounded prompt and declared model without requiring a pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped`. A failed admission short-circuits downstream tiers; a failed grant-bearing re-run at the same content hash voids its evidence and live capability, while `skipped` never does. | | Hook-file integrity and development conformance mode | **Working** (2026-08-24, PR #131 follow-up) | `hook.files` validates an explicit relative inventory; `hashAdapterContent` adds per-path SHA-256 digests; admission, consent, grants, and pre-spawn execution use the combined identity; `ak host adapters conformance --dev` runs real probes without persisting evidence or grants. | -| Capability-grant store + promotion command | **Working** (updated 2026-08-26) | `grants.mjs` keeps consent, tier evidence, and `canBePrimary`/`aqeProvider`/`commandStatusline` grants hash-pinned and edit-invalidated. Bootstrap registers an AQE provider only when the adapter is admitted, enabled, current, and holds a live `aqeProvider` grant. Primary selection and statusline rendering remain separate consumption gaps; AQE projection is live. | -| Agentic-QE #628 projection | **Working** (2026-08-26) | Requires Agentic-QE >=3.13.12; supports direct `codex` plus admitted external ids in project default, fallback chain, and agent overrides; writes receipt-owned `externalProviders[id]` and the minimal `providers[id].enabled=true` required by AQE MCP bootstrap; never persists an external default to user settings; preserves foreign entries, refuses same-id/explicit-disable conflicts, and prunes only exact receipt-owned stale values. Status/verify distinguish configuration proof from served inference, billing, and vendor evidence. | +| Capability-grant store + promotion command | **Working** (updated 2026-08-26) | `grants.mjs` keeps consent, tier evidence, and `canBePrimary`/`aqeProvider`/`commandStatusline` grants hash-pinned and edit-invalidated. Bootstrap registers an AQE provider only when the adapter is admitted, enabled, current, and holds a live `aqeProvider` grant; the production bridge re-reads all three authorization gates immediately before spawn, so revocation during prompt collection fails closed. Primary selection and statusline rendering remain separate consumption gaps; AQE projection is live. | +| Agentic-QE #628 projection | **Working** (2026-08-26) | Requires Agentic-QE >=3.13.12; supports direct `codex` plus admitted external ids in project default, fallback chain, and agent overrides; writes independently receipt-owned `externalProviders[id]`, `providers[id].enabled=true`, and external default values; never persists an external default to user settings; preserves foreign entries, refuses same-id/explicit-disable conflicts, and prunes exact owned references in the same enable/disable/conflict/downgrade operation. Status/verify distinguish configuration proof from served inference, billing, and vendor evidence. | | Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A; tightened 2026-08-24) | file / https (no redirects, bounded time+bytes) / `npm:` (`npm pack --ignore-scripts` + `tar -xzOf` stdout-only — nothing extracted to disk, package scripts never run); resolver runs before hashing, and remote sources with script-like hook paths are refused because no bundle is retained. | | Upstream request tracking (`gated: #NNN` against a tier) | **Working** (2026-08-16, wave D) | `ak host adapters gate ` records a ref-format-validated upstream gate; `ak host adapters status` surfaces per-tier passed/gated state (stale-marked on a manifest edit) and the granted capabilities | | A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) | From cd9c33ce644fbca037656a2da4673ac196e39108 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:44:46 -0700 Subject: [PATCH 12/21] fix(adapters): converge provider intent on grant revocation --- src/commands/x/host-adapters-grants.mjs | 121 +++++++++++++++++++++- src/commands/x/host-adapters.mjs | 16 ++- tests/kit/host-adapters-cli.test.mjs | 127 ++++++++++++++++++++++++ tests/kit/provider-cli.test.mjs | 45 +++++++++ 4 files changed, 302 insertions(+), 7 deletions(-) diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index d66fc2b..2573a59 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -16,6 +16,9 @@ import { CONFORMANCE_TIERS, TIER_GRANTS, recordTierGate, grantCapability, revokeGrants, revokeCapability, grantsFor, grantedCapabilitiesFor, gatedTiersFor, } from '../../lib/adapters/grants.mjs'; +import { bootstrapHostAdapters } from '../../lib/adapters/admission.mjs'; +import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; +import { applyAqeRouter } from '../../lib/providers.mjs'; import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; import { findEntry, loadAndHash, stripControl, hookCommandsFor, @@ -280,7 +283,92 @@ export async function status({ // revoking because they no longer TRUST the recorded evidence (not just // withdrawing the grant) wants the whole-record form. -export function revokeGrant({ name, capability, grantsFile }) { +function retireAqeProviderIntent(cfg, name) { + let changed = false; + const retired = { selected: false, fallback: 0, routes: 0, escalations: 0 }; + cfg.providers ??= {}; + if (cfg.providers.aqeProvider === name) { + cfg.providers.aqeProvider = null; + retired.selected = true; + changed = true; + } + if (Array.isArray(cfg.providers.aqeFallback)) { + const kept = cfg.providers.aqeFallback.filter((entry) => entry?.provider !== name); + retired.fallback = cfg.providers.aqeFallback.length - kept.length; + if (retired.fallback) { + cfg.providers.aqeFallback = kept; + changed = true; + } + } + const routes = cfg.routing?.routes; + if (routes && typeof routes === 'object' && !Array.isArray(routes)) { + for (const [activity, route] of Object.entries(routes)) { + if (!route || typeof route !== 'object') continue; + if (route.host === name) { + delete routes[activity]; + retired.routes += 1; + changed = true; + continue; + } + if (Array.isArray(route.escalation)) { + const kept = route.escalation.filter((rung) => rung?.host !== name); + const removed = route.escalation.length - kept.length; + if (removed) { + retired.escalations += removed; + if (kept.length) route.escalation = kept; + else delete route.escalation; + changed = true; + } + } + } + } + return { changed, retired }; +} + +async function reconcileRevokedAqeProvider({ + name, cfg, env, cwd, saveConfig, bootstrapAdapters, applyRouter, +}) { + const resolvedCfg = cfg ?? loadKitConfig(); + const { changed, retired } = retireAqeProviderIntent(resolvedCfg, name); + const configured = Array.isArray(resolvedCfg.hostAdapters) + && resolvedCfg.hostAdapters.some((entry) => entry?.name === name); + if (changed) { + saveConfig(resolvedCfg); + ok(`retired AQE intent for '${stripControl(name)}': ` + + `${retired.selected ? 'default, ' : ''}${retired.fallback} fallback, ` + + `${retired.routes} route, ${retired.escalations} escalation reference(s)`); + } + // A custom/unit grants store with no configured adapter has no project + // projection to reconcile. Production entries and every changed config do. + if (!changed && !configured) return 0; + + const refreshEnv = env ?? process.env; + if (refreshEnv.AK_EXPERIMENTAL_HOST_ADAPTERS === '1') { + try { + // Preserve every other admitted provider by rebuilding the full overlay + // after the grant-store write. Flag-off revoke deliberately skips this: + // a fresh flag-off CLI process has no admitted registry, and withdrawing + // authority must not silently re-read sources the operator disabled. + const refreshed = await bootstrapAdapters({ cfg: resolvedCfg, env: refreshEnv }); + for (const entry of refreshed.warnings ?? []) { + warn(`host adapter '${entry.name}' refresh refused (${entry.reason}): ${entry.detail ?? ''}`.trimEnd()); + } + } catch (error) { + fail(`AQE provider authority was revoked, but the live registry could not be refreshed: ${error?.message ?? String(error)}`); + return 1; + } + } + const router = applyRouter(resolvedCfg, cwd); + if (router.changed || !router.ok) (router.ok ? ok : fail)(`aqe router: ${router.detail}`); + return router.ok ? 0 : 1; +} + +export async function revokeGrant({ + name, capability, grantsFile, cfg, env, cwd = process.cwd(), + saveConfig = saveKitConfig, + bootstrapAdapters = bootstrapHostAdapters, + applyRouter = applyAqeRouter, +}) { if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant [capability]'); return 2; } const safeName = stripControl(name); @@ -291,13 +379,36 @@ export function revokeGrant({ name, capability, grantsFile }) { return 1; } const existed = revokeCapability(name, capability, { file: grantsFile }); - if (existed) { ok(`revoked capability '${safeCapability}' for '${safeName}' — other tiers and capabilities are untouched`); return 0; } + if (existed) { + ok(`revoked capability '${safeCapability}' for '${safeName}' — other tiers and capabilities are untouched`); + if (capability === 'aqeProvider') { + return reconcileRevokedAqeProvider({ + name, cfg, env, cwd, saveConfig, bootstrapAdapters, applyRouter, + }); + } + return 0; + } info(`no recorded '${safeCapability}' grant for '${safeName}'`); + // Idempotent cleanup: a prior/manual grant-store edit can leave dependent + // intent behind even though there is no capability record left to remove. + if (capability === 'aqeProvider') { + return reconcileRevokedAqeProvider({ + name, cfg, env, cwd, saveConfig, bootstrapAdapters, applyRouter, + }); + } return 0; } const existed = revokeGrants(name, { file: grantsFile }); - if (existed) { ok(`revoked all conformance evidence and grants for '${safeName}'`); return 0; } - info(`no recorded grants for '${safeName}'`); - return 0; + if (existed) { + ok(`revoked all conformance evidence and grants for '${safeName}'`); + } else { + info(`no recorded grants for '${safeName}'`); + } + // Whole-record revoke is also the idempotent authority-withdrawal command: + // a manually edited/missing grant store must not strand the same dependent + // intent that a recorded aqeProvider grant would retire. + return reconcileRevokedAqeProvider({ + name, cfg, env, cwd, saveConfig, bootstrapAdapters, applyRouter, + }); } diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 186ab29..9657127 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -420,13 +420,18 @@ async function conformance({ * consent?: { recordedHashFor(name:string): string|null, recordConsent(name:string, hash:string): void, revokeConsent(name:string): boolean }, * reader?: (source: string) => Promise, ask?: (question: string) => Promise, * isTTY?: boolean, cfg?: any, runTieredConformance?: (options: any) => Promise, - * consentFile?: string, grantsFile?: string }} [args] + * consentFile?: string, grantsFile?: string, cwd?: string, + * saveConfig?: (cfg:any)=>void, + * bootstrapAdapters?: typeof import('../../lib/adapters/admission.mjs').bootstrapHostAdapters, + * applyRouter?: typeof import('../../lib/providers.mjs').applyAqeRouter }} [args] */ export async function run({ positionals = [], flags = {}, env = process.env, consent = consentStore, reader = defaultReader, ask = defaultAsk, isTTY = process.stdin.isTTY === true, cfg, runTieredConformance = defaultRunTieredConformance, consentFile, grantsFile, + cwd = process.cwd(), + saveConfig, bootstrapAdapters, applyRouter, } = {}) { const sub = positionals[0] ?? 'list'; const name = positionals[1]; @@ -438,7 +443,14 @@ export async function run({ // `conformance`/`grant`/`gate`/`status` stay gated — they're the surface // that reads/records new trust, evidence, or capability. if (sub === 'revoke') return revoke({ name, consent }); - if (sub === 'revoke-grant') return revokeGrant({ name, capability: positionals[2], grantsFile }); + if (sub === 'revoke-grant') { + return revokeGrant({ + name, capability: positionals[2], grantsFile, cfg, env, cwd, + ...(saveConfig ? { saveConfig } : {}), + ...(bootstrapAdapters ? { bootstrapAdapters } : {}), + ...(applyRouter ? { applyRouter } : {}), + }); + } if (!flagEnabled(env)) { fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); diff --git a/tests/kit/host-adapters-cli.test.mjs b/tests/kit/host-adapters-cli.test.mjs index 54db114..4137925 100644 --- a/tests/kit/host-adapters-cli.test.mjs +++ b/tests/kit/host-adapters-cli.test.mjs @@ -1605,6 +1605,133 @@ test('revoke-grant aqeProvider withdraws only the provider grant and leav assert.equal(grantsFor('hermes', { file: grantsFile }).tiers['aqe-provider'].status, 'passed'); }); +test('aqeProvider revocation retires only dependent config and refreshes the router for specific and whole-record forms', async () => { + for (const wholeRecord of [false, true]) { + const grantsFile = tmpGrantsFile(); + const hash = 'b'.repeat(64); + recordTierResult('hermes', 'aqe-provider', { hash, evidence: 'real provider response' }, { file: grantsFile }); + grantCapability('hermes', 'aqeProvider', { hash }, { file: grantsFile }); + recordTierResult('hermes', 'primary-eligible', { hash, evidence: 'independent lead evidence' }, { file: grantsFile }); + grantCapability('hermes', 'canBePrimary', { hash }, { file: grantsFile }); + const cfg = { + hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }], + integrations: { hosts: { claude: true, codex: true, hermes: true } }, + providers: { + aqeProvider: 'hermes', maxBudgetUsd: 7, + aqeFallback: [ + { provider: 'hermes', models: ['default'], source: 'user' }, + { provider: 'openai', models: ['gpt-5.6'], source: 'user' }, + ], + models: [{ id: 'ollama', model: 'qwen' }], + }, + routing: { routes: { + testing: { host: 'hermes', model: 'default', provenance: 'user' }, + review: { + host: 'claude', model: 'claude-sonnet-5', provenance: 'user', + escalation: [{ host: 'hermes', model: 'default' }, { host: 'codex', model: 'gpt-5.6' }], + }, + implementation: { host: 'codex', model: 'gpt-5.6', provenance: 'user' }, + } }, + userValue: { keep: true }, + }; + let saved = null; + let refreshed = 0; + let applied = 0; + const cap = capture(); + let code; + try { + code = await run({ + positionals: wholeRecord + ? ['revoke-grant', 'hermes'] + : ['revoke-grant', 'hermes', 'aqeProvider'], + env: ON_ENV, cfg, grantsFile, flags: {}, cwd: '/sandbox/project', + saveConfig: (next) => { saved = structuredClone(next); }, + bootstrapAdapters: async ({ cfg: next, env }) => { + refreshed += 1; + assert.equal(next, cfg); + assert.equal(env.AK_EXPERIMENTAL_HOST_ADAPTERS, '1'); + return { warnings: [] }; + }, + applyRouter: (next, cwd) => { + applied += 1; + assert.equal(next, cfg); + assert.equal(cwd, '/sandbox/project'); + return { ok: true, changed: true, detail: 'owned Hermes projection pruned' }; + }, + }); + } finally { cap.restore(); } + + assert.equal(code, 0, cap.text()); + assert.equal(refreshed, 1); + assert.equal(applied, 1); + assert.deepEqual(saved, cfg, 'the exact retired intent is what persistence receives'); + assert.equal(cfg.integrations.hosts.hermes, true, 'host enablement is independent and preserved'); + assert.equal(cfg.providers.aqeProvider, null); + assert.deepEqual(cfg.providers.aqeFallback, [ + { provider: 'openai', models: ['gpt-5.6'], source: 'user' }, + ]); + assert.equal(cfg.providers.maxBudgetUsd, 7); + assert.deepEqual(cfg.providers.models, [{ id: 'ollama', model: 'qwen' }]); + assert.equal(cfg.routing.routes.testing, undefined); + assert.deepEqual(cfg.routing.routes.review.escalation, [{ host: 'codex', model: 'gpt-5.6' }]); + assert.deepEqual(cfg.routing.routes.implementation, + { host: 'codex', model: 'gpt-5.6', provenance: 'user' }); + assert.deepEqual(cfg.userValue, { keep: true }); + if (!wholeRecord) { + assert.deepEqual(grantedCapabilitiesFor('hermes', hash, { file: grantsFile }), { canBePrimary: true }); + } else { + assert.equal(grantsFor('hermes', { file: grantsFile }), null); + } + } +}); + +test('whole-record revoke repairs stale AQE intent even when the record or capability was already removed', async () => { + for (const recordWithoutCapability of [false, true]) { + const grantsFile = tmpGrantsFile(); + if (recordWithoutCapability) { + recordTierResult('hermes', 'admission', { + hash: 'c'.repeat(64), evidence: 'non-provider evidence remains', + }, { file: grantsFile }); + } + const cfg = { + integrations: { hosts: { claude: true, hermes: true } }, + providers: { + aqeProvider: 'hermes', + aqeFallback: [{ provider: 'hermes', models: ['default'], source: 'user' }], + models: [{ id: 'openrouter', model: 'user-model' }], + }, + routing: { routes: { + testing: { host: 'hermes', model: 'default', provenance: 'user' }, + review: { host: 'claude', model: 'kept', provenance: 'user' }, + } }, + }; + let saves = 0; + let refreshes = 0; + let applies = 0; + const cap = capture(); + let code; + try { + code = await run({ + positionals: ['revoke-grant', 'hermes'], env: OFF_ENV, cfg, grantsFile, + flags: {}, cwd: '/sandbox/project', + saveConfig: () => { saves += 1; }, + bootstrapAdapters: async () => { refreshes += 1; return { warnings: [] }; }, + applyRouter: () => { applies += 1; return { ok: true, changed: true, detail: 'pruned' }; }, + }); + } finally { cap.restore(); } + assert.equal(code, 0, cap.text()); + assert.equal(saves, 1); + assert.equal(refreshes, 0, 'flag-off authority withdrawal never re-reads configured adapter sources'); + assert.equal(applies, 1); + assert.equal(cfg.providers.aqeProvider, null); + assert.deepEqual(cfg.providers.aqeFallback, []); + assert.deepEqual(cfg.providers.models, [{ id: 'openrouter', model: 'user-model' }]); + assert.equal(cfg.routing.routes.testing, undefined); + assert.deepEqual(cfg.routing.routes.review, { host: 'claude', model: 'kept', provenance: 'user' }); + assert.equal(cfg.integrations.hosts.hermes, true); + } +}); + test('revoke-grant works even when the experimental flag is off (fail-safe, same as the whole-record form)', async () => { const grantsFile = tmpGrantsFile(); const raw = validManifest(); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 06fcace..a340963 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -500,6 +500,51 @@ test('one pick disabling an external host prunes every owned AQE reference', () } }); +test('revoking an AQE provider grant atomically retires dependent intent and projection', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb); + const prefix = fakeAqeInstall(sb.home); + const selected = akPick([ + 'x', 'host', 'pick', '--aqe-provider', 'hermes', + '--aqe-fallback', 'hermes:default;ollama:qwen', + '--route', 'testing:hermes:default', + '--route', 'review:claude:claude-sonnet-5', '--yes', + ], sb, { env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1', npm_config_prefix: prefix } }); + assert.equal(selected.status, 0, + `external projection failed\nstdout: ${selected.stdout}\nstderr: ${selected.stderr}`); + + // Revocation remains reachable with the experimental flag off. The + // command internally performs the bounded refresh needed for cleanup. + const revoked = akPick([ + 'x', 'host', 'adapters', 'revoke-grant', 'hermes', 'aqeProvider', + ], sb, { env: { npm_config_prefix: prefix } }); + assert.equal(revoked.status, 0, + `grant revoke failed\nstdout: ${revoked.stdout}\nstderr: ${revoked.stderr}`); + assert.match(revoked.stdout, /retired AQE intent for 'hermes'/); + + const cfg = kitJson(sb.home); + assert.equal(cfg.integrations.hosts.hermes, true, 'provider revocation does not disable the execution host'); + assert.equal(cfg.providers.aqeProvider, null); + assert.deepEqual(cfg.providers.aqeFallback.map((entry) => entry.provider), ['ollama']); + assert.equal(cfg.routing.routes.testing, undefined, 'dependent external route is retired'); + assert.equal(cfg.routing.routes.review.host, 'claude', 'unrelated user route survives'); + + const disk = JSON.parse(fs.readFileSync( + path.join(sb.project, '.agentic-qe', 'llm-config.json'), 'utf8', + )); + assert.equal(disk.externalProviders?.hermes, undefined, 'owned declaration pruned immediately'); + assert.equal(disk.providers?.hermes, undefined, 'owned activation pruned immediately'); + assert.equal(disk.defaultProvider, 'ollama', 'remaining fallback becomes the usable default'); + assert.deepEqual(disk.fallbackChain.entries.map((entry) => entry.provider), ['ollama']); + assert.equal(Object.values(disk.agentOverrides ?? {}).some((entry) => entry.provider === 'hermes'), false); + assert.ok(Object.values(disk.agentOverrides ?? {}).some((entry) => entry.provider === 'claude-code'), + 'unrelated user route projection survives'); + } finally { + rm(sb.home, sb.project); + } +}); + test('host off clears the OpenCode catalog override after a successful teardown', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false }, From f5e28671b8fde82019277eaebc92cabcd14f252c Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:44:46 -0700 Subject: [PATCH 13/21] fix(sync): surface external provider convergence failures --- src/commands/status.mjs | 50 +++++++++++++++++++++++------ src/commands/sync.mjs | 9 ++++++ tests/kit/status-aqe-drift.test.mjs | 31 ++++++++++++++++++ tests/kit/sync-command.test.mjs | 32 ++++++++++++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 003c736..e83f698 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -843,6 +843,31 @@ export async function collect({ try { const { file, scope } = settingsTarget(cwd); const env = readJson(file, {})?.env ?? {}; + const externalRoot = paths.repoRoot(cwd); + const externalDisk = externalRoot ? (readJson(aqeRouterFile(externalRoot), {}) ?? {}) : {}; + const external = externalRoot + ? aqeExternalProviderState(externalDisk, { projectRoot: externalRoot }) + : null; + const configuredAdapterIds = new Set((cfg.hostAdapters ?? []) + .map((entry) => entry?.name).filter((name) => typeof name === 'string' && name)); + const builtinHostIds = new Set(HOSTS.map((host) => host.id)); + for (const id of Object.keys(cfg.integrations?.hosts ?? {})) { + if (!builtinHostIds.has(id)) configuredAdapterIds.add(id); + } + const externalIntent = new Set(); + if (configuredAdapterIds.has(cfg.providers?.aqeProvider)) externalIntent.add(cfg.providers.aqeProvider); + for (const entry of cfg.providers?.aqeFallback ?? []) { + if (configuredAdapterIds.has(entry?.provider)) externalIntent.add(entry.provider); + } + for (const route of Object.values(cfg.routing?.routes ?? {})) { + if (configuredAdapterIds.has(route?.host)) externalIntent.add(route.host); + for (const rung of route?.escalation ?? []) { + if (configuredAdapterIds.has(rung?.host)) externalIntent.add(rung.host); + } + } + const liveExternal = new Set(external?.desired ?? []); + const unavailableExternalIntent = [...externalIntent].filter((id) => !liveExternal.has(id)); + const unavailableExternalSet = new Set(unavailableExternalIntent); if (isDefault(cfg)) { // advisory only (no fix): opting codex in is a deliberate `ak host pick` if (await have('codex')) { @@ -867,7 +892,11 @@ export async function collect({ if (chain.length && chainRoot) { const disk = readJson(aqeRouterFile(chainRoot)); const diskOrder = (disk?.fallbackChain?.entries ?? []).map((e) => e.provider).join('→'); - routerDrift = disk?._managedBy !== 'agentic-kit' || diskOrder !== chain.map((e) => e.provider).join('→'); + const liveOrder = chain.filter((entry) => !unavailableExternalSet.has(entry?.provider)) + .map((entry) => entry.provider).join('→'); + routerDrift = liveOrder + ? disk?._managedBy !== 'agentic-kit' || diskOrder !== liveOrder + : diskOrder !== ''; } const on = HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id).join('+') || 'none'; const chainStr = chain.length ? `; aqe chain ${chain.map((e) => e.provider).join('→')}` : ''; @@ -880,21 +909,24 @@ export async function collect({ // order whose rungs have no credential fails over into nothing (#54). Warn, // not fail — the primary rung still works — and no `fix`, since only the // user can supply a key. - if (chain.length) { - const gaps = credentialGaps(chain); + const credentialChain = chain.filter((entry) => !unavailableExternalSet.has(entry?.provider)); + if (credentialChain.length) { + const gaps = credentialGaps(credentialChain); if (gaps.length) { rows.push(row('providers', 'warn', - `aqe chain: ${chain.length - gaps.length}/${chain.length} rungs have credentials ` + `aqe chain: ${credentialChain.length - gaps.length}/${credentialChain.length} rungs have credentials ` + `(${gaps.map((g) => `${g.provider}: needs ${g.missing.join(', ')}`).join('; ')})`)); } else { - rows.push(row('providers', 'ok', `aqe chain: ${chain.length}/${chain.length} rungs have credentials`)); + rows.push(row('providers', 'ok', `aqe chain: ${credentialChain.length}/${credentialChain.length} rungs have credentials`)); } } } - const externalRoot = paths.repoRoot(cwd); - if (externalRoot) { - const externalDisk = readJson(aqeRouterFile(externalRoot), {}) ?? {}; - const external = aqeExternalProviderState(externalDisk, { projectRoot: externalRoot }); + if (unavailableExternalIntent.length) { + rows.push(row('providers', 'warn', + `external AQE intent is unavailable (${unavailableExternalIntent.join(', ')}) — restore its admission/host/grant, or retire only its dependent intent with ` + + `\`ak host adapters revoke-grant ${unavailableExternalIntent[0]} aqeProvider\``)); + } + if (externalRoot && external) { if (external.desired.length || external.stale.length) { const defaultDrift = external.desired.includes(cfg.providers?.aqeProvider) && externalDisk.defaultProvider !== cfg.providers.aqeProvider; diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 1abd005..7b8fe20 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -97,6 +97,7 @@ export async function run({ const subsystems = new Set(plan.map((p) => p.subsystem)); const report = reportOutcome; let dejaVuApplyFailed = false; + let aqeRouterApplyFailure = null; // Run a managed heal under a live elapsed-time ticker, then print its result. // Keeps every slow tool (npm upgrades, brain KB download, native rebuild) // visibly alive instead of freezing the prompt; fast/local steps clear in <1s. @@ -293,6 +294,7 @@ export async function run({ } const router = applyAqeRouter(cfg, cwd); if (router.changed || !router.ok) report('aqe router', router); + if (!router.ok) aqeRouterApplyFailure = router.detail || 'AQE router apply failed'; const mcp = await retireCodexMcp(cfg, cwd); if (mcp.changed) saveKitConfig(cfg); if (mcp.changed || !mcp.ok) report('legacy codex MCP', mcp); @@ -366,6 +368,13 @@ export async function run({ const remaining = after.filter((r) => r.level === 'fail' || (r.subsystem === 'deja-vu' && r.fix !== null)); + // Collector rows describe persisted state after the heal, but they cannot + // erase an apply failure from this run. In particular, an unavailable + // external fallback can leave only a warning row; claiming convergence + // after applyAqeRouter returned !ok is a false success and retry loop. + if (aqeRouterApplyFailure && !remaining.some((r) => r.subsystem === 'providers')) { + remaining.push({ subsystem: 'providers', message: `AQE router apply failed: ${aqeRouterApplyFailure}` }); + } if (dejaVuApplyFailed && !remaining.some((r) => r.subsystem === 'deja-vu')) { remaining.push({ subsystem: 'deja-vu', message: 'companion lifecycle apply failed' }); } diff --git a/tests/kit/status-aqe-drift.test.mjs b/tests/kit/status-aqe-drift.test.mjs index 4249f39..53d2465 100644 --- a/tests/kit/status-aqe-drift.test.mjs +++ b/tests/kit/status-aqe-drift.test.mjs @@ -154,3 +154,34 @@ test('REAL drift is still caught: a configured policy with no file yet warns', a assert.equal(row.level, 'warn', 'missing file in a managed project is drift a sync will fix'); assert.equal(row.fix, 'sync re-applies agentOverrides'); }); + +test('unavailable external intent names a manual remedy instead of an impossible sync loop', async () => { + const project = sandboxProject('ak-drift-revoked-external'); + seedHome(offlineKitConfig({ + // Deliberately mismatched/stale entry name: the external host id in + // integration intent remains the actionable cleanup identity. + hostAdapters: [{ name: 'adapter-package-name', source: 'mem://hermes', contract: 1 }], + integrations: { + version: 2, hosts: { claude: true, codex: false, opencode: false, hermes: true }, + bindings: [], ownership: {}, + }, + routing: { version: 1, primaryHost: 'claude', routes: { + testing: { host: 'hermes', model: 'default', provenance: 'user' }, + } }, + providers: { + aqeProvider: 'hermes', + aqeFallback: [{ provider: 'hermes', models: ['default'], source: 'user' }], + models: [], maxBudgetUsd: null, + }, + })); + neutralizeEnvDrift(project); + + const providerRows = rowsFor(await collect(project), 'providers'); + const unavailable = providerRows.find((entry) => /external AQE intent is unavailable \(hermes\)/.test(entry.message)); + assert.ok(unavailable, providerRows.map((entry) => entry.message).join('\n')); + assert.equal(unavailable.level, 'warn'); + assert.equal(unavailable.fix, null, 'sync cannot restore an absent grant, so this is not a sync plan item'); + assert.match(unavailable.message, /revoke-grant hermes aqeProvider/); + assert.equal(providerRows.some((entry) => entry.fix === 'sync re-applies provider env + aqe router'), false, + 'a clean disk plus unavailable intent must not prescribe the non-converging sync loop'); +}); diff --git a/tests/kit/sync-command.test.mjs b/tests/kit/sync-command.test.mjs index e14858b..de4c7d9 100644 --- a/tests/kit/sync-command.test.mjs +++ b/tests/kit/sync-command.test.mjs @@ -278,6 +278,38 @@ test('--dry-run stops before the apply phase (no heal results, no convergence re assert.ok(!/^\s*✓ (natives|aidefence|npx|blocks|statusline):/m.test(out), 'no heal ran'); }); +test('an AQE router apply failure survives collection and prevents a false converged verdict', async () => { + seedHome(offlineKitConfig({ + security: false, agentdb: false, mcp: { register: false, excludeFamilies: [] }, + integrations: { + version: 2, hosts: { claude: true, codex: false, opencode: false }, + bindings: [], ownership: {}, + }, + routing: { version: 1, primaryHost: 'claude', routes: {} }, + providers: { + aqeProvider: null, + aqeFallback: [{ provider: 'missing-external', models: ['default'], source: 'user' }], + models: [], maxBudgetUsd: null, + }, + }), { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }); + const collectProviders = async () => [{ + subsystem: 'providers', level: 'warn', message: 'router needs repair', + fix: 'sync re-applies provider env + aqe router', + }]; + const prior = process.cwd(); + process.chdir(PROJECT); + let result; + try { + result = await captureLog(() => sync.run({ + flags: FLAGS({ 'no-upgrade': true }), pkgRoot: PKG_ROOT, collectFn: collectProviders, + })); + } finally { process.chdir(prior); } + assert.equal(result.result, 1, result.out); + assert.match(result.out, /aqe router:.*no valid providers in fallback chain/); + assert.match(result.out, /still failing: \[providers\] AQE router apply failed/); + assert.doesNotMatch(result.out, /converged — no failing subsystems/); +}); + test('an oversized RVF store is planned as a quarantine', async () => { seedHome(); const aqeDir = paths.projectAqeDir(PROJECT); From e8aac658a59821fb92c83527af95d8d299cde161 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 15:59:11 -0700 Subject: [PATCH 14/21] fix(providers): keep converged projections stable --- src/lib/providers.mjs | 8 ++++++++ tests/kit/providers-external.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index ccee1b6..fedbdef 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -824,6 +824,14 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { wrote ||= JSON.stringify(stableValue(next)) !== JSON.stringify(stableValue(existing)); if (!wrote) return { ok: !chainError && !externalError, changed: false, detail: details.join('; ') || 'nothing to apply' }; next._managedBy = AQE_MANAGED_TAG; + // `wrote` means this invocation owns at least one projection surface; it + // does not by itself mean the artifact changed. Compare the complete + // managed value (including the ownership tag) before touching disk so a + // converged external default/fallback/override remains byte- and + // mtime-stable across repeated syncs. + if (JSON.stringify(stableValue(next)) === JSON.stringify(stableValue(existing))) { + return { ok: !chainError && !externalError, changed: false, detail: details.join('; ') || 'nothing to apply' }; + } fs.mkdirSync(path.dirname(file), { recursive: true }); writeJsonWithBackup(file, next); return { ok: !chainError && !externalError, changed: true, detail: details.join('; ') }; diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 0ca93d1..548dbfd 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -106,6 +106,30 @@ test('AQE 3.13.12 projection writes a project-only default and ownership receipt assert.equal(managedEnv(cfg()).AQE_LLM_PROVIDER, undefined, 'external default never leaks into settings env'); }); +test('a converged external projection is byte- and mtime-idempotent', () => { + fakeAqe('3.13.12'); registerHermes(); + for (const desired of [ + cfg({ chain: [], routes: {} }), + cfg(), + ]) { + const dir = project(); + const first = applyAqeRouter(desired, dir); + const file = aqeRouterFile(dir); + const bytes = fs.readFileSync(file, 'utf8'); + const old = new Date('2001-01-01T00:00:00.000Z'); + fs.utimesSync(file, old, old); + const beforeMtime = fs.statSync(file).mtimeMs; + + const second = applyAqeRouter(desired, dir); + + assert.equal(first.changed, true); + assert.equal(second.ok, true, second.detail); + assert.equal(second.changed, false, second.detail); + assert.equal(fs.readFileSync(file, 'utf8'), bytes); + assert.equal(fs.statSync(file).mtimeMs, beforeMtime, 'converged projection must not rewrite the file'); + } +}); + test('an owned declaration refreshes atomically when admitted provider content changes', () => { fakeAqe('3.13.12'); const admitted = registerHermes(); From f296131896b8592be50528fdbd4bbde62848fecc Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 16:05:49 -0700 Subject: [PATCH 15/21] fix(providers): retire explicitly deselected external default --- src/lib/providers.mjs | 21 +++++++++++++++---- tests/kit/provider-cli.test.mjs | 30 +++++++++++++++++++++++++++ tests/kit/providers-external.test.mjs | 18 ++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index fedbdef..ac98566 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -647,6 +647,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (!root) return { ok: true, changed: false, detail: 'not a project — aqe router unmanaged' }; const file = aqeRouterFile(root); const existing = readJson(file, {}) ?? {}; + const ownedExternalDefault = exactlyOwnedExternalDefault(existing); const desiredExternal = aqeExternalProviders({ projectRoot: root }); const hasExternal = Object.keys(desiredExternal).length > 0; const hasOwnedExternal = Object.keys(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}).length > 0; @@ -666,7 +667,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { // Exact receipts never regain authority. If a user changes the default away // from the value ak wrote, relinquish ownership immediately; changing it // back later is still a user write and cannot resurrect this receipt. - if (hasExternalDefaultReceipt && !exactlyOwnedExternalDefault(existing)) { + if (hasExternalDefaultReceipt && !ownedExternalDefault) { clearExternalDefaultOwnership(next); } const details = []; @@ -689,7 +690,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (Object.keys(reconciled.providers).length) next.providers = reconciled.providers; else delete next.providers; const ownership = { ...(plainRecord(next[AQE_OWNERSHIP_KEY]) ?? {}) }; - if (!exactlyOwnedExternalDefault(existing)) delete ownership.externalDefaultProvider; + if (!ownedExternalDefault) delete ownership.externalDefaultProvider; if (Object.keys(reconciled.receipts).length) ownership.externalProviders = reconciled.receipts; else delete ownership.externalProviders; if (Object.keys(ownership).length) next[AQE_OWNERSHIP_KEY] = ownership; @@ -720,7 +721,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (next.fallbackChain.entries.length === 0) delete next.fallbackChain; } if (unavailableExternal.has(next.defaultProvider) - && (managedFallbackOwnedDefault || exactlyOwnedExternalDefault(existing) === next.defaultProvider)) { + && (managedFallbackOwnedDefault || ownedExternalDefault === next.defaultProvider)) { delete next.defaultProvider; clearExternalDefaultOwnership(next); } @@ -737,6 +738,18 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); + // `aqeProvider: null` is an explicit deselection. Retire only an exact + // external default that ak previously wrote, while leaving the admitted + // declaration and MCP activation intact for routes or future selection. + // A configured fallback chain owns default selection independently and is + // handled below; it must not be erased by primary-provider deselection. + if (!hasChain && selectedProvider === null && ownedExternalDefault) { + delete next.defaultProvider; + clearExternalDefaultOwnership(next); + details.push(`defaultProvider: ${ownedExternalDefault} retired`); + wrote = true; + } + let chainError = null; if (hasChain) { const selectable = new Set(aqeSelectableChainProviderTypes()); @@ -758,7 +771,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { next.fallbackChain = buildChain(valid); if (next.defaultProvider in desiredExternal && externalActive.has(next.defaultProvider)) { setExternalDefaultOwnership(next, next.defaultProvider); - } else if (exactlyOwnedExternalDefault(existing)) { + } else if (ownedExternalDefault) { clearExternalDefaultOwnership(next); } const emptyModels = valid.filter((e) => !e.models || e.models.length === 0).map((e) => e.provider); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index a340963..ba6943a 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -424,6 +424,36 @@ test('external provider selection accepts the effective host and provider-only r } }); +test('pick --aqe-provider none retires the owned external default without disabling admission', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb); + const env = { + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + npm_config_prefix: fakeAqeInstall(sb.home), + }; + + const selected = akPick(['x', 'host', 'pick', '--aqe-provider', 'hermes', '--yes'], sb, { env }); + assert.equal(selected.status, 0, + `external selection failed\nstdout: ${selected.stdout}\nstderr: ${selected.stderr}`); + + const deselected = akPick(['x', 'host', 'pick', '--aqe-provider', 'none', '--yes'], sb, { env }); + assert.equal(deselected.status, 0, + `external deselection failed\nstdout: ${deselected.stdout}\nstderr: ${deselected.stderr}`); + assert.equal(kitJson(sb.home).providers.aqeProvider, null, 'kit intent records explicit deselection'); + + const router = JSON.parse(fs.readFileSync( + path.join(sb.project, '.agentic-qe', 'llm-config.json'), 'utf8', + )); + assert.equal(router.defaultProvider, undefined, 'AQE no longer defaults to the deselected provider'); + assert.equal(router._agenticKit.externalDefaultProvider, undefined, 'the exact default receipt is retired'); + assert.ok(router.externalProviders.hermes, 'the admitted declaration remains projected'); + assert.equal(router.providers.hermes.enabled, true, 'the MCP activation remains projected'); + } finally { + rm(sb.home, sb.project); + } +}); + test('one pick can enable a disabled admitted provider and select it atomically', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); try { diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 548dbfd..2acf2fa 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -206,6 +206,24 @@ test('an external-default receipt cannot reacquire ownership after user drift', assert.deepEqual(disk.externalProviders.hermes.command, ['/tmp/user-edited-provider']); }); +test('explicit deselection retires only the exactly owned external default', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg({ chain: [], routes: {} }), dir).ok, true); + + const deselected = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + const disk = JSON.parse(fs.readFileSync(aqeRouterFile(dir), 'utf8')); + assert.equal(deselected.ok, true, deselected.detail); + assert.equal(deselected.changed, true); + assert.equal(disk.defaultProvider, undefined, 'the previously selected external default is retired'); + assert.equal(disk._agenticKit.externalDefaultProvider, undefined, 'its exact ownership receipt is retired'); + assert.ok(disk.externalProviders.hermes, 'the admitted declaration remains available'); + assert.deepEqual(disk.providers.hermes, { enabled: true }, 'the MCP activation remains available'); + + const converged = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + assert.equal(converged.changed, false, converged.detail); +}); + test('malformed ownership receipts are relinquished without deleting user values or throwing', () => { fakeAqe('3.13.12'); const dir = project(); From a10d3865cbb3d18cb010f60975ca6d5e737f91d6 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 16:13:26 -0700 Subject: [PATCH 16/21] fix(host): propagate AQE router failures --- src/commands/x/host.mjs | 13 +++++++---- tests/kit/provider-cli.test.mjs | 29 +++++++++++++++++++++++++ tests/kit/provider-refresh-cli.test.mjs | 13 +++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index b7a1fd9..fa91fc4 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -354,7 +354,7 @@ async function refresh({ flags, cwd }) { const router = applyAqeRouter(cfg, cwd); (router.ok ? ok : warn)(`aqe router: ${router.detail}`); printActivityRoutingTable(cfg); - return 0; + return router.ok ? 0 : 1; } async function off({ cwd, pkgRoot }) { @@ -745,9 +745,13 @@ async function pick({ flags, cwd, pkgRoot }) { const alt = routing.filter((e) => e !== primaryHost).join(', ') || 'none'; ok(`primary host: ${primaryHost} (alternate: ${alt})`); } - if (aqeProvider) ok(`aqe provider: AQE_LLM_PROVIDER=${aqeProvider}`); const router = applyAqeRouter(cfg, cwd); if (router.changed || !router.ok) (router.ok ? ok : warn)(`aqe router: ${router.detail}`); + if (aqeProvider) { + (router.ok ? ok : warn)(router.ok + ? `aqe provider: AQE_LLM_PROVIDER=${aqeProvider}` + : `aqe provider intent not active: ${aqeProvider} (router projection incomplete)`); + } const mcp = await retireCodexMcp(cfg, cwd); if (mcp.changed) saveKitConfig(cfg); if (mcp.changed || !mcp.ok) (mcp.ok ? ok : warn)(`legacy codex MCP: ${mcp.detail}`); @@ -758,10 +762,11 @@ async function pick({ flags, cwd, pkgRoot }) { if (rmcp.changed || !rmcp.ok) (rmcp.ok ? ok : warn)(`ruflo→codex MCP: ${rmcp.detail}`); const prov = await applyProviders(cfg, cwd); (prov.status === 'degraded' ? warn : prov.ok ? (prov.changed ? ok : info) : warn)(`ruflo providers: ${prov.detail}`); - ok('saved to kit.json — reapplied on every `ak sync`; undo with `ak host off`'); + if (router.ok) ok('saved to kit.json — reapplied on every `ak sync`; undo with `ak host off`'); + else warn('saved intent to kit.json, but AQE routing is incomplete — fix the warning above and re-run `ak sync`'); if (seed.seeded) ok(`per-activity routing seeded — ${seed.count} activities (dual-host defaults; tune with --route or edit kit.json)`); printActivityRoutingTable(cfg); await maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProvider }); printDualHostTips(cfg); - return incompleteTeardown ? 1 : 0; + return incompleteTeardown || !router.ok ? 1 : 0; } diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index ba6943a..611d19f 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -454,6 +454,35 @@ test('pick --aqe-provider none retires the owned external default without disabl } }); +test('pick fails honestly when installed AQE cannot project the requested external provider', () => { + const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); + try { + configureExternalAqeProvider(sb); + const env = { + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + npm_config_prefix: fakeAqeInstall(sb.home, '3.13.11'), + }; + + const result = akPick(['x', 'host', 'pick', '--aqe-provider', 'hermes', '--yes'], sb, { env }); + assert.equal(result.status, 1, + `unsupported external selection must fail\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.match(result.stdout, /external providers need agentic-qe >=3\.13\.12/); + assert.match(result.stdout, /aqe provider intent not active: hermes/); + assert.match(result.stdout, /saved intent to kit\.json, but AQE routing is incomplete/); + assert.doesNotMatch(result.stdout, /✓ aqe provider:/, 'the command must not claim the provider is active'); + assert.doesNotMatch(result.stdout, /✓ saved to kit\.json/, 'the command must not claim full convergence'); + assert.equal(kitJson(sb.home).providers.aqeProvider, 'hermes', + 'declarative intent remains available for upgrade plus sync'); + assert.equal(fs.existsSync(path.join(sb.project, '.agentic-qe', 'llm-config.json')), false, + 'unsupported AQE receives no unusable external declaration'); + + const status = akPick(['status', '--json'], sb, { env }); + assert.equal(status.status, 1, 'immediate status must also report the incomplete projection'); + } finally { + rm(sb.home, sb.project); + } +}); + test('one pick can enable a disabled admitted provider and select it atomically', () => { const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); try { diff --git a/tests/kit/provider-refresh-cli.test.mjs b/tests/kit/provider-refresh-cli.test.mjs index abdda5e..468d1fd 100644 --- a/tests/kit/provider-refresh-cli.test.mjs +++ b/tests/kit/provider-refresh-cli.test.mjs @@ -188,6 +188,19 @@ test('x host refresh --yes re-seeds every diverged route and converges', () => { rm(home, project); }); +test('x host refresh propagates an AQE router failure to automation', () => { + const { home, project } = sandbox({ + ...divergedProviders(), + aqeFallback: [{ provider: 'not-a-provider', models: ['model'] }], + }); + const r = ak(['x', 'host', 'refresh', '--activity', 'architecture'], { cwd: project, home }); + assert.equal(r.status, 1, `router failure must survive the command boundary\n${r.all}`); + assert.match(r.all, /aqe router:.*no valid providers in fallback chain/); + assert.equal(readKit(home).routing.routes.architecture.model, DEFAULT_ROUTES.architecture.model, + 'the requested route intent is saved for a corrected follow-up sync'); + rm(home, project); +}); + test('x host refresh prints the cost-per-task trade for BOTH models, not just ids', () => { // The whole point of the neutral framing: the user is being handed a decision // that genuinely goes both ways, so both sides need their characteristic. From 86e084bf802dfc0486a5d3f3c91c069da7d19aa2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 16:25:57 -0700 Subject: [PATCH 17/21] fix(config): avoid rewriting converged intent --- src/lib/config.mjs | 8 +++++++- tests/kit/provider-cli.test.mjs | 18 ++++++++++++++++++ tests/kit/settings-config.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 12279a0..905cb78 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -204,6 +204,12 @@ export function loadKitConfig(file = kitConfigPath()) { } export function saveKitConfig(cfg, file = kitConfigPath()) { + const serialized = JSON.stringify(migrateKitConfig(cfg), null, 2) + '\n'; + try { + if (fs.readFileSync(file, 'utf8') === serialized) return; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify(migrateKitConfig(cfg), null, 2) + '\n'); + fs.writeFileSync(file, serialized); } diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 611d19f..f9fc376 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -437,6 +437,23 @@ test('pick --aqe-provider none retires the owned external default without disabl assert.equal(selected.status, 0, `external selection failed\nstdout: ${selected.stdout}\nstderr: ${selected.stderr}`); + const configFile = path.join(sb.home, '.config', 'agentic-kit', 'kit.json'); + const routerFile = path.join(sb.project, '.agentic-qe', 'llm-config.json'); + const assertRepeatIsStable = (args, label) => { + const beforeBytes = [configFile, routerFile].map((file) => fs.readFileSync(file, 'utf8')); + const old = new Date('2001-01-01T00:00:00.000Z'); + for (const file of [configFile, routerFile]) fs.utimesSync(file, old, old); + const beforeMtimes = [configFile, routerFile].map((file) => fs.statSync(file).mtimeMs); + const repeated = akPick(args, sb, { env }); + assert.equal(repeated.status, 0, + `${label} repeat failed\nstdout: ${repeated.stdout}\nstderr: ${repeated.stderr}`); + assert.deepEqual([configFile, routerFile].map((file) => fs.readFileSync(file, 'utf8')), beforeBytes, + `${label} repeat changed durable bytes`); + assert.deepEqual([configFile, routerFile].map((file) => fs.statSync(file).mtimeMs), beforeMtimes, + `${label} repeat rewrote a converged artifact`); + }; + assertRepeatIsStable(['x', 'host', 'pick', '--aqe-provider', 'hermes', '--yes'], 'selection'); + const deselected = akPick(['x', 'host', 'pick', '--aqe-provider', 'none', '--yes'], sb, { env }); assert.equal(deselected.status, 0, `external deselection failed\nstdout: ${deselected.stdout}\nstderr: ${deselected.stderr}`); @@ -449,6 +466,7 @@ test('pick --aqe-provider none retires the owned external default without disabl assert.equal(router._agenticKit.externalDefaultProvider, undefined, 'the exact default receipt is retired'); assert.ok(router.externalProviders.hermes, 'the admitted declaration remains projected'); assert.equal(router.providers.hermes.enabled, true, 'the MCP activation remains projected'); + assertRepeatIsStable(['x', 'host', 'pick', '--aqe-provider', 'none', '--yes'], 'deselection'); } finally { rm(sb.home, sb.project); } diff --git a/tests/kit/settings-config.test.mjs b/tests/kit/settings-config.test.mjs index 6f5d6cf..0fafe0c 100644 --- a/tests/kit/settings-config.test.mjs +++ b/tests/kit/settings-config.test.mjs @@ -98,6 +98,26 @@ test('loadKitConfig returns defaults when file missing and round-trips saves', ( fs.rmSync(tmp, { recursive: true, force: true }); }); +test('saveKitConfig preserves bytes and mtime when durable intent is unchanged', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-idempotent-')); + const f = tmpFile(tmp, 'kit.json'); + const cfg = loadKitConfig(f); + saveKitConfig(cfg, f); + const bytes = fs.readFileSync(f, 'utf8'); + const old = new Date('2001-01-01T00:00:00.000Z'); + fs.utimesSync(f, old, old); + const beforeMtime = fs.statSync(f).mtimeMs; + + saveKitConfig(cfg, f); + assert.equal(fs.readFileSync(f, 'utf8'), bytes); + assert.equal(fs.statSync(f).mtimeMs, beforeMtime); + + cfg.providers.aqeProvider = 'openai'; + saveKitConfig(cfg, f); + assert.notEqual(fs.statSync(f).mtimeMs, beforeMtime); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + test('loadKitConfig rejects invalid deja-vu companion intent', () => { const cases = [ [{ enabled: true, mode: 'ambient', hosts: [], indexOnSetup: true }, /mode must be one of/], From e36b5fa32c49ff93fa149f2a18919f8739c42c6e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 16:33:07 -0700 Subject: [PATCH 18/21] fix(providers): retire empty managed fallback --- src/lib/providers.mjs | 26 ++++++++++++++++++++------ tests/kit/provider-cli.test.mjs | 16 ++++++++++++++++ tests/kit/providers-external.test.mjs | 26 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index ac98566..0ff75c3 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -654,12 +654,16 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const hasExternalDefaultReceipt = plainRecord( existing[AQE_OWNERSHIP_KEY]?.externalDefaultProvider, ) !== null; + const hasManagedFallback = existing.fallbackChain?.id === AQE_MANAGED_TAG; + const managedFallbackOwnedDefault = hasManagedFallback + && Array.isArray(existing.fallbackChain?.entries) + && existing.fallbackChain.entries.some((entry) => entry?.provider === existing.defaultProvider); const priorOverrides = existing.agentOverrides ?? {}; let projected = configuredPolicyToAgentOverrides(policy); const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP)); let staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); - if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal + if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal && !hasManagedFallback && !hasExternalDefaultReceipt && staleOverrides.length === 0) { return { ok: true, changed: false, detail: 'no aqe router config to apply' }; } @@ -709,11 +713,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { details.push(`externalProviders: disabled (${externalError})`); } const unavailableExternal = new Set([...reconciled.unavailable, ...reconciled.retired]); - const fallbackIsManaged = next.fallbackChain?.id === AQE_MANAGED_TAG; - const managedFallbackOwnedDefault = fallbackIsManaged && next.fallbackChain?.entries?.some( - (entry) => entry.provider === next.defaultProvider, - ); - if (fallbackIsManaged && next.fallbackChain?.entries) { + if (hasManagedFallback && next.fallbackChain?.entries) { next.fallbackChain = { ...next.fallbackChain, entries: next.fallbackChain.entries.filter((entry) => !unavailableExternal.has(entry.provider)), @@ -738,6 +738,20 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); + // An empty canonical fallback intent retires the tagged chain ak previously + // wrote. Its derived default belongs to the same projection and must not + // survive independently; provider declarations/activations remain available + // for explicit selection, routes, or a future chain. + if (!hasChain && hasManagedFallback) { + delete next.fallbackChain; + if (managedFallbackOwnedDefault) { + delete next.defaultProvider; + if (ownedExternalDefault) clearExternalDefaultOwnership(next); + } + details.push('chain: managed fallback retired'); + wrote = true; + } + // `aqeProvider: null` is an explicit deselection. Retire only an exact // external default that ak previously wrote, while leaving the admitted // declaration and MCP activation intact for routes or future selection. diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index f9fc376..863789f 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -467,6 +467,22 @@ test('pick --aqe-provider none retires the owned external default without disabl assert.ok(router.externalProviders.hermes, 'the admitted declaration remains projected'); assert.equal(router.providers.hermes.enabled, true, 'the MCP activation remains projected'); assertRepeatIsStable(['x', 'host', 'pick', '--aqe-provider', 'none', '--yes'], 'deselection'); + + const withFallback = akPick([ + 'x', 'host', 'pick', '--aqe-provider', 'none', '--aqe-fallback', 'hermes:default', '--yes', + ], sb, { env }); + assert.equal(withFallback.status, 0, + `fallback selection failed\nstdout: ${withFallback.stdout}\nstderr: ${withFallback.stderr}`); + const clearedFallback = akPick(['x', 'host', 'pick', '--aqe-fallback', 'none', '--yes'], sb, { env }); + assert.equal(clearedFallback.status, 0, + `fallback removal failed\nstdout: ${clearedFallback.stdout}\nstderr: ${clearedFallback.stderr}`); + assert.deepEqual(kitJson(sb.home).providers.aqeFallback, [], 'kit intent records an empty fallback'); + const afterFallback = JSON.parse(fs.readFileSync(routerFile, 'utf8')); + assert.equal(afterFallback.fallbackChain, undefined, 'the managed fallback is retired immediately'); + assert.equal(afterFallback.defaultProvider, undefined, 'the fallback-derived default is retired'); + assert.ok(afterFallback.externalProviders.hermes, 'the declaration survives fallback removal'); + assert.equal(afterFallback.providers.hermes.enabled, true, 'the activation survives fallback removal'); + assertRepeatIsStable(['x', 'host', 'pick', '--aqe-fallback', 'none', '--yes'], 'fallback removal'); } finally { rm(sb.home, sb.project); } diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 2acf2fa..8325823 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -224,6 +224,32 @@ test('explicit deselection retires only the exactly owned external default', () assert.equal(converged.changed, false, converged.detail); }); +test('empty fallback intent retires the managed chain and its derived default', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg({ provider: null, routes: {} }), dir).ok, true); + + const cleared = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + const file = aqeRouterFile(dir); + const disk = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(cleared.ok, true, cleared.detail); + assert.equal(cleared.changed, true); + assert.equal(disk.fallbackChain, undefined, 'the managed fallback is retired'); + assert.equal(disk.defaultProvider, undefined, 'the fallback-derived default is retired'); + assert.equal(disk._agenticKit.externalDefaultProvider, undefined, 'the derived default receipt is retired'); + assert.ok(disk.externalProviders.hermes, 'the admitted declaration remains available'); + assert.deepEqual(disk.providers.hermes, { enabled: true }, 'the activation remains available'); + + const bytes = fs.readFileSync(file, 'utf8'); + const old = new Date('2001-01-01T00:00:00.000Z'); + fs.utimesSync(file, old, old); + const beforeMtime = fs.statSync(file).mtimeMs; + const converged = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + assert.equal(converged.changed, false, converged.detail); + assert.equal(fs.readFileSync(file, 'utf8'), bytes); + assert.equal(fs.statSync(file).mtimeMs, beforeMtime); +}); + test('malformed ownership receipts are relinquished without deleting user values or throwing', () => { fakeAqe('3.13.12'); const dir = project(); From 67bb0fb8444a43896b4064c3673b0b759b272c60 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 16:43:01 -0700 Subject: [PATCH 19/21] fix(providers): preserve user fallback replacements --- docs/PROVIDERS.md | 4 +- docs/adr/0029-host-adapter-extension-point.md | 2 +- src/lib/providers.mjs | 48 +++++++++++++------ tests/kit/providers-external.test.mjs | 45 ++++++++++++++++- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 013a0b5..697176c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -381,7 +381,9 @@ Agentic-QE **3.13.12 or newer** is required for an external id. The same admitte default, a fallback rung, or the provider projected from an explicit external-host activity route. Agentic-kit merges `externalProviders` without replacing foreign declarations and also writes the minimal compatibility activation AQE 3.13.12's MCP bootstrap requires: -`providers[id] = { "enabled": true }`. Both values have exact ownership receipts. A same-id foreign +`providers[id] = { "enabled": true }`. Both values have exact ownership receipts. Fallback-derived +defaults carry a separate exact receipt, so removing a managed chain preserves any user-selected +replacement even when that provider was another rung in the old chain. A same-id foreign or user-edited declaration is preserved and reported as a conflict; an explicit foreign `enabled:false` is refused rather than overridden. When a grant is revoked, a host is disabled, or declared content changes, sync removes only a stale declaration/activation that still exactly diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index 1419fe6..10d4e20 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -357,7 +357,7 @@ implemented and tested in this worktree. | Subprocess hook-runner (`cli-subprocess` surface) | **Working** | `src/lib/adapters/hook-runner.mjs`; bounded real-subprocess tests. | | Hash-pinned consent + edit-invalidation | **Working** | `src/lib/adapters/integrity.mjs`; manifest + declared hook-file digests, pre-spawn recheck, integrity tests. | | Capability-cap schema absence (§3) | **Working** | Schema refusal tests and maintainer-only grant allow-list. | -| AQE external-provider candidate + projection | **Working** | Strict `aqe.provider` validation; six-tier conformance includes a real `aqe-provider` probe; live pre-spawn host/consent/grant reauthorization; private verified-byte execution snapshots for declared command paths and relative imports; Agentic-QE 3.13.12 gate; project-only default/fallback/agentOverrides projection; independent exact ownership receipts for declarations, activations, and external defaults; foreign-entry preservation, explicit-disable/conflict refusal, and same-command stale-reference pruning. | +| AQE external-provider candidate + projection | **Working** | Strict `aqe.provider` validation; six-tier conformance includes a real `aqe-provider` probe; live pre-spawn host/consent/grant reauthorization; private verified-byte execution snapshots for declared command paths and relative imports; Agentic-QE 3.13.12 gate; project-only default/fallback/agentOverrides projection; independent exact ownership receipts for declarations, activations, external defaults, and fallback-derived defaults; foreign-entry preservation, explicit-disable/conflict refusal, and same-command stale-reference pruning. | | Gate item 1 — import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). | | Gate item 2 — uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). | | Gate item 3 — permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). | diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index 0ff75c3..704c74d 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -443,28 +443,39 @@ function plainRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } -function exactlyOwnedExternalDefault(config) { +function exactlyOwnedDefault(config, receiptKey) { const provider = config?.defaultProvider; - const receipt = plainRecord(config?.[AQE_OWNERSHIP_KEY]?.externalDefaultProvider); + const receipt = plainRecord(config?.[AQE_OWNERSHIP_KEY]?.[receiptKey]); return typeof provider === 'string' && receipt?.provider === provider && receipt.writtenHash === declarationHash(provider) ? provider : null; } -function setExternalDefaultOwnership(config, provider) { +function setDefaultOwnership(config, receiptKey, provider) { const ownership = { ...(plainRecord(config[AQE_OWNERSHIP_KEY]) ?? {}) }; - ownership.externalDefaultProvider = { provider, writtenHash: declarationHash(provider) }; + ownership[receiptKey] = { provider, writtenHash: declarationHash(provider) }; config[AQE_OWNERSHIP_KEY] = ownership; } -function clearExternalDefaultOwnership(config) { +function clearDefaultOwnership(config, receiptKey) { const ownership = { ...(plainRecord(config[AQE_OWNERSHIP_KEY]) ?? {}) }; - delete ownership.externalDefaultProvider; + delete ownership[receiptKey]; if (Object.keys(ownership).length) config[AQE_OWNERSHIP_KEY] = ownership; else delete config[AQE_OWNERSHIP_KEY]; } +const exactlyOwnedExternalDefault = (config) => exactlyOwnedDefault(config, 'externalDefaultProvider'); +const exactlyOwnedFallbackDefault = (config) => exactlyOwnedDefault(config, 'fallbackDefaultProvider'); +const setExternalDefaultOwnership = (config, provider) => + setDefaultOwnership(config, 'externalDefaultProvider', provider); +const setFallbackDefaultOwnership = (config, provider) => + setDefaultOwnership(config, 'fallbackDefaultProvider', provider); +const clearExternalDefaultOwnership = (config) => + clearDefaultOwnership(config, 'externalDefaultProvider'); +const clearFallbackDefaultOwnership = (config) => + clearDefaultOwnership(config, 'fallbackDefaultProvider'); + function admittedProviderRecord(id) { const records = admittedAqeProviders(); return (Array.isArray(records) ? records : Object.values(records ?? {})) @@ -648,23 +659,21 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const file = aqeRouterFile(root); const existing = readJson(file, {}) ?? {}; const ownedExternalDefault = exactlyOwnedExternalDefault(existing); + const ownedFallbackDefault = exactlyOwnedFallbackDefault(existing); const desiredExternal = aqeExternalProviders({ projectRoot: root }); const hasExternal = Object.keys(desiredExternal).length > 0; const hasOwnedExternal = Object.keys(existing[AQE_OWNERSHIP_KEY]?.externalProviders ?? {}).length > 0; - const hasExternalDefaultReceipt = plainRecord( - existing[AQE_OWNERSHIP_KEY]?.externalDefaultProvider, - ) !== null; + const existingOwnership = plainRecord(existing[AQE_OWNERSHIP_KEY]) ?? {}; + const hasExternalDefaultReceipt = Object.hasOwn(existingOwnership, 'externalDefaultProvider'); + const hasFallbackDefaultReceipt = Object.hasOwn(existingOwnership, 'fallbackDefaultProvider'); const hasManagedFallback = existing.fallbackChain?.id === AQE_MANAGED_TAG; - const managedFallbackOwnedDefault = hasManagedFallback - && Array.isArray(existing.fallbackChain?.entries) - && existing.fallbackChain.entries.some((entry) => entry?.provider === existing.defaultProvider); const priorOverrides = existing.agentOverrides ?? {}; let projected = configuredPolicyToAgentOverrides(policy); const managedOverrideKeys = new Set(Object.keys(AGENT_ACTIVITY_MAP)); let staleOverrides = Object.keys(priorOverrides) .filter((agent) => managedOverrideKeys.has(agent) && !(agent in projected)); if (!hasChain && !hasPolicy && !hasExternal && !hasOwnedExternal && !hasManagedFallback - && !hasExternalDefaultReceipt && staleOverrides.length === 0) { + && !hasExternalDefaultReceipt && !hasFallbackDefaultReceipt && staleOverrides.length === 0) { return { ok: true, changed: false, detail: 'no aqe router config to apply' }; } const next = { ...existing }; @@ -674,6 +683,12 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (hasExternalDefaultReceipt && !ownedExternalDefault) { clearExternalDefaultOwnership(next); } + // A fallback-derived default is owned only while its exact receipt matches. + // Membership in the old chain is not provenance: a user may deliberately + // replace the default with another rung before retiring the chain. + if (hasFallbackDefaultReceipt && (!ownedFallbackDefault || !hasManagedFallback)) { + clearFallbackDefaultOwnership(next); + } const details = []; let wrote = false; let externalError = null; @@ -721,9 +736,10 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { if (next.fallbackChain.entries.length === 0) delete next.fallbackChain; } if (unavailableExternal.has(next.defaultProvider) - && (managedFallbackOwnedDefault || ownedExternalDefault === next.defaultProvider)) { + && (ownedFallbackDefault === next.defaultProvider || ownedExternalDefault === next.defaultProvider)) { delete next.defaultProvider; clearExternalDefaultOwnership(next); + clearFallbackDefaultOwnership(next); } wrote = reconciled.added.length > 0 || reconciled.pruned.length > 0 || reconciled.activationsAdded.length > 0 || reconciled.activationsPruned.length > 0 @@ -744,10 +760,11 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { // for explicit selection, routes, or a future chain. if (!hasChain && hasManagedFallback) { delete next.fallbackChain; - if (managedFallbackOwnedDefault) { + if (ownedFallbackDefault) { delete next.defaultProvider; if (ownedExternalDefault) clearExternalDefaultOwnership(next); } + clearFallbackDefaultOwnership(next); details.push('chain: managed fallback retired'); wrote = true; } @@ -778,6 +795,7 @@ export function applyAqeRouter(cfg, cwd = process.cwd()) { const requestedDefault = cfg.providers.aqeProvider; const requestedUnavailable = requestedDefault in desiredExternal && !externalActive.has(requestedDefault); next.defaultProvider = requestedUnavailable ? valid[0].provider : requestedDefault ?? valid[0].provider; + setFallbackDefaultOwnership(next, next.defaultProvider); next.providers = { ...(next.providers ?? existing.providers ?? {}) }; for (const e of valid) { if (!(e.provider in desiredExternal)) next.providers[e.provider] = { ...(existing.providers?.[e.provider] ?? {}), enabled: true }; diff --git a/tests/kit/providers-external.test.mjs b/tests/kit/providers-external.test.mjs index 8325823..78dc3d2 100644 --- a/tests/kit/providers-external.test.mjs +++ b/tests/kit/providers-external.test.mjs @@ -250,6 +250,43 @@ test('empty fallback intent retires the managed chain and its derived default', assert.equal(fs.statSync(file).mtimeMs, beforeMtime); }); +test('fallback retirement preserves a user-selected replacement from the old chain', () => { + fakeAqe('3.13.12'); registerHermes(); + const dir = project(); + assert.equal(applyAqeRouter(cfg({ + provider: null, + chain: [ + { provider: 'hermes', models: ['default'] }, + { provider: 'ollama', models: ['qwen'] }, + ], + routes: {}, + }), dir).ok, true); + + const file = aqeRouterFile(dir); + const edited = JSON.parse(fs.readFileSync(file, 'utf8')); + edited.defaultProvider = 'ollama'; + fs.writeFileSync(file, JSON.stringify(edited, null, 2) + '\n'); + + const cleared = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + const disk = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(cleared.ok, true, cleared.detail); + assert.equal(disk.fallbackChain, undefined, 'the tagged fallback is retired'); + assert.equal(disk.defaultProvider, 'ollama', 'chain membership alone cannot claim a user replacement'); + assert.equal(disk._agenticKit.fallbackDefaultProvider, undefined, 'the stale fallback receipt is retired'); + assert.equal(disk._agenticKit.externalDefaultProvider, undefined, 'the stale external receipt is retired'); + assert.ok(disk.externalProviders.hermes, 'the admitted declaration remains available'); + assert.deepEqual(disk.providers.hermes, { enabled: true }, 'the activation remains available'); + + const bytes = fs.readFileSync(file, 'utf8'); + const old = new Date('2001-01-01T00:00:00.000Z'); + fs.utimesSync(file, old, old); + const beforeMtime = fs.statSync(file).mtimeMs; + const converged = applyAqeRouter(cfg({ provider: null, chain: [], routes: {} }), dir); + assert.equal(converged.changed, false, converged.detail); + assert.equal(fs.readFileSync(file, 'utf8'), bytes); + assert.equal(fs.statSync(file).mtimeMs, beforeMtime); +}); + test('malformed ownership receipts are relinquished without deleting user values or throwing', () => { fakeAqe('3.13.12'); const dir = project(); @@ -257,7 +294,12 @@ test('malformed ownership receipts are relinquished without deleting user values const userDeclaration = { kind: 'cli', command: ['user-provider'] }; fs.writeFileSync(aqeRouterFile(dir), JSON.stringify({ _managedBy: 'agentic-kit', - _agenticKit: { externalProviders: { dead: null } }, + _agenticKit: { + externalProviders: { dead: null }, + externalDefaultProvider: null, + fallbackDefaultProvider: 'not-a-receipt', + }, + defaultProvider: 'ollama', externalProviders: { dead: userDeclaration }, providers: { dead: { enabled: true, source: 'user' } }, })); @@ -267,6 +309,7 @@ test('malformed ownership receipts are relinquished without deleting user values assert.equal(result.ok, true, result.detail); assert.deepEqual(disk.externalProviders.dead, userDeclaration); assert.deepEqual(disk.providers.dead, { enabled: true, source: 'user' }); + assert.equal(disk.defaultProvider, 'ollama', 'malformed receipts cannot authorize deleting a user default'); assert.equal(disk._agenticKit, undefined, 'a malformed receipt proves no ownership and is dropped'); }); From a0e06a3b747cdecf3c279eeeaba7dc4cc24ff82a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 17:01:18 -0700 Subject: [PATCH 20/21] test(providers): pin AQE fixture in CLI selection --- tests/kit/provider-cli.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index 863789f..278930c 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -402,7 +402,10 @@ test('external provider selection accepts the effective host and provider-only r const sb = pickSandbox({ hosts: { claude: true, codex: false, opencode: false } }); try { configureExternalAqeProvider(sb); - const env = { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }; + const env = { + AK_EXPERIMENTAL_HOST_ADAPTERS: '1', + npm_config_prefix: fakeAqeInstall(sb.home), + }; const explicit = akPick(['x', 'host', 'pick', '--host', 'claude,hermes', '--yes'], sb, { env }); assert.equal(explicit.status, 0, `explicit external host failed\nstdout: ${explicit.stdout}\nstderr: ${explicit.stderr}`); From f83ee84b45645673e22c14c69fef95db46087e8e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 26 Aug 2026 17:14:30 -0700 Subject: [PATCH 21/21] test(ci): isolate grant stores on Windows --- tests/kit/adapter-admission.test.mjs | 21 +++++---------- tests/kit/adapter-aqe-provider.test.mjs | 18 +++++-------- tests/kit/helpers/home-sandbox.mjs | 26 +++++++++++++++++++ .../aqe-external-provider-transport.test.mjs | 7 ++++- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs index 4c4d4f9..fa7df86 100644 --- a/tests/kit/adapter-admission.test.mjs +++ b/tests/kit/adapter-admission.test.mjs @@ -23,6 +23,7 @@ import { buildAdmittedLifecycleAdapter, registerAdmittedLifecycle, lifecycleAdapterFor, } from '../../src/lib/adapters/lifecycle-registry.mjs'; import { HOST_REGISTRY } from '../../src/lib/adapters/registries.mjs'; +import { sandboxConfigBase } from './helpers/home-sandbox.mjs'; beforeEach(() => resetAdmitted()); @@ -245,15 +246,11 @@ test('flag-on with a trusted entry admits it and applies the overlay', async () // grantsByName directly to test applyAdmitted's own guarantee in isolation), // these two exercise the actual bootstrap -> grantedCapabilitiesFor(name, // freshly-computed hash) -> applyAdmitted wiring end to end, against the -// REAL grants.mjs file store — redirected via XDG_CONFIG_HOME to a throwaway -// directory so they never touch the developer's real -// ~/.config/agentic-kit/adapter-grants.json. +// REAL grants.mjs file store — redirected via XDG_CONFIG_HOME and APPDATA to a +// throwaway directory so no platform touches the developer's real config. test('bootstrapHostAdapters: a real, currently-hashed grant for primary-eligible makes canBePrimary live in effectiveHostRegistry()', async (t) => { - const prevXdg = process.env.XDG_CONFIG_HOME; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-live-')); - process.env.XDG_CONFIG_HOME = dir; - t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + sandboxConfigBase(t, 'ak-d2-grant-live'); const name = 'hermes-grant-live'; const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); @@ -274,10 +271,7 @@ test('bootstrapHostAdapters: a real, currently-hashed grant for primary-eligible }); test('bootstrapHostAdapters: a grant recorded against a STALE manifest hash never lights up (edit-invalidation)', async (t) => { - const prevXdg = process.env.XDG_CONFIG_HOME; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-stale-')); - process.env.XDG_CONFIG_HOME = dir; - t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + sandboxConfigBase(t, 'ak-d2-grant-stale'); const name = 'hermes-grant-stale'; const oldManifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); @@ -306,10 +300,7 @@ test('bootstrapHostAdapters: a grant recorded against a STALE manifest hash neve }); test('bootstrapHostAdapters: with no grant recorded at all, an admitted host stays at the manifest floor (canBePrimary false)', async (t) => { - const prevXdg = process.env.XDG_CONFIG_HOME; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-none-')); - process.env.XDG_CONFIG_HOME = dir; - t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + sandboxConfigBase(t, 'ak-d2-grant-none'); const name = 'hermes-grant-none'; const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); diff --git a/tests/kit/adapter-aqe-provider.test.mjs b/tests/kit/adapter-aqe-provider.test.mjs index fe93988..a528ea4 100644 --- a/tests/kit/adapter-aqe-provider.test.mjs +++ b/tests/kit/adapter-aqe-provider.test.mjs @@ -8,7 +8,9 @@ import { fileURLToPath } from 'node:url'; import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; import { resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; -import { grantCapability, recordTierResult, revokeCapability } from '../../src/lib/adapters/grants.mjs'; +import { + adapterGrantsPath, grantCapability, recordTierResult, revokeCapability, +} from '../../src/lib/adapters/grants.mjs'; import { hashAdapterContent } from '../../src/lib/adapters/integrity.mjs'; import { admittedAqeProviderFor, @@ -19,6 +21,7 @@ import { runAdmittedAqeProvider, runAdmittedAqeProviderProbe, } from '../../src/lib/adapters/aqe-provider.mjs'; +import { sandboxConfigBase } from './helpers/home-sandbox.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -241,15 +244,12 @@ test('live provider receipts are immutable and projection exposes no manifest in }); test('bootstrap activates only an admitted, enabled, hash-current aqeProvider grant', async (t) => { - const priorXdg = process.env.XDG_CONFIG_HOME; - const grantHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-grant-')); - process.env.XDG_CONFIG_HOME = grantHome; + const grantBase = sandboxConfigBase(t, 'ak-aqe-provider-grant'); + assert.equal(adapterGrantsPath(), path.join(grantBase, 'agentic-kit', 'adapter-grants.json')); const { dir, manifest, integrity } = fixture(); const manifestFile = path.join(dir, 'manifest.json'); fs.writeFileSync(manifestFile, JSON.stringify(manifest)); t.after(() => { - process.env.XDG_CONFIG_HOME = priorXdg; - fs.rmSync(grantHome, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true }); resetAdmittedAqeProviders(); resetAdmitted(); @@ -347,9 +347,7 @@ process.stdout.write(value); }); test('provider rechecks live host, consent, and grant authority immediately before spawn', async (t) => { - const priorXdg = process.env.XDG_CONFIG_HOME; - const grantHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-provider-live-grant-')); - process.env.XDG_CONFIG_HOME = grantHome; + sandboxConfigBase(t, 'ak-aqe-provider-live-grant'); const { dir, manifest, integrity } = fixture(); const manifestFile = path.join(dir, 'manifest.json'); fs.writeFileSync(manifestFile, JSON.stringify(manifest)); @@ -362,8 +360,6 @@ test('provider rechecks live host, consent, and grant authority immediately befo isTrusted: (_name, hash) => hash === integrity.hash, }; t.after(() => { - process.env.XDG_CONFIG_HOME = priorXdg; - fs.rmSync(grantHome, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true }); resetAdmittedAqeProviders(); resetAdmitted(); diff --git a/tests/kit/helpers/home-sandbox.mjs b/tests/kit/helpers/home-sandbox.mjs index f63a34e..a307ea3 100644 --- a/tests/kit/helpers/home-sandbox.mjs +++ b/tests/kit/helpers/home-sandbox.mjs @@ -41,6 +41,32 @@ export function sandboxHome(prefix) { return home; } +/** + * Redirect only the platform config base for a single test. paths.mjs reads + * XDG_CONFIG_HOME on POSIX and APPDATA on Windows, so setting just one leaves + * the other platform writing to the runner's real user config directory. + * @param {import('node:test').TestContext} testContext + * @param {string} prefix + * @returns {string} the temporary config base + */ +export function sandboxConfigBase(testContext, prefix) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + const previous = { + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + APPDATA: process.env.APPDATA, + }; + process.env.XDG_CONFIG_HOME = base; + process.env.APPDATA = base; + testContext.after(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(base, { recursive: true, force: true }); + }); + return base; +} + /** Fail loudly (rather than mutating the developer's machine) if the redirect * above did not take — call once per test file, right after the kit modules * are imported. */ diff --git a/tests/live/aqe-external-provider-transport.test.mjs b/tests/live/aqe-external-provider-transport.test.mjs index a9ecd9b..855bd4d 100644 --- a/tests/live/aqe-external-provider-transport.test.mjs +++ b/tests/live/aqe-external-provider-transport.test.mjs @@ -241,6 +241,7 @@ test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { const xdg = path.join(temp, 'xdg'); const home = path.join(temp, 'home'); const priorXdg = process.env.XDG_CONFIG_HOME; + const priorAppdata = process.env.APPDATA; fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); fs.mkdirSync(home, { recursive: true }); const validated = writeFixture(adapterDir); @@ -252,6 +253,8 @@ test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { t.after(() => { if (priorXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = priorXdg; + if (priorAppdata === undefined) delete process.env.APPDATA; + else process.env.APPDATA = priorAppdata; resetAdmittedAqeProviders(); resetAdmitted(); fs.rmSync(temp, { recursive: true, force: true }); @@ -271,6 +274,7 @@ test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { grantCapability(PROVIDER_ID, 'aqeProvider', { hash: integrity.hash }, { file: grantsFile }); process.env.XDG_CONFIG_HOME = xdg; + process.env.APPDATA = xdg; const bootstrap = await bootstrapHostAdapters({ cfg, env: { ...process.env, AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, @@ -288,6 +292,7 @@ test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { ...process.env, HOME: home, XDG_CONFIG_HOME: xdg, + APPDATA: xdg, AK_EXPERIMENTAL_HOST_ADAPTERS: '1', AQE_CONFIG_ROOT: projectRoot, AQE_PROJECT_ROOT: projectRoot, @@ -303,5 +308,5 @@ test('Agentic-QE 3.13.12+ serves an admitted provider through CLI and MCP', { assert.match(mcpText, new RegExp(COMPLETION), mcp.stderr); assert.match(mcpText, new RegExp(`provider=${PROVIDER_ID}`)); assert.match(mcpText, new RegExp(`model=${MODEL_ID}`)); - assert.equal(adapterGrantsPath().startsWith(xdg), true); + assert.equal(adapterGrantsPath(), grantsFile); });