diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index 9472b77..a036065 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -154,8 +154,8 @@ unbuilt. This table is the source of truth for what is real. | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | | `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) | 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 (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); 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 | **Proposed — not built** | Loops are built-in-scoped by design until generalized | -| Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit | +| 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. *Known limitation:* the `sync` path is wired but not yet reachable through a real `ak sync` — `status.mjs`'s subsystem derivation is still opencode-scoped; setup and uninstall are fully live. Generalizing `status.mjs` is a tracked follow-up | +| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, wave C) | `runTieredConformance` + `ak host adapters conformance`: `admission` and `activity-routing` genuinely pass black-box against a real fixture (real subprocess worker); `session-driving`/`primary-eligible`/`statusline` report `gated`/`skipped` honestly because their runtime paths (external session driving, lead/escalation, statusline render) are not built — the harness never fabricates a pass. Passed/gated tiers are recorded into the grant store against the manifest hash; a failed `admission` tier short-circuits every downstream tier so no evidence is laundered | | Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave | | Remote manifest sources (npm / URL) + resolve→hash ordering | **Working** (2026-08-16, wave A) | 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, so a mutated remote surfaces as `consent-stale`. The https fetch is host-unrestricted by design (the source is operator-authored in user-scope `kit.json`; redirects refused, no credentials attached) | | Upstream request tracking (`gated: #NNN` against a tier) | **Partial** (2026-08-16, wave A) | Per-tier `gated` records exist in the grant store (`recordTierGate`, ref-format-validated); CLI recording/display pending a later wave | diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index a285216..6f8a9e8 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -14,7 +14,8 @@ import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; @@ -29,6 +30,17 @@ import { import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — 'fail' + * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`, + * so a genuinely failed opencode sub-surface never reads as merely + * informational. */ +function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, yes: { type: 'boolean', default: false }, @@ -215,21 +227,23 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // 6b. host lifecycle wiring — config-file MCP + skills, lifecycle plugin, // converted agents, platform skill (each adapter owns its own surfaces - // — opencode.mjs for opencode). Registry-driven: loops - // builtinHostsWithLifecycle() rather than naming opencode, so a second - // BUILT-IN lifecycle host needs no new branch here. Only when the CLI - // is actually present: a declined/failed install must not leave a - // freshly-created config home behind (codex-review #4). The result - // SHAPE consumed below (stack.oc/plugin/agents/skill) is still - // opencode's own — the lifecycle contract doesn't mandate a common - // `apply()` result shape across hosts. builtinHostsWithLifecycle() - // (not hostsWithLifecycle()) deliberately excludes admitted external - // hosts: this loop body is opencode-shaped, and external lifecycle - // execution graduates in a later wave alongside a shape-agnostic body - // (see lifecycle-registry.mjs's registerAdmittedLifecycle comment). - for (const hostId of builtinHostsWithLifecycle()) { - if (!cfg.integrations?.hosts?.[hostId]) continue; - if (!(await have(hostId))) { + // — opencode.mjs for opencode; a subprocess hook for an admitted + // external — see lifecycle-registry.mjs's buildAdmittedLifecycleAdapter). + // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, + // ADR-0031 P3) rather than naming opencode, so a second lifecycle host + // — built-in or admitted — needs no new branch here. lifecycleExecutionEnabled + // gates each host: a built-in only needs cfg enablement (unchanged); an + // admitted external ALSO needs the experimental flag — an admitted host + // is opt-in exactly like opencode, and this never auto-enables anything. + // Only when the CLI is actually present: a declined/failed install must + // not leave a freshly-created config home behind (codex-review #4). + // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle + // result's own shape (opencode's rich per-surface shape vs. an admitted + // host's generic lifecycleResult), so this loop body never destructures + // a host-specific result directly. + for (const hostId of hostsWithLifecycle()) { + if (!lifecycleExecutionEnabled(hostId, cfg)) continue; + if (!(await have(detectionBinFor(hostId)))) { const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId; warn(`${hostId}: enabled but CLI not installed — wiring skipped (re-run \`ak sync\` after installing ${pkg})`); continue; @@ -237,23 +251,22 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const lifecycle = await runLifecycle({ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot }, }); - const stack = lifecycle.result; - (stack.oc.ok ? ok : warn)(`opencode: ${stack.oc.detail}`); - if (stack.oc.fatal) { - warn(`opencode plugin/agents/skill/guidance skipped — ${stack.oc.detail}`); - return false; + const report = renderApplyReport(hostId, lifecycle); + for (const line of report.lines) printReportLine(line); + if (report.fatal) return false; + // guidance blocks + the startup-reload note are opencode-specific surfaces + // (AGENTS.md blocks, opencode's own load-once-at-startup behavior) with no + // equivalent in the generic hook contract — stays gated on the rich shape. + if (report.shape === 'opencode') { + // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) + // — not on the next status-driven reconcile. Same shared reconcile pick + // and off use, so every command converges guidance identically. + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); + ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); + // opencode loads config/plugins/MCP/agents once at startup — say so now, + // or the user files "hooks don't work" issues (observed live). + info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); } - ok(`opencode plugin: ${stack.plugin.detail}`); - ok(`opencode agents: ${stack.agents.detail}`); - if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`); - // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) - // — not on the next status-driven reconcile. Same shared reconcile pick - // and off use, so every command converges guidance identically. - const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); - ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); - // opencode loads config/plugins/MCP/agents once at startup — say so now, - // or the user files "hooks don't work" issues (observed live). - info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); } // 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 9f44e46..3160dfa 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -9,7 +9,8 @@ import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, detectionBinFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, migrateRetiredRoutesInConfig, ensureCodexMcp, ensureRufloMcpInCodex, bothHostsEnabled } from '../lib/providers.mjs'; @@ -24,6 +25,17 @@ import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs'; import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — 'fail' + * (F5, Wave C security review) reaches `fail()`, not a fallback `info()`, + * so a genuinely failed opencode sub-surface never reads as merely + * informational. */ +function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, 'no-upgrade': { type: 'boolean', default: false }, @@ -188,32 +200,29 @@ export async function run({ flags, pkgRoot, fetchLatest }) { // Runs BEFORE the blocks branch: the agents-opencode guidance target is gated // on the config home this branch creates — this order lets a fresh enable // converge guidance in the SAME sync (a second sync is then a true no-op). - // Registry-driven: loops builtinHostsWithLifecycle() rather than naming - // opencode, so a second BUILT-IN lifecycle host needs no new branch here. - // Only opencode is registered today, so this loop runs exactly once — - // byte-identical to the single-host branch it replaces. The result SHAPE - // consumed below (stack.oc/plugin/agents/skill) is still opencode's own — - // the lifecycle contract doesn't mandate a common `apply()` result shape - // across hosts. builtinHostsWithLifecycle() (not hostsWithLifecycle()) - // deliberately excludes admitted external hosts — see lifecycle-registry.mjs. - for (const hostId of builtinHostsWithLifecycle()) { - if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue; - if (!(await have(hostId))) { + // Registry-driven: loops hostsWithLifecycle() (built-ins + admitted, + // ADR-0031 P3) rather than naming opencode, so a second lifecycle host — + // built-in or admitted — needs no new branch here. lifecycleExecutionEnabled + // gates each host exactly as setup.mjs does (built-in: cfg enablement only; + // admitted: cfg enablement AND the experimental flag — never auto-enabled). + // lifecycle-render.mjs's renderApplyReport dispatches on the runLifecycle + // result's own shape, so this loop body never destructures a host-specific + // result directly; opencode's per-surface lines render exactly as before. + for (const hostId of hostsWithLifecycle()) { + if (!subsystems.has(hostId) || !lifecycleExecutionEnabled(hostId, cfg)) continue; + if (!(await have(detectionBinFor(hostId)))) { info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`); continue; } const lifecycle = await runLifecycle({ adapter: lifecycleAdapterFor(hostId), action: 'apply', cfg, options: { pkgRoot }, }); - const stack = lifecycle.result; + const applyReport = renderApplyReport(hostId, lifecycle); // persist the markers on ANY refresh (a converged file whose kit.json // markers are stale/missing still needs the save, or the next teardown // cannot prove ownership — codex-review r3), not only on file changes. - if (stack.oc.changed || stack.markersChanged) saveKitConfig(cfg); - if (stack.oc.changed || !stack.oc.ok) report('opencode', stack.oc); - report('opencode plugin', stack.plugin); - report('opencode agents', stack.agents); - if (stack.skill.changed || !stack.skill.ok) report('opencode skill', stack.skill); + if (applyReport.ocChanged || applyReport.markersChanged) saveKitConfig(cfg); + for (const line of applyReport.lines) printReportLine(line); } // The 'opencode' guard: the opencode branch above can CREATE the config home // that activates the agents-opencode guidance target — a machine whose other diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 721e129..74ab88f 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -11,12 +11,25 @@ import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs'; import { unregister } from '../lib/mcp.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; -import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, isBuiltinHost } from '../lib/adapters/lifecycle-registry.mjs'; +import { renderUndoReport } from '../lib/adapters/lifecycle-render.mjs'; import { present as rbPresent } from '../lib/ruvnet-brain.mjs'; import * as paths from '../lib/paths.mjs'; -import { ok, warn, info } from '../lib/output.mjs'; +import { ok, warn, fail, info } from '../lib/output.mjs'; import { removeCodexStatusline } from '../lib/codex-statusline.mjs'; +/** Prints one lifecycle-render.mjs report line at its own level — mirrors + * setup.mjs/sync.mjs's own printReportLine (N-2, Wave C security review + * follow-up): renderUndoReport only ever emits 'ok'/'warn' today, so this is + * latent, but the day an undo renderer adopts levelForResult (F5's mapping) + * a 'fail' line must reach fail(), not be silently downgraded to warn(). */ +export function printReportLine(line) { + if (line.level === 'ok') ok(line.text); + else if (line.level === 'warn') warn(line.text); + else if (line.level === 'fail') fail(line.text); + else info(line.text); +} + export const options = { 'dry-run': { type: 'boolean', default: false }, 'this-project': { type: 'boolean', default: false }, @@ -128,23 +141,31 @@ export async function run({ flags }) { // removes kit.json below, so persisting cfg here would recreate it. Each // adapter's own undo() already honors ownership/receipts (opencode's // undoOpencode no-ops when it never held mcp:'ak', and marker-gates - // artifact removal independent of that), so this call is unconditional per - // host — the only kit-side gate is "did anything actually happen", to - // avoid a no-op teardown line (and a needless kit.json rewrite) on a host - // that was never enabled. builtinHostsWithLifecycle() (not - // hostsWithLifecycle()) deliberately excludes admitted external hosts: - // this loop body destructures an opencode-shaped result (ret.undo, - // ret.artifacts) — see lifecycle-registry.mjs's registerAdmittedLifecycle - // comment for why external lifecycle execution isn't wired through here yet. - for (const hostId of builtinHostsWithLifecycle()) { + // artifact removal independent of that), so a BUILT-IN's call is + // unconditional per host, same as before ADR-0031 P3 — the only kit-side + // gate is "did anything actually happen", to avoid a no-op teardown line + // (and a needless kit.json rewrite) on a host that was never enabled. An + // ADMITTED external host is different: there is no "always safe, always + // idempotent" guarantee for an arbitrary third-party hook the way there is + // for opencode's own undo, so an admitted host's teardown is gated by + // lifecycleExecutionEnabled (cfg enablement AND the experimental flag) — + // an admitted host that was never enabled/consented for this run is never + // invoked. hostsWithLifecycle() (built-ins + admitted, ADR-0031 P3) is safe + // to loop unconditionally now: lifecycle-render.mjs's renderUndoReport + // dispatches on the runLifecycle result's own shape, so this loop body + // never destructures a host-specific result directly. + for (const hostId of hostsWithLifecycle()) { + if (!isBuiltinHost(hostId) && !lifecycleExecutionEnabled(hostId, cfg)) continue; const adapter = lifecycleAdapterFor(hostId); if (dry) { - info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`); + info(isBuiltinHost(hostId) + ? `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` + : `[dry-run] stripped ak-managed ${hostId} wiring + artifacts (hook-declared undo)`); continue; } const retired = await runLifecycle({ adapter, action: 'undo', cfg }); - const ret = retired.result; - ownershipTeardownOk = ownershipTeardownOk && ret.ok; + const undoReport = renderUndoReport(hostId, retired); + ownershipTeardownOk = ownershipTeardownOk && undoReport.ok; // Persist markers unconditionally, exactly like x/host.mjs's off()/pick(): // undo() mutates cfg's ownership markers in memory even when it rewrote // no file (`undo.changed` measures the FILE, not cfg), so gating the save @@ -152,11 +173,7 @@ export async function run({ flags }) { // quiet-success path. Only the human-facing line stays gated on "did // anything observable happen". if (!flags.purge) saveKitConfig(cfg); - if (ret.undo.changed || ret.artifacts.changed || !ret.ok) { - (ret.ok ? ok : warn)(ret.ok - ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` - : `${hostId} teardown incomplete — ${ret.undo.detail}`); - } + for (const line of undoReport.lines) printReportLine(line); } if (flags.purge && fs.existsSync(paths.kitConfigPath())) { if (ownershipTeardownOk) act('removed kit.json', () => fs.rmSync(paths.kitConfigPath())); diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 9ed021f..8f62ae4 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -14,6 +14,7 @@ import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.m import { validateAdapterManifest } from '../../lib/adapters/manifest.mjs'; import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs'; import * as consentStore from '../../lib/adapters/consent.mjs'; +import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; @@ -267,16 +268,127 @@ function revoke({ name, consent }) { return 0; } +/** Unwrap a reader result down to the raw manifest document — + * runTieredConformance's `readManifest` follows admitAdapters' own contract + * ((source) => Promise raw), while `reader` here may return either the + * sources.mjs `{raw, origin}` shape or a bare raw document (same tolerance + * loadAndHash above applies). */ +function toRawManifestReader(reader) { + return async (source) => { + const resolved = await reader(source); + return resolved && typeof resolved === 'object' && 'raw' in resolved ? resolved.raw : resolved; + }; +} + +function tierLine(tier) { + const detail = tier.detail + ?? tier.checks.find((c) => !c.ok)?.detail + ?? tier.checks[0]?.detail + ?? ''; + return `${String(tier.tier).padEnd(18)} ${String(tier.status).padEnd(8)} ${dim(stripControl(detail))}`; +} + +function hookCommandsFor(manifest) { + const hooks = []; + for (const [verb, def] of Object.entries(manifest.lifecycle ?? {})) { + if (def?.hook?.command) hooks.push(`lifecycle.${verb}: ${JSON.stringify(def.hook.command)}`); + } + if (manifest.execution?.run?.hook?.command) { + hooks.push(`execution.run: ${JSON.stringify(manifest.execution.run.hook.command)}`); + } + return hooks; +} + +/** F6 (Wave C security review): `conformance` self-consents — it records its + * own temporary consent and spawns declared hooks with only the experimental + * flag plus a kit.json entry as gates, needing no prior + * `ak host adapters trust`. That is the intended self-test posture (ADR-0031 + * §5), not a consent bypass, but the operator/maintainer must not be + * surprised by it: disclose every hook command about to run as a REAL + * subprocess before the harness does anything. Best-effort and non-fatal — + * a manifest that can't be pre-read/validated here still gets a generic + * warning; the harness's own admission tier reports the real failure reason + * either way, this is disclosure only, never a gate. */ +async function warnAboutHooks(name, entry, rawReader) { + warn(`'${name}' conformance is a SELF-TEST (ADR-0031 §5) — it records its own temporary consent and needs no prior 'ak host adapters trust'.`); + let manifest; + try { + manifest = validateAdapterManifest(await rawReader(entry.source)); + } catch { + warn(' the manifest could not be pre-disclosed here; any declared hooks still run as REAL subprocesses.'); + return; + } + const hooks = hookCommandsFor(manifest); + if (!hooks.length) { + info(` '${name}' declares no lifecycle/execution hooks — nothing will be spawned.`); + return; + } + warn(' the following hooks will run as REAL subprocesses:'); + // N-1 (Wave C security review): hook.command is arbitrary validated + // strings — JSON.stringify escapes C0 (0x00-0x1F) but leaves C1 + // (0x80-0x9F, e.g. U+009B CSI) and DEL (0x7F) untouched, same gap F3 + // closed elsewhere in this file. This is the one line whose entire job is + // to be trustworthy before any code runs, so it routes through the same + // stripControl every other untrusted-string sink here already uses. + for (const line of hooks) console.log(` ${dim(stripControl(line))}`); +} + +/** `ak host adapters conformance ` — runs the ADR-0031 §2 tiered + * conformance harness against one configured adapter entry and prints a + * per-tier table. Recording into the grant store happens INSIDE + * runTieredConformance itself (conformance.mjs) — this command's job is + * resolving the cfg entry, wiring a real reader, disclosing what is about to + * spawn, and rendering the report; see conformance.mjs's own header for the + * recording semantics (passed -> recordTierResult, upstream-gated -> + * recordTierGate, everything else persists nothing). */ +async function conformance({ + name, cfg, reader, runTiered, consentFile, grantsFile, +}) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters conformance '); return 2; } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; } + + const rawReader = toRawManifestReader(reader); + await warnAboutHooks(name, entry, rawReader); + + let report; + try { + report = await runTiered({ + name, + manifestSource: entry.source, + readManifest: rawReader, + consentFile, + grantsFile, + }); + } catch (error) { + fail(`'${name}' conformance run failed: ${stripControl(error?.message ?? String(error))}`); + return 1; + } + + console.log(bold(`host adapter conformance — ${report.name}`) + (report.hash ? dim(` (${report.hash})`) : '')); + let anyFailed = false; + for (const tier of report.tiers) { + const line = tierLine(tier); + if (tier.status === 'passed') ok(line); + else if (tier.status === 'failed') { fail(line); anyFailed = true; } + else if (tier.status === 'gated') warn(line); + else info(line); + } + return anyFailed ? 1 : 0; +} + /** * @param {{ positionals?: string[], flags?: any, env?: NodeJS.ProcessEnv, * 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 }} [args] + * isTTY?: boolean, cfg?: any, runTieredConformance?: (options: any) => Promise, + * consentFile?: string, grantsFile?: string }} [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, } = {}) { const sub = positionals[0] ?? 'list'; const name = positionals[1]; @@ -284,8 +396,9 @@ export async function run({ // Revocation is fail-safe and stays reachable regardless of the // experimental flag: an operator who turns the flag OFF must still be // able to withdraw a standing consent record, or that record silently - // reactivates the next time the flag is turned back on. `list`/`trust` - // stay gated — they're the surface that reads/records new trust. + // reactivates the next time the flag is turned back on. `list`/`trust`/ + // `conformance` stay gated — they're the surface that reads/records new + // trust or evidence. if (sub === 'revoke') return revoke({ name, consent }); if (!flagEnabled(env)) { @@ -302,7 +415,12 @@ export async function run({ yes: !!flags.yes, expectHash: flags['expect-hash'], }); } + if (sub === 'conformance') { + return conformance({ + name, cfg: resolvedCfg, reader, runTiered: runTieredConformance, consentFile, grantsFile, + }); + } - fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke)`); + fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance)`); return 2; } diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index eba98a1..0702ab6 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -219,6 +219,14 @@ export async function bootstrapHostAdapters({ const { applyAdmitted } = await import('./admitted.mjs'); applyAdmitted(admitted); + // name -> the cfg entry's own declared source, for F-1's baseDir + // derivation below (admitted results carry the validated manifest, not + // the raw cfg entry that named where it came from). Shared by both the + // execution- and lifecycle-registration blocks below — one map, not a + // second copy — so a caller correcting F-1 in one place can't drift from + // the other. + const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); + // 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 @@ -228,10 +236,6 @@ export async function bootstrapHostAdapters({ result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true )); if (executionCandidates.length) { - // name -> the cfg entry's own declared source, for F-1's baseDir - // derivation below (admitted results carry the validated manifest, not - // the raw cfg entry that named where it came from). - const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source])); try { const { registerAdmittedExecution } = await import('../execution/admitted.mjs'); for (const result of executionCandidates) { @@ -259,6 +263,47 @@ export async function bootstrapHostAdapters({ } } } + + // P3 (ADR-0031): an admitted manifest declaring a lifecycle block gets its + // derived lifecycle adapter registered here, so it appears in + // hostsWithLifecycle() and setup/sync/uninstall's lifecycle loops can + // drive it (gated per-run by lifecycleExecutionEnabled — registration + // alone never runs a hook). Same guarded, non-fatal posture as the + // execution-registration block above: one adapter's registration failure + // never blocks the others or the admission result. F-1 (same as + // execution above): baseDir anchors a relative lifecycle hook command to + // the adapter's own directory — without it, a relative command would + // resolve against the OPERATOR's cwd, arbitrary-code-execution with the + // consent hash unchanged. Unlike execution (one hook, all-or-nothing), + // lifecycle has five independently-optional verbs, so an unanchorable + // one is refused per-verb (buildAdmittedLifecycleAdapter never wires it + // to spawn) rather than failing the whole registration — the other, + // anchored/PATH-binary verbs still register and work. + const lifecycleCandidates = admitted.filter((result) => !!result.manifest?.lifecycle); + if (lifecycleCandidates.length) { + try { + const { registerAdmittedLifecycle } = await import('./lifecycle-registry.mjs'); + for (const result of lifecycleCandidates) { + try { + const baseDir = baseDirForSource(sourceByName.get(result.name)); + const adapter = registerAdmittedLifecycle(result.manifest, { baseDir }); + if (adapter.unanchoredVerbs.length) { + warnings.push({ + name: result.name, reason: 'lifecycle-unanchored', + detail: `'${result.name}' lifecycle hook(s) refused (relative command, no anchored adapter ` + + `base directory): ${adapter.unanchoredVerbs.join(', ')}`, + }); + } + } catch (error) { + warnings.push({ name: result.name, reason: error?.reason ?? 'lifecycle-registration-failed', detail: error?.message ?? String(error) }); + } + } + } catch (error) { + for (const result of lifecycleCandidates) { + warnings.push({ name: result.name, reason: 'lifecycle-registration-failed', detail: error?.message ?? String(error) }); + } + } + } } return { active: true, admitted, warnings }; diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs new file mode 100644 index 0000000..26c0f39 --- /dev/null +++ b/src/lib/adapters/conformance.mjs @@ -0,0 +1,459 @@ +// 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 +// 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/. +// +// Reuse, not reimplementation: this module composes the SAME library calls +// runConformanceReport uses (admitAdapters, applyAdmitted, effectiveHostRegistry, +// registerAdmittedLifecycle, registerAdmittedExecution, executeRunPlan) rather +// than importing that test file directly. A *.test.mjs module registers +// node:test hooks/tests as an IMPORT-TIME side effect (its own top-level +// `before`/`test` calls run the instant the module loads) — importing it from +// a CLI command would fire that TAP output on every `ak host adapters +// conformance` invocation and re-run its whole negative corpus for nothing +// this command needs. The admission tier below is a leaner subset of the same +// sequence (manifest validates -> admits -> joins the registry -> a declared +// detect hook runs as a real subprocess); the negative corpus and +// edit-invalidation checks stay owned by adapter-conformance.test.mjs, which +// exercises the admission GATE itself, not one adapter's conformance. +// +// Passing a tier RECORDS EVIDENCE (grants.mjs's recordTierResult/recordTierGate) +// — it never grants a capability. That stays the maintainer's explicit act +// (ADR-0031 §1), landing with the promotion command in a later wave. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { validateAdapterManifest } from './manifest.mjs'; +import { admitAdapters, hashManifest } from './admission.mjs'; +import { + applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds, +} from './admitted.mjs'; +import { + recordConsent, recordedHashFor as consentRecordedHashFor, isTrusted as consentIsTrusted, +} from './consent.mjs'; +import { registerAdmittedLifecycle, resetAdmittedLifecycle } from './lifecycle-registry.mjs'; +import { registerAdmittedExecution, resetAdmittedExecution } from '../execution/admitted.mjs'; +import { executeRunPlan } from '../execution/runner.mjs'; +import { have } from '../exec.mjs'; +import { + CONFORMANCE_TIERS, TIER_GRANTS, adapterGrantsPath, recordTierResult, recordTierGate, grantedCapabilitiesFor, +} from './grants.mjs'; + +export { CONFORMANCE_TIERS, TIER_GRANTS }; + +const nowIso = () => new Date().toISOString(); + +/** admitAdapters' readManifest contract: `(source) => Promise` — a real + * resolve, matching admission.mjs's own default (dynamic import so this + * module's own load stays cheap for callers that always inject a reader). */ +async function defaultReadManifest(source) { + const { resolveManifestSource } = await import('./sources.mjs'); + const { raw } = await resolveManifestSource(source); + return raw; +} + +/** The adapter's own directory, for the execution adapter's F-1 cwd-anchoring + * (execution/admitted.mjs) — same derivation admission.mjs's own + * baseDirForSource uses (not exported there, so mirrored here rather than + * reaching into that module's private internals). A remote (npm/https) + * source has no persistent local bundle: null, honestly, never a guessed cwd. */ +function baseDirForSource(source) { + if (typeof source !== 'string' || !source + || source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) { + return null; + } + try { + return path.dirname(fs.realpathSync(source)); + } catch { + return null; + } +} + +async function runCheck(checks, name, fn) { + try { + const detail = await fn(); + checks.push({ name, ok: true, detail: typeof detail === 'string' ? detail : undefined }); + } catch (error) { + checks.push({ name, ok: false, detail: error?.message ?? String(error) }); + } +} + +function evidenceFromChecks(checks) { + return checks.map((c) => c.detail).filter(Boolean).join('; '); +} + +// ── admission tier ────────────────────────────────────────────────────── +// manifest validates -> admits through admitAdapters with a real on-disk +// consent record -> joins effectiveHostRegistry() -> a declared detect hook +// runs as a real subprocess and its JSON payload flows back (ADR-0031 §2's +// evidence shape for this tier, and the shipped ADR-0029 admission gate). +async function checkAdmission({ + name, source, readManifest, consentFile, baseDir, +}) { + const checks = []; + let manifest = null; + let admittedResult = null; + + await runCheck(checks, 'manifest reads and validates', async () => { + const raw = await readManifest(source); + manifest = validateAdapterManifest(raw); + return `host id '${manifest.host.id}', contract ${manifest.contract}`; + }); + + if (!manifest) return { checks, manifest: null, hash: null }; + + const hash = hashManifest(manifest); + const resolvedName = name ?? manifest.host.id; + + await runCheck(checks, 'admits through admitAdapters with a real on-disk consent record', async () => { + recordConsent(resolvedName, hash, { file: consentFile }); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: resolvedName, source }] }, + readManifest, + consent: { + recordedHashFor: (n) => consentRecordedHashFor(n, { file: consentFile }), + isTrusted: (n, h) => consentIsTrusted(n, h, { file: consentFile }), + }, + }); + admittedResult = results[0]; + if (!admittedResult?.admitted) throw new Error(admittedResult?.detail ?? `admission refused: ${admittedResult?.reason}`); + return `admitted as '${admittedResult.entry.id}'`; + }); + + if (admittedResult?.admitted) { + await runCheck(checks, "joins effectiveHostRegistry()", async () => { + applyAdmitted([admittedResult]); + if (!effectiveHostRegistry().some((host) => host.id === resolvedName)) throw new Error('not present in effectiveHostRegistry()'); + if (!admittedHostIds().includes(resolvedName)) throw new Error('not present in admittedHostIds()'); + return 'joined effectiveHostRegistry()'; + }); + } + + const detectHook = manifest.lifecycle?.detect?.hook; + if (detectHook) { + await runCheck(checks, 'declared detect hook runs as a real subprocess', async () => { + // No runHook injection: exercises buildAdmittedLifecycleAdapter's real + // default, a dynamic import of the real hook-runner.mjs — the whole + // chain (derived adapter -> hook runner -> spawned process -> stdout + // JSON) actually runs, not just wired (mirrors runConformanceReport). + // F1 (Wave C security review): baseDir MUST be threaded through here — + // with no baseDir, buildAdmittedLifecycleAdapter's own F-1 anchoring + // check (lifecycle-registry.mjs) refuses ANY relative lifecycle hook + // command outright (adapter.unanchoredVerbs), which would report every + // realistically-authored file-sourced adapter — including the repo's + // own acme fixture — as FAILED here for a reason that has nothing to + // do with the adapter's actual conformance. + const adapter = registerAdmittedLifecycle(manifest, { baseDir }); + const detected = await adapter.detect({}); + if (!detected || typeof detected !== 'object') throw new Error('detect hook returned no observation'); + if (detected.error) throw new Error(`detect hook reported an error: ${detected.error}`); + return 'detect hook produced an observation'; + }); + } else { + checks.push({ name: 'declared detect hook runs as a real subprocess', ok: true, detail: 'no detect hook declared — nothing to prove' }); + } + + return { checks, manifest, hash }; +} + +// ── session-driving tier ──────────────────────────────────────────────── +// Gates canDriveSession. A manifest that never declares it has nothing to +// prove (skipped). A manifest that DOES declare it cannot be proven today: +// ak has no external session-driving execution path, and being a native +// ruflo backend is upstream-owned (ADR-0031 §4, grounded against +// ruvnet/ruflo@45e65b5's per-host ENABLE_* backend model, not an outside +// registration surface) — honestly 'gated', never faked. `upstreamRef` +// (caller-supplied, `#` form) is ONLY set once a real tracking +// issue exists to file the backend-registration request against; omitted, the +// report still says 'gated' but carries no `gatedBy` to persist — there is +// nothing yet to pin a grants.mjs recordTierGate() call to. +function checkSessionDriving({ manifest, upstreamRef }) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + if (manifest.host?.capabilities?.canDriveSession !== true) { + return { + status: 'skipped', + checks: [{ name: 'canDriveSession declared', ok: true, detail: 'not declared — nothing to prove' }], + }; + } + const detail = "ak has no external session-driving execution path yet — being a native ruflo backend is " + + 'upstream-owned (ADR-0031 §4: ruvnet/ruflo\'s per-host ENABLE_* backend model, not an outside ' + + "registration surface); interim behaviour: the host runs through ak's own supervised execution, " + + 'just not as a ruflo-native backend'; + return { + status: 'gated', + checks: [{ name: 'external session-driving execution path exists', ok: false, detail }], + detail, + ...(typeof upstreamRef === 'string' && upstreamRef ? { gatedBy: upstreamRef } : {}), + }; +} + +// ── activity-routing tier ─────────────────────────────────────────────── +// Gates canRouteActivities. Requires an execution.run hook. Admits, derives +// the real execution adapter, and drives a real one-worker executeRunPlan +// routed to the host — asserting a succeeded WorkerResult under the runner's +// contract (ADR-0018). This is the tier P2 (execution/admitted.mjs) makes +// real, so it is the one tier expected to genuinely PASS against a conforming +// fixture today. +async function checkActivityRouting({ + manifest, name, baseDir, haveFn, clock, +}) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + const canRoute = manifest.host?.capabilities?.canRouteActivities === true; + const hasHook = !!manifest.execution?.run?.hook; + if (!canRoute || !hasHook) { + return { + status: 'skipped', + checks: [{ name: 'canRouteActivities + execution.run.hook declared', ok: true, detail: 'not declared — nothing to prove' }], + }; + } + + const checks = []; + let succeeded = null; + + await runCheck(checks, 'registerAdmittedExecution derives and registers a real execution adapter', async () => { + resetAdmittedExecution(); + registerAdmittedExecution(manifest, { haveFn, baseDir }); + return `registered for '${name}'`; + }); + + await runCheck(checks, "a real one-worker executeRunPlan routed to the host returns a succeeded WorkerResult (ADR-0018)", async () => { + // No runHook injection: exercises executeRunPlan's default adapter lookup + // (executionAdapterFor -> admittedExecutionAdapterFor) end-to-end, same + // discipline as runConformanceReport's P2 check — a genuine spawned + // subprocess, not a stub. + const plan = { + workers: [{ + id: 'conformance-w1', activity: 'implementation', role: 'coder', host: name, prompt: 'conformance harness probe', + }], + }; + const [result] = await executeRunPlan(plan, { clock }); + if (result.status !== 'succeeded') throw new Error(result.failure?.reason ?? `expected a succeeded WorkerResult, got '${result.status}'`); + succeeded = result; + return `worker '${result.workerId}' succeeded via host '${result.host}' (exitCategory=${result.exitCategory})`; + }); + + resetAdmittedExecution(); + + const status = checks.every((c) => c.ok) ? 'passed' : 'failed'; + return { + status, + checks, + ...(succeeded ? { evidence: `worker succeeded via host '${succeeded.host}', exitCategory=${succeeded.exitCategory}, provider=${succeeded.provider ?? 'unknown'}` } : {}), + }; +} + +// ── primary-eligible / statusline tiers ───────────────────────────────── +// Both gate a capability the manifest can NEVER declare (canBePrimary, +// commandStatusline are inexpressible in the schema — ADR-0029/0031). Neither +// can be exercised end-to-end today: there is no lead-a-run/receive-an- +// escalation runtime path (ADR-0019) and no admitted-host statusline +// footer-render path anywhere in src/ yet (grep-verified against this wave) — +// so even a granted capability has nothing real to drive through. `exercise` +// is the injection point for the day one of those paths lands: the honest +// default reports 'gated' rather than fabricate a pass, per ADR-0031's "do +// not fake a pass" discipline. Ungranted is reported 'gated' too (an +// ak-local wait on the maintainer's promotion-command grant, Wave D — never +// an upstream ceiling, so it is never persisted via grants.mjs's +// recordTierGate, which is reserved for genuinely upstream gates). +async function checkGrantGatedTier({ + capability, manifest, name, hash, grantsFile, exercise, exerciseLabel, +}) { + if (!manifest) { + return { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } + const granted = grantedCapabilitiesFor(name, hash, { file: grantsFile })[capability] === true; + if (!granted) { + const detail = `no '${capability}' grant recorded at this manifest hash — conferred only by an explicit ` + + 'maintainer grant on top of passed conformance evidence (ADR-0031 §1), via the promotion command'; + return { + status: 'gated', + checks: [{ name: `${capability} granted`, ok: false, detail }], + detail: `ak-local: awaiting maintainer grant for '${capability}' (not an upstream ceiling)`, + }; + } + + const outcome = exercise + ? await exercise({ manifest, name, hash }) + : { ok: false, detail: `${exerciseLabel} — no runtime path built yet` }; + + if (outcome?.ok) { + const detail = typeof outcome.detail === 'string' ? outcome.detail : exerciseLabel; + return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail }], evidence: detail }; + } + return { + status: 'gated', + checks: [{ name: exerciseLabel, ok: false, detail: outcome?.detail ?? `${exerciseLabel} — no runtime path built yet` }], + detail: `ak-local: '${capability}' is granted, but the runtime path to exercise it is not built yet`, + }; +} + +/** + * Run the ADR-0031 §2 tiered conformance sequence against one adapter + * manifest and return a structured, per-tier report. Self-contained: builds + * its own temp consent store (unless `consentFile` is supplied) and cleans + * it up, resets the admitted/execution overlays on the way out, and is safe + * to call more than once in the same process — same discipline as + * runConformanceReport (tests/kit/adapter-conformance.test.mjs). + * + * Recording (grants.mjs): a 'passed' tier is recorded via recordTierResult; + * a 'gated' tier carrying a `gatedBy` upstream ref (only ever session-driving, + * and only when the caller supplies one — see checkSessionDriving) is + * recorded via recordTierGate. 'skipped'/'failed' tiers, and 'gated' tiers + * with no upstream ref, record nothing — there is nothing new to persist. + * `grantsFile` defaults to the REAL adapter-grants.json (matching grants.mjs's + * own default-to-real-config-path convention) — callers that don't want a + * conformance run to touch the real store (tests, dry runs) must pass an + * explicit path. Pass `persist: false` to skip recording altogether. + * + * @param {{ + * fixtureRoot?: string, manifestSource?: string, name?: string, + * tiers?: readonly string[], readManifest?: (source: string) => Promise, + * consentFile?: string, grantsFile?: string, persist?: boolean, + * haveFn?: (cmd: string, opts?: any) => Promise, baseDir?: string|null, + * sessionDrivingUpstreamRef?: string, + * exercisePrimaryEligible?: (ctx: {manifest:any,name:string,hash:string}) => Promise<{ok:boolean,detail?:string}>, + * exerciseStatusline?: (ctx: {manifest:any,name:string,hash:string}) => Promise<{ok:boolean,detail?:string}>, + * clock?: () => string, + * }} [options] + * @returns {Promise<{ name: string, hash: string|null, + * tiers: Array<{ tier: string, status: 'passed'|'failed'|'gated'|'skipped', + * checks: Array<{name:string, ok:boolean, detail?:string}>, + * detail?: string, gatedBy?: string, evidence?: string, recordError?: string }> }>} + */ +export async function runTieredConformance({ + fixtureRoot, + manifestSource = fixtureRoot ? path.join(fixtureRoot, 'manifest.json') : undefined, + name, + tiers = CONFORMANCE_TIERS, + readManifest = defaultReadManifest, + consentFile, + grantsFile = adapterGrantsPath(), + persist = true, + haveFn = have, + baseDir, + sessionDrivingUpstreamRef, + exercisePrimaryEligible, + exerciseStatusline, + clock = nowIso, +} = {}) { + if (typeof manifestSource !== 'string' || !manifestSource) { + throw new TypeError('runTieredConformance requires fixtureRoot or manifestSource'); + } + + let tempDir = null; + let consentFileUsed = consentFile; + if (!consentFileUsed) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-tiers-')); + consentFileUsed = path.join(tempDir, 'adapter-consent.json'); + } + + try { + // F1 (Wave C): baseDir is computed BEFORE checkAdmission so it can thread + // straight into registerAdmittedLifecycle for the detect-hook check — + // the same anchoring the execution tier already needed, just derived + // earlier now that admission needs it too. + const derivedBaseDir = baseDir !== undefined ? baseDir : baseDirForSource(manifestSource); + const admission = await checkAdmission({ + name, source: manifestSource, readManifest, consentFile: consentFileUsed, baseDir: derivedBaseDir, + }); + const resolvedName = name ?? admission.manifest?.host?.id ?? '(unknown)'; + const { hash } = admission; + // F2 (Wave C, BLOCKER): every post-admission tier gated on manifest + // validity alone (`admission.manifest != null`) would still exercise a + // manifest that schema-validated but whose REAL admission (admitAdapters + // — the consent/contract/builtin-shadow gate) was refused: e.g. stale or + // missing consent. That let a refused adapter's execution.run hook be + // spawned for real and its result recorded 'passed' into grantsFile + // (which defaults to the operator's REAL adapter-grants.json). Gating on + // the WHOLE admission tier passing — not just the manifest parsing — + // closes that: a manifest whose admission failed is treated identically + // to one that never validated at all, for every downstream tier. + const admissionPassed = admission.checks.length > 0 && admission.checks.every((c) => c.ok); + const effectiveManifest = admissionPassed ? admission.manifest : null; + const wantTier = (tier) => tiers.includes(tier); + + const tierResults = []; + + if (wantTier('admission')) { + tierResults.push({ + tier: 'admission', + status: admissionPassed ? 'passed' : 'failed', + checks: admission.checks, + }); + } + + if (wantTier('session-driving')) { + tierResults.push({ tier: 'session-driving', ...checkSessionDriving({ manifest: effectiveManifest, upstreamRef: sessionDrivingUpstreamRef }) }); + } + + if (wantTier('activity-routing')) { + const result = await checkActivityRouting({ + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + }); + tierResults.push({ tier: 'activity-routing', ...result }); + } + + if (wantTier('primary-eligible')) { + const result = await checkGrantGatedTier({ + capability: 'canBePrimary', + manifest: effectiveManifest, + name: resolvedName, + hash, + grantsFile, + exercise: exercisePrimaryEligible, + exerciseLabel: 'leads a run and receives an escalation (ADR-0019)', + }); + tierResults.push({ tier: 'primary-eligible', ...result }); + } + + if (wantTier('statusline')) { + const result = await checkGrantGatedTier({ + capability: 'commandStatusline', + manifest: effectiveManifest, + name: resolvedName, + hash, + grantsFile, + exercise: exerciseStatusline, + exerciseLabel: 'renders and refreshes a command-backed footer', + }); + tierResults.push({ tier: 'statusline', ...result }); + } + + if (persist && hash) { + for (const tierResult of tierResults) { + try { + if (tierResult.status === 'passed') { + const evidence = tierResult.evidence ?? evidenceFromChecks(tierResult.checks) ?? tierResult.tier; + recordTierResult(resolvedName, tierResult.tier, { hash, evidence: evidence || tierResult.tier }, { file: grantsFile }); + } else if (tierResult.status === 'gated' && tierResult.gatedBy) { + recordTierGate(resolvedName, tierResult.tier, { hash, gatedBy: tierResult.gatedBy }, { file: grantsFile }); + } + } catch (error) { + tierResult.recordError = error?.message ?? String(error); + } + } + } + + return { name: resolvedName, hash, tiers: tierResults }; + } finally { + // F7 (Wave C security review): all THREE overlays a conformance run can + // touch — the host overlay, the execution overlay, and the lifecycle + // registration checkAdmission's detect-hook check creates via + // registerAdmittedLifecycle — must reset together. Resetting only the + // first two (as before) left an already-registered lifecycle adapter + // live for a host id this run's own admission may have just refused, + // the same live-edge class F-9 (execution/admitted.mjs's + // resetAllAdmitted) already closed for the other two overlays. + resetAdmitted(); + resetAdmittedExecution(); + resetAdmittedLifecycle(); + if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } + } +} diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs index 59b6188..ee0d6e0 100644 --- a/src/lib/adapters/lifecycle-registry.mjs +++ b/src/lib/adapters/lifecycle-registry.mjs @@ -12,12 +12,20 @@ // — so this is a new, one-way edge (adapters/* -> opencode.mjs) that never // cycles back (opencode.mjs has no reason to import this module: callers // reach it through lifecycleAdapterFor/hostsWithLifecycle instead). +import path from 'node:path'; import { validateLifecycleAdapter, LIFECYCLE_OPERATIONS, lifecycleResult } from './lifecycle.mjs'; import { HOST_REGISTRY } from './registries.mjs'; import { effectiveHostRegistry } from './admitted.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER } from '../opencode.mjs'; const LIFECYCLE_ADAPTERS = new Map(); +// F7 (Wave C security review — prioritized for P4's paired-overlay-reset +// integration, mirroring execution/admitted.mjs's own resetAllAdmitted +// pairing): tracks which LIFECYCLE_ADAPTERS keys came from +// registerAdmittedLifecycle (never a built-in) so resetAdmittedLifecycle() +// below can clear exactly those without disturbing 'opencode' (or any other +// built-in registered via registerBuiltinLifecycle). +const ADMITTED_LIFECYCLE_IDS = new Set(); /** * Register a built-in host's lifecycle adapter. Internal — called by this @@ -68,24 +76,74 @@ export function hostsWithLifecycle() { /** * Host ids with a registered lifecycle adapter, restricted to BUILT-IN hosts * (LIFECYCLE_ADAPTERS keys intersected with HOST_REGISTRY — never an - * admitted external, even after applyAdmitted). setup.mjs/sync.mjs/ - * uninstall.mjs's lifecycle loops destructure an opencode-SHAPED result - * (stack.oc/.plugin/.agents/.skill, ret.undo/.artifacts) — those loop bodies - * are not generic across hosts yet. Today that's unreachable dead code, - * because registerAdmittedLifecycle is never called in production — but it - * is ARMED: the moment something wires an admitted external's lifecycle - * adapter in (registerAdmittedLifecycle exists for exactly that), a - * non-opencode-shaped host would enter hostsWithLifecycle() and crash one of - * those command loops on the first opencode-specific destructure. Command - * loops use this function instead until external lifecycle EXECUTION and a - * shape-agnostic loop body graduate together, in a later wave. - * hostsWithLifecycle() above stays for pure registry queries, unaffected. + * admitted external, even after applyAdmitted). Kept as a pure, built-ins- + * only registry query — e.g. for an install-hint lookup that only makes + * sense against HOSTS' own package metadata. + * + * setup.mjs/sync.mjs/uninstall.mjs no longer use this to pick their loop's + * iteration source (ADR-0031 P3): their lifecycle loops used to destructure + * an opencode-SHAPED result (stack.oc/.plugin/.agents/.skill, + * ret.undo/.artifacts) directly, which would have crashed the moment a + * non-opencode-shaped admitted host entered the loop. That crash risk is + * exactly what lifecycle-render.mjs's shape-dispatching renderer removes — + * the command loops now iterate hostsWithLifecycle() (built-ins + admitted) + * and render through renderApplyReport/renderUndoReport instead of raw + * destructuring, gated per-host by lifecycleExecutionEnabled() below. * @returns {string[]} */ export function builtinHostsWithLifecycle() { return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id); } +/** + * True when hostId is one of HOST_REGISTRY's own entries (never an admitted + * external, even after applyAdmitted). Used by lifecycleExecutionEnabled and + * detectionBinFor to tell a built-in from an admitted host without a command + * reaching into registries.mjs directly. + * @param {string} hostId + * @returns {boolean} + */ +export function isBuiltinHost(hostId) { + return HOST_REGISTRY.some((host) => host.id === hostId); +} + +const EXPERIMENTAL_HOST_ADAPTERS_FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +/** + * Whether setup/sync/uninstall should actually exercise hostId's lifecycle + * adapter this run (ADR-0031 P3). A BUILT-IN host is gated only by cfg's own + * enablement — unchanged from before this wave. An ADMITTED external host + * needs BOTH: explicit cfg enablement (opt-in exactly like opencode — there + * is no pick-UI for external hosts yet, so enablement is operator-set in + * kit.json) AND the experimental flag. Neither condition is ever inferred or + * set here — this function only reads, never auto-enables anything. + * @param {string} hostId + * @param {any} cfg + * @param {NodeJS.ProcessEnv} [env] + * @returns {boolean} + */ +export function lifecycleExecutionEnabled(hostId, cfg, env = process.env) { + if (!cfg?.integrations?.hosts?.[hostId]) return false; + if (isBuiltinHost(hostId)) return true; + return env?.[EXPERIMENTAL_HOST_ADAPTERS_FLAG] === '1'; +} + +/** + * The binary name a command loop should probe for hostId's CLI presence + * before exercising its lifecycle adapter (mirrors the built-in convention + * `have(hostId)` — a built-in's host id IS its binary name). An admitted + * host's binary name is whatever its manifest declared + * (manifest.detection.bin, always present on a validated manifest) — + * buildAdmittedLifecycleAdapter stashes it on the registered adapter as + * `.detectionBin` so this never needs a second store keyed by host id. + * @param {string} hostId + * @returns {string} + */ +export function detectionBinFor(hostId) { + if (isBuiltinHost(hostId)) return hostId; + return lifecycleAdapterFor(hostId)?.detectionBin ?? hostId; +} + // ── admitted (external) lifecycle adapters ───────────────────────────────── // A derived adapter never runs third-party code in-process: each declared // verb is a thin wrapper that shells out through the sibling's hook runner @@ -96,18 +154,93 @@ export function builtinHostsWithLifecycle() { // manifest never declared gets an honest no-op: it never ran, so nothing // changed. +// F4 (Wave C security review — the un-applied Wave B R-1 twin): `result.stdout` +// is the MERGED stdout+stderr (hook-runner.mjs's mergeCapture) — any stderr +// output (a deprecation warning, interpreter noise) breaks JSON.parse even +// when the hook fully succeeded, so a genuinely successful apply/undo would +// misreport as failed. `result.stdoutText` is the UNMERGED stdout only +// (hook-runner.mjs's boundedText(stdoutCaptured), the same field execution's +// own R-1 fix reads) — reading it here is the lifecycle-side twin of that +// fix, never applied to this file until now. function parseHookPayload(result) { - if (!result || typeof result.stdout !== 'string') return null; + if (!result || typeof result.stdoutText !== 'string') return null; try { - const parsed = JSON.parse(result.stdout); + const parsed = JSON.parse(result.stdoutText); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } +// Minimal UTF-8-safe truncation (mirrors execution/handoff.mjs's own +// truncateUtf8 — that copy is canonical; not exported there, so this is a +// local twin) so a failure detail bounded from raw hook stdout can never +// promote an unbounded blob into lifecycle-render.mjs's print path (which +// feeds F3's own control-char stripping, but a huge string is still a +// separate flooding/DoS-adjacent concern worth bounding at the source). +function truncateUtf8(value, maxBytes) { + const bytes = (v) => Buffer.byteLength(v, 'utf8'); + if (bytes(value) <= maxBytes) return value; + if (maxBytes <= 3) return '.'.repeat(Math.max(0, maxBytes)); + let out = ''; + for (const char of value) { + if (bytes(`${out}${char}…`) > maxBytes) break; + out += char; + } + return `${out}…`; +} + function hookFailureResult(verb, result) { - const detail = result?.detail || (result?.stdout || '').trim() || `hook exited ${result?.exitCode ?? 'unknown'}`; + const detail = result?.detail + || truncateUtf8((result?.stdoutText || '').trim(), 240) + || `hook exited ${result?.exitCode ?? 'unknown'}`; + if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail }; + if (verb === 'plan') return { changed: false, operations: [], error: detail }; + return lifecycleResult({ ok: false, changed: false, errors: [detail] }); +} + +// F-1 (mirrors execution/admitted.mjs's own F-1 — that copy is canonical; +// this is a minimal, lifecycle-scoped replica since the check isn't exported +// there): a relative lifecycle hook command resolves against whatever `cwd` +// the child spawns with. With no anchored adapter base directory (a remote +// npm/https source has no persistent local bundle), that would fall back to +// the OPERATOR's process.cwd() — arbitrary-code-execution by planting a +// same-named file, with the consent hash unchanged. A bare interpreter/ +// binary name found through PATH (node, hermes) is unaffected by cwd and +// stays legal; only a path-separator-bearing or script-looking bare token is +// refused. Not shared as an import (yet) — a later refactor can hoist this +// into a common module once a second caller needs it; for now the small +// duplication keeps this file independent of execution/admitted.mjs. +// F9 (Windows parity): exe/bat/cmd/com/ps1 included alongside the script +// extensions — Windows' CreateProcess searches the CURRENT DIRECTORY before +// PATH for a bare relative executable name, so a null baseDir with e.g. +// `hook.bat` is exactly as exploitable there as a relative `.mjs` is on +// POSIX and must be refused the same way. +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i; + +function looksRelative(token) { + if (typeof token !== 'string' || !token) return false; + if (path.isAbsolute(token)) return false; + if (token.includes('/') || token.includes('\\')) return true; + return SCRIPT_LIKE_RE.test(token); +} + +function commandIsUnanchorable(command) { + const [argv0, ...args] = command; + if (looksRelative(argv0)) return true; + return args.some((arg) => looksRelative(arg)); +} + +/** Honest refusal for a declared verb whose hook command is relative with no + * adapter base directory to anchor it — the hook subprocess is NEVER + * spawned for this verb (see commandIsUnanchorable above), so this can only + * ever be a refusal, never a fabricated success. Shaped exactly like + * hookFailureResult so callers (lifecycle-render.mjs's generic summary, + * runLifecycle) treat it identically to any other honest per-verb failure. */ +function unanchoredResult(verb, hostId, command) { + const detail = `'${hostId}' declares a relative lifecycle.${verb}.hook.command ` + + `(${JSON.stringify(command)}) with no anchored adapter base directory (a remote npm/https ` + + 'source has no persistent local bundle) — use an absolute path or a PATH binary'; if (verb === 'detect' || verb === 'verify') return { observed: null, error: detail }; if (verb === 'plan') return { changed: false, operations: [], error: detail }; return lifecycleResult({ ok: false, changed: false, errors: [detail] }); @@ -121,24 +254,48 @@ function hookFailureResult(verb, result) { * defaults to a dynamic import of ./hook-runner.mjs (the sibling module), * fetched lazily so this factory (and this whole file) loads cleanly even * before that module exists, and so tests never pay for the import unless - * they omit the injection on purpose. + * they omit the injection on purpose. `baseDir` (F-1) is the adapter's own + * directory — derived by the caller (admission.mjs) from the manifest's + * `source` at registration time, `null` for a source with no persistent + * local bundle (npm/https) — never process.cwd(). A verb whose hook.command + * is relative and has no baseDir to anchor it is NEVER wired to spawn — + * `adapter.unanchoredVerbs` names every verb refused this way, so a caller + * (registerAdmittedLifecycle's bootstrap caller) can surface it as a warning + * without needing to re-derive the check itself. * @param {any} manifest — validateAdapterManifest's return shape - * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}> }} [opts] + * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}>, + * baseDir?: string|null }} [opts] */ -export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) { +export function buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir = null } = {}) { const hostId = manifest.host.id; const declared = manifest.lifecycle ?? {}; - const adapter = { id: hostId }; + // Stashed alongside the verb functions (validateLifecycleAdapter only + // requires id + the five verbs — extra own-properties are untouched) so + // detectionBinFor(hostId) can find the manifest's own CLI binary name + // without a second store keyed by host id. + const adapter = { id: hostId, detectionBin: manifest.detection?.bin ?? hostId, unanchoredVerbs: [] }; for (const verb of LIFECYCLE_OPERATIONS) { const hookEntry = declared[verb]?.hook; if (!hookEntry) { adapter[verb] = async () => lifecycleResult({ ok: true, changed: false, facts: null }); continue; } + if (baseDir == null && commandIsUnanchorable(hookEntry.command)) { + adapter.unanchoredVerbs.push(verb); + adapter[verb] = async () => unanchoredResult(verb, hostId, hookEntry.command); + continue; + } adapter[verb] = async (context = {}) => { const run = runHook ?? (await import('./hook-runner.mjs')).runAdapterHook; const result = await run({ hook: hookEntry, hostId, verb, timeoutMs: hookEntry.timeoutMs, env: context.env, + // F-1: anchor a relative command to the adapter's own directory when + // one was declared; with no baseDir, the check above already proved + // this command has no relative component a cwd could redirect (bare + // PATH binaries only), so omitting cwd (Node's own default — inherit + // ak's process.cwd()) is safe and never reopens the arbitrary-code- + // execution vector this exists to close. + ...(baseDir == null ? {} : { cwd: baseDir }), }); if (!result?.ok) return hookFailureResult(verb, result); const payload = parseHookPayload(result); @@ -156,24 +313,43 @@ export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) { * an admitted-but-not-yet-overlaid host id would otherwise never pass the * built-ins-only check. * - * Not called anywhere in production yet: registering an external host here - * makes it appear in hostsWithLifecycle() (registry-query, all hosts), but - * setup.mjs/sync.mjs/uninstall.mjs deliberately read builtinHostsWithLifecycle() - * instead, which stays built-ins-only. External lifecycle EXECUTION and a - * shape-agnostic command-loop body (today's loops assume the opencode result - * shape) graduate together, in a later wave — wiring a real caller for this - * function ahead of that loop-body rewrite would crash setup/sync/uninstall - * the moment a non-opencode-shaped host is admitted. + * Called from admission.mjs's bootstrapHostAdapters (ADR-0031 P3), as a + * sibling to the execution-registration block: any admitted manifest + * declaring a lifecycle block gets its adapter registered here so it appears + * in hostsWithLifecycle(). Registration alone never runs a hook — a + * registered admitted host is only actually EXERCISED by setup/sync/ + * uninstall's loops once lifecycleExecutionEnabled() also passes (the + * experimental flag AND explicit cfg enablement) for that run. `baseDir` + * (F-1) is threaded straight through to buildAdmittedLifecycleAdapter — the + * caller derives it from the manifest's own source the same way the + * execution-registration block does (baseDirForSource). * @param {any} manifest - * @param {{ runHook?: (args: any) => Promise }} [opts] + * @param {{ runHook?: (args: any) => Promise, baseDir?: string|null }} [opts] */ -export function registerAdmittedLifecycle(manifest, { runHook } = {}) { +export function registerAdmittedLifecycle(manifest, { runHook, baseDir = null } = {}) { const hostId = manifest.host.id; if (!effectiveHostRegistry().some((host) => host.id === hostId)) { throw new TypeError(`lifecycle registry: unknown host id '${hostId}' — not present in effectiveHostRegistry`); } - const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook, baseDir }); validateLifecycleAdapter(adapter); LIFECYCLE_ADAPTERS.set(hostId, adapter); + ADMITTED_LIFECYCLE_IDS.add(hostId); return adapter; } + +/** + * Clear every ADMITTED lifecycle registration (never a built-in — 'opencode' + * and any other registerBuiltinLifecycle entry survive untouched). Pairs + * with adapters/admitted.mjs's resetAdmitted() and execution/admitted.mjs's + * resetAdmittedExecution() the same way execution/admitted.mjs's own + * resetAllAdmitted() pairs those two: a test (or a future paired-reset + * helper) that resets the host overlay without also resetting this one would + * otherwise leave a stale, unreachable-but-still-registered lifecycle + * adapter behind for a host id that effectiveHostRegistry() no longer knows + * about. + */ +export function resetAdmittedLifecycle() { + for (const hostId of ADMITTED_LIFECYCLE_IDS) LIFECYCLE_ADAPTERS.delete(hostId); + ADMITTED_LIFECYCLE_IDS.clear(); +} diff --git a/src/lib/adapters/lifecycle-render.mjs b/src/lib/adapters/lifecycle-render.mjs new file mode 100644 index 0000000..e976f20 --- /dev/null +++ b/src/lib/adapters/lifecycle-render.mjs @@ -0,0 +1,200 @@ +// Shape-agnostic lifecycle report renderer (ADR-0031 P3). setup.mjs, sync.mjs +// and uninstall.mjs used to destructure a runLifecycle() result directly +// (stack.oc/.plugin/.agents/.skill for apply, ret.undo/.artifacts for undo) — +// a shape only OPENCODE_LIFECYCLE_ADAPTER produces. That worked only because +// the command loops iterated builtinHostsWithLifecycle() (opencode-only). +// Now that the loops iterate hostsWithLifecycle() (built-ins + admitted +// externals — see lifecycle-registry.mjs), an admitted host's adapter +// (buildAdmittedLifecycleAdapter) returns the GENERIC lifecycleResult shape +// instead ({ok, changed, facts, actions, ownership, warnings, errors} — see +// lifecycle.mjs) and the raw destructure would throw on `stack.oc`. This +// module is the single place that tells the two shapes apart and turns +// either one into print-ready lines, so no command needs to know which shape +// it got. +// +// opencode's rich shape renders the lines the three commands already printed +// before this wave (same text, same conditions) — pinned by the existing +// setup/sync/uninstall test suites — plus a level fix (Wave C security +// review F5): plugin/agents/skill now carry their OWN ok/status into the +// line's level instead of a hard-coded 'ok', so a failed sub-surface renders +// as failed rather than a fabricated green checkmark. A generic admitted +// host renders one honest summary line instead: ok/changed plus a short +// detail pulled from actions/errors/warnings, never a per-surface breakdown +// the manifest never promised. + +/** True when `lifecycle` is opencode's apply() shape: `{changed, result: + * {oc, plugin, agents, skill, markersChanged}}`. Any other shape (including + * a bare generic lifecycleResult, which has no `.result` at all) is generic. */ +function isOpencodeApplyShape(lifecycle) { + return !!(lifecycle && lifecycle.result && lifecycle.result.oc); +} + +/** True when `lifecycle` is opencode's undo() shape: `{changed, result: + * {undo, artifacts, ok}}`. */ +function isOpencodeUndoShape(lifecycle) { + return !!(lifecycle && lifecycle.result && lifecycle.result.undo); +} + +// F3 (Wave C security review, BLOCKER — ANSI/control-char smuggling): a +// hostile hook's stdout (a manifest's own detect/apply/undo hook, or a +// user-editable error string that eventually lands in one of these lines) +// can carry raw ANSI control sequences — e.g. ESC[2K (erase line) + ESC[1A +// (cursor up) followed by a forged "✓ opencode: … in sync" — which a +// terminal would happily execute, erasing the real (failing) line and +// forging a fake green one. Every line this module builds funnels through +// line() below, so stripping there is the one choke point that closes it +// for every caller, opencode-shaped or generic alike. Reused shape (not +// imported — command/lib boundary): src/commands/x/host-adapters.mjs's own +// ~8-line stripControl is the canonical copy; this is the lib-side twin. +function stripControl(value) { + const input = String(value ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + // Tab/LF/CR become a space rather than vanishing — this is also what + // clamps every line to a SINGLE line (no embedded newline can smuggle a + // second, attacker-controlled "line" into the terminal). + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + // C0 (0x00-0x1f, includes ESC 0x1b) and C1 (0x7f-0x9f, includes DEL + // 0x7f) are dropped outright — an ESC-led CSI sequence loses its ESC + // byte and the rest (e.g. "[2K") survives only as inert, visible text. + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out; +} + +/** Typed constructor for one report line — keeps `level` a literal union + * instead of widening to `string` the moment it comes from a ternary, AND + * is the single choke point every line's text passes through (F3, above). + * @param {'ok'|'warn'|'info'|'fail'} level + * @param {string} text + * @returns {{level:'ok'|'warn'|'info'|'fail', text:string}} */ +function line(level, text) { + return { level, text: stripControl(text) }; +} + +// F5 (Wave C security review, BLOCKER — built-in sync regression): mirrors +// output.mjs's reportOutcome exactly (result.status ?? (ok?'ok':'failed'), +// then ok/degraded/skipped/anything-else -> ok/warn/info/fail) so a +// FAILED opencode sub-surface (plugin/agents/skill/oc itself) renders at its +// own real level instead of a level this renderer fabricates. Pre-wave sync +// read every opencode sub-result through reportOutcome directly; this is +// that same mapping, now shared by setup AND sync from the one renderer. +function levelForResult(result) { + const status = result?.status ?? (result?.ok ? 'ok' : 'failed'); + if (status === 'ok') return 'ok'; + if (status === 'degraded') return 'warn'; + if (status === 'skipped') return 'info'; + return 'fail'; +} + +/** A short, honest one-line detail for a generic lifecycleResult: the first + * error, else the first warning, else an action count, else "no changes" — + * never fabricated, never a per-surface guess. */ +function summarizeGeneric(result) { + if (Array.isArray(result?.errors) && result.errors.length) return result.errors[0]; + if (Array.isArray(result?.warnings) && result.warnings.length) return result.warnings[0]; + if (Array.isArray(result?.actions) && result.actions.length) return `${result.actions.length} action(s)`; + return 'no changes'; +} + +/** @returns {{shape:'opencode', fatal:boolean, ok:boolean, changed:boolean, + * ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderOpencodeApply(hostId, lifecycle) { + const stack = lifecycle.result; + const lines = [line(levelForResult(stack.oc), `${hostId}: ${stack.oc.detail}`)]; + if (stack.oc.fatal) { + lines.push(line('warn', `${hostId} plugin/agents/skill/guidance skipped — ${stack.oc.detail}`)); + return { + shape: 'opencode', fatal: true, ok: !!stack.oc.ok, changed: !!lifecycle.changed, + ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines, + }; + } + lines.push(line(levelForResult(stack.plugin), `${hostId} plugin: ${stack.plugin.detail}`)); + lines.push(line(levelForResult(stack.agents), `${hostId} agents: ${stack.agents.detail}`)); + // F5: restored the `|| !skill.ok` half of the gate — a failed skill write + // (ok:false, changed:false, e.g. opencode.mjs's adoptionBlocked path) must + // still be reported, not silently dropped because nothing "changed". + if (stack.skill.changed || !stack.skill.ok) { + lines.push(line(levelForResult(stack.skill), `${hostId} skill: ${stack.skill.detail}`)); + } + return { + shape: 'opencode', fatal: false, ok: !!stack.oc.ok, changed: !!lifecycle.changed, + ocChanged: !!stack.oc.changed, markersChanged: !!stack.markersChanged, lines, + }; +} + +/** @returns {{shape:'generic', fatal:boolean, ok:boolean, changed:boolean, + * ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderGenericApply(hostId, result) { + const ok = !!result?.ok; + const changed = !!result?.changed; + const verdict = ok ? (changed ? 'applied' : 'in sync') : 'apply failed'; + const lines = [line(ok ? 'ok' : 'warn', `${hostId}: ${verdict} — ${summarizeGeneric(result)}`)]; + return { + shape: 'generic', fatal: false, ok, changed, ocChanged: false, markersChanged: false, lines, + }; +} + +/** + * Turn a runLifecycle({action:'apply', ...}) result into print-ready lines. + * Dispatches on shape (see module doc) — the caller never inspects the + * result's own fields, only this report's normalized ones. + * @param {string} hostId + * @param {any} lifecycle — runLifecycle's return value + * @returns {{shape:'opencode'|'generic', fatal:boolean, ok:boolean, + * changed:boolean, ocChanged:boolean, markersChanged:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} + */ +export function renderApplyReport(hostId, lifecycle) { + return isOpencodeApplyShape(lifecycle) + ? renderOpencodeApply(hostId, lifecycle) + : renderGenericApply(hostId, lifecycle); +} + +/** @returns {{shape:'opencode', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderOpencodeUndo(hostId, lifecycle) { + const ret = lifecycle.result; + const ok = !!ret.ok; + const changed = !!(ret.undo.changed || ret.artifacts.changed); + const lines = (changed || !ok) ? [line( + ok ? 'ok' : 'warn', + ok + ? `stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)` + : `${hostId} teardown incomplete — ${ret.undo.detail}`, + )] : []; + return { shape: 'opencode', ok, changed, lines }; +} + +/** @returns {{shape:'generic', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} */ +function renderGenericUndo(hostId, result) { + const ok = !!result?.ok; + const changed = !!result?.changed; + const lines = (changed || !ok) ? [line( + ok ? 'ok' : 'warn', + ok + ? `${hostId}: undo complete — ${summarizeGeneric(result)}` + : `${hostId} teardown incomplete — ${summarizeGeneric(result)}`, + )] : []; + return { shape: 'generic', ok, changed, lines }; +} + +/** + * Turn a runLifecycle({action:'undo', ...}) result into print-ready lines. + * Same dispatch as renderApplyReport. `ok` on the return is the caller's + * ownership-teardown signal (uninstall.mjs ANDs it across every host). + * @param {string} hostId + * @param {any} lifecycle — runLifecycle's return value + * @returns {{shape:'opencode'|'generic', ok:boolean, changed:boolean, + * lines:Array<{level:'ok'|'warn'|'info'|'fail', text:string}>}} + */ +export function renderUndoReport(hostId, lifecycle) { + return isOpencodeUndoShape(lifecycle) + ? renderOpencodeUndo(hostId, lifecycle) + : renderGenericUndo(hostId, lifecycle); +} diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs index 2a3ba8c..0854f4a 100644 --- a/src/lib/execution/admitted.mjs +++ b/src/lib/execution/admitted.mjs @@ -51,7 +51,11 @@ function execError(reason, message) { // A bare interpreter/binary name found through PATH (node, hermes) is // unaffected by cwd and stays legal; only a path-separator-bearing or // script-looking bare token is refused. -const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl)$/i; +// Windows executable/script extensions (exe|bat|cmd|com|ps1) are included +// because Windows CreateProcess searches the current directory, so a bare +// `hook.bat` with no anchoring base directory would resolve from the +// operator's cwd — the same planted-file vector as a relative script on POSIX. +const SCRIPT_LIKE_RE = /\.(?:mjs|cjs|js|ts|py|rb|sh|pl|exe|bat|cmd|com|ps1)$/i; function looksRelative(token) { if (typeof token !== 'string' || !token) return false; diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs index f318ee7..089c603 100644 --- a/tests/kit/adapter-admission.test.mjs +++ b/tests/kit/adapter-admission.test.mjs @@ -7,6 +7,9 @@ // undeclared one. import { test, beforeEach } 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 { admitAdapters, bootstrapHostAdapters, hashManifest, canonicalizeManifest, SUPPORTED_CONTRACT, } from '../../src/lib/adapters/admission.mjs'; @@ -285,7 +288,11 @@ test('buildAdmittedLifecycleAdapter satisfies validateLifecycleAdapter and route const calls = []; const runHook = async (args) => { calls.push(args); - return { ok: true, stdout: JSON.stringify({ observed: { version: '1.0.0' } }), exitCode: 0 }; + // F4: parseHookPayload reads stdoutText (the UNMERGED stdout hook-runner + // reports), not stdout (stdout+stderr merged) — a real runAdapterHook + // call always populates both; this mock does too, to match that contract. + const stdout = JSON.stringify({ observed: { version: '1.0.0' } }); + return { ok: true, stdout, stdoutText: stdout, exitCode: 0 }; }; const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); assert.doesNotThrow(() => validateLifecycleAdapter(adapter)); @@ -313,13 +320,47 @@ test('buildAdmittedLifecycleAdapter reports a hook failure honestly instead of f const manifest = validateAdapterManifest(validManifest({ lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } }, })); - const runHook = async () => ({ ok: false, stdout: 'boom', exitCode: 1 }); + // No `.detail` (a real failed runAdapterHook call always sets one — see + // hook-runner.mjs — so this specifically exercises hookFailureResult's + // OWN fallback: F4's fix reads stdoutText for that fallback, not stdout). + const runHook = async () => ({ ok: false, stdout: 'boom', stdoutText: 'boom', exitCode: 1 }); const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); const result = await adapter.verify({}); assert.equal(result.observed, null); assert.equal(result.error, 'boom'); }); +test('F4: hookFailureResult\'s detail fallback reads stdoutText (unmerged), never the merged stdout+stderr blob', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } }, + })); + // stdout is the MERGED blob (what a real hook-runner would produce when + // stderr chatter follows); stdoutText is the real, unmerged signal. + const runHook = async () => ({ + ok: false, stdout: 'clean-stdout\n--- stderr ---\nnoisy stderr chatter', stdoutText: 'clean-stdout', exitCode: 1, + }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const result = await adapter.verify({}); + assert.equal(result.error, 'clean-stdout', 'the fallback must read stdoutText, not the merged stdout blob'); +}); + +test('F4: a successful hook exit with valid JSON on stdout and unrelated stderr chatter still reports ok:true (Wave B R-1 twin)', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { apply: { hook: { command: ['hermes', 'apply'] } } }, + })); + const payload = JSON.stringify({ ok: true, changed: true, actions: ['wired'], ownership: [], warnings: [], errors: [] }); + // A stray stderr warning would break JSON.parse(stdout) (the merged blob) + // pre-fix — the hook still exited 0 with a fully valid JSON payload on its + // OWN stdout, so this must report success. + const runHook = async () => ({ + ok: true, stdout: `${payload}\n--- stderr ---\nsome deprecation warning`, stdoutText: payload, exitCode: 0, + }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const result = await adapter.apply({}); + assert.equal(result.ok, true, 'stderr chatter alongside valid stdout JSON must not fail the apply'); + assert.equal(result.changed, true); +}); + test('registerAdmittedLifecycle checks effectiveHostRegistry, not HOST_REGISTRY, and is retrievable via lifecycleAdapterFor', () => { applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]); const manifest = validateAdapterManifest(validManifest()); @@ -332,3 +373,119 @@ test('registerAdmittedLifecycle throws for a host id absent from effectiveHostRe const manifest = validateAdapterManifest(validManifest({ name: 'ghost', host: validHost({ id: 'ghost' }) })); assert.throws(() => registerAdmittedLifecycle(manifest), /ghost/); }); + +// ── P3 (ADR-0031): bootstrapHostAdapters registers an admitted lifecycle ──── +// The sibling block to execution registration (§222-261 above): an admitted +// manifest declaring a lifecycle block gets its derived adapter registered +// during bootstrap, guarded and non-fatal, the same posture as execution. +// Every test here uses its own host id — LIFECYCLE_ADAPTERS is a +// process-shared Map with no unregister, so reusing 'hermes' would collide +// with the registerAdmittedLifecycle tests above. + +test('bootstrapHostAdapters registers the lifecycle adapter for an admitted manifest that declares one', async () => { + const name = 'hermes-boot-lifecycle'; + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [name, 'apply'] } } }, + })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-lifecycle' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + assert.notEqual(lifecycleAdapterFor(name), null, 'the lifecycle adapter must be registered by bootstrap'); +}); + +test('bootstrapHostAdapters never registers a lifecycle adapter for an admitted manifest with no lifecycle block', async () => { + const name = 'hermes-boot-no-lifecycle'; + const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-boot-no-lifecycle' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.equal(lifecycleAdapterFor(name), null, 'no lifecycle block declared — nothing to register'); +}); + +// ── F-1 (ADR-0031 P3, critical fix): bootstrap-level baseDir derivation ──── +// Mirrors adapter-execution.test.mjs's own F-1 (bootstrap) tests exactly: +// a file-sourced manifest's relative lifecycle hook resolves against the +// manifest's own directory (never the operator's cwd — this is a REAL +// subprocess spawn, not an injected runHook); a remote (npm/https) source +// has no persistent local bundle to anchor to, so its relative hook is +// refused with a surfaced 'lifecycle-unanchored' warning instead. + +test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and a real relative lifecycle hook runs anchored to it', async () => { + const name = 'hermes-f1-file'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-lifecycle-f1-basedir-')); + try { + // A REAL script, on disk, referenced by a RELATIVE path — proves the + // hook actually resolves against the manifest's own directory rather + // than wherever this test process happens to be running from. + fs.writeFileSync(path.join(tmpDir, 'apply-hook.mjs'), + "process.stdout.write(JSON.stringify({ok:true,changed:true,actions:['wired'],ownership:[],warnings:[],errors:[]}));\n"); + // process.execPath (absolute), not the bare token 'node' — runAdapterHook + // spawns with shell:false, and a bare 'node' does not resolve on Windows + // (no PATHEXT/shell resolution there), so the subprocess would never + // start. process.execPath is the running node's own absolute path, + // always spawnable on every OS; the RELATIVE 'apply-hook.mjs' argument + // is what this test is actually anchoring, unaffected by the change. + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } }, + })); + const manifestPath = path.join(tmpDir, 'manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: manifestPath }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + const adapter = lifecycleAdapterFor(name); + assert.notEqual(adapter, null); + assert.deepEqual(adapter.unanchoredVerbs, []); + const applied = await adapter.apply({}); + assert.equal(applied.ok, true, 'the real relative script must have actually run, anchored to the manifest\'s own directory'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('F-1 (bootstrap): an npm-sourced admitted manifest with a relative lifecycle hook surfaces a lifecycle-unanchored warning, and the verb is refused (never spawned)', async () => { + const name = 'hermes-f1-npm'; + // process.execPath, not the bare token 'node' — this verb is refused + // before ever spawning either way (npm source -> null baseDir -> unanchored), + // but kept consistent with the file-sourced test above rather than leaving + // a bare token that would misbehave the moment this test's shape changes. + const manifest = validateAdapterManifest(validManifest({ + name, host: validHost({ id: name }), + lifecycle: { apply: { hook: { command: [process.execPath, 'apply-hook.mjs'] } } }, + })); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'npm:hermes-f1-npm-adapter@1.0.0' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1, 'the host itself still admits — only the unanchored verb is refused'); + const warning = result.warnings.find((w) => w.reason === 'lifecycle-unanchored'); + assert.ok(warning, `expected a 'lifecycle-unanchored' warning; got ${JSON.stringify(result.warnings)}`); + const adapter = lifecycleAdapterFor(name); + assert.notEqual(adapter, null, 'the adapter still registers — an unanchorable verb refuses itself, not the whole adapter'); + assert.deepEqual(adapter.unanchoredVerbs, ['apply']); + const applied = await adapter.apply({}); + assert.equal(applied.ok, false, 'the hook must NEVER have been spawned for an unanchored verb'); + assert.match(applied.errors[0], /no anchored adapter base directory/); +}); diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs new file mode 100644 index 0000000..adc69ae --- /dev/null +++ b/tests/kit/conformance-tiers.test.mjs @@ -0,0 +1,406 @@ +// Tiered conformance harness (ADR-0031 §2, §5) — src/lib/adapters/conformance.mjs. +// Reuses the acme fixture adapter-conformance.test.mjs established: real +// admission, real on-disk consent, a real spawned subprocess for both the +// lifecycle detect hook and the execution.run hook. This file proves the +// GENERALIZED tiered shape on top of that same fixture: which tiers +// genuinely pass, and which are honestly 'gated'/'skipped' rather than +// faked, per ADR-0031's "do not fake a pass" discipline. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runTieredConformance, CONFORMANCE_TIERS } from '../../src/lib/adapters/conformance.mjs'; +import { grantsFor, recordTierResult, grantCapability } from '../../src/lib/adapters/grants.mjs'; +import { lifecycleAdapterFor } from '../../src/lib/adapters/lifecycle-registry.mjs'; + +const FIXTURE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../fixtures/adapters/acme'); +const VALID_MANIFEST_PATH = path.join(FIXTURE_ROOT, 'manifest.json'); + +function tempGrantsFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-tiers-grants-')); + return path.join(dir, 'adapter-grants.json'); +} + +/** + * Reads the fixture manifest EXACTLY as authored — no command rewriting. + * checkAdmission now threads a real baseDir into registerAdmittedLifecycle + * (Wave C, F1), so the fixture's own literal, unrewritten + * ["node","detect-hook.mjs"] resolves correctly through the real resolver: + * 'node' via PATH (matches how the execution.run hook already worked + * unrewritten — see the sibling comment this file used to carry), and the + * relative 'detect-hook.mjs' anchored to the manifest's own directory via + * the derived baseDir. If this rewrite were still needed, that would mean F1 + * regressed — this reader is deliberately a no-op beyond parsing, so the + * admission tier proves the real, shipped resolution path. + */ +async function readManifestFromFile(source) { + const text = fs.readFileSync(source, 'utf8'); + return JSON.parse(text); +} + +function rawAcmeManifest(overrides = {}) { + const text = fs.readFileSync(VALID_MANIFEST_PATH, 'utf8'); + const base = JSON.parse(text); + return { + ...base, + ...overrides, + host: { ...base.host, ...(overrides.host ?? {}), capabilities: { ...base.host.capabilities, ...(overrides.host?.capabilities ?? {}) } }, + }; +} + +// ── exports sanity ─────────────────────────────────────────────────────── + +test('re-exports the five ADR-0031 §2 conformance tiers in graduation order', () => { + assert.deepEqual(CONFORMANCE_TIERS, [ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + ]); +}); + +// ── admission tier: genuinely passes ──────────────────────────────────── + +test('admission tier passes against the acme fixture: real admission, real registry join, real detect-hook subprocess', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['admission'], + grantsFile, + }); + + assert.equal(report.name, 'acme'); + assert.ok(typeof report.hash === 'string' && report.hash.length > 0); + assert.equal(report.tiers.length, 1); + const [admissionTier] = report.tiers; + assert.equal(admissionTier.tier, 'admission'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.ok(admissionTier.checks.length >= 3, 'expects manifest-validates, admits, registry-join, detect-hook checks'); + assert.ok(admissionTier.checks.every((c) => c.ok)); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.hash, report.hash); + assert.equal(record.tiers.admission.status, 'passed'); +}); + +test('admission tier fails honestly on an invalid manifest, with no grant-store write', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-primary-claim', + readManifest: async () => rawAcmeManifest({ host: { capabilities: { canBePrimary: true } } }), + tiers: ['admission'], + grantsFile, + }); + const [admissionTier] = report.tiers; + assert.equal(admissionTier.status, 'failed'); + assert.ok(admissionTier.checks.some((c) => !c.ok)); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── F2 (Wave C security review, BLOCKER): a manifest that schema-VALIDATES +// but whose real admission (admitAdapters — consent/contract/name-mismatch) +// FAILS must not let any downstream tier still spawn a real hook or record +// 'passed'. Forcing a genuine admitOne 'name-mismatch' refusal (an explicit +// cfg `name` that disagrees with the manifest's own host.id) proves the +// distinction from the previous test: the manifest itself is schema-valid +// (check 1 in checkAdmission passes), only the SECOND check — the real +// admission gate — fails. Every downstream tier must see this exactly as if +// admission had never validated at all. + +test('F2: a manifest that schema-validates but fails real admission (name-mismatch) short-circuits every downstream tier to skipped, with no hook spawned and nothing persisted', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + 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'], + grantsFile, + haveFn: async () => true, + }); + + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + 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']) { + 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/); + } + + // Nothing at all persisted — not even the failed admission tier itself. + assert.equal(grantsFor('not-acme', { file: grantsFile }), null); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── activity-routing tier: genuinely passes (real subprocess worker) ─────── + +test('activity-routing tier genuinely passes against acme via a real subprocess worker (ADR-0018)', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['activity-routing'], + grantsFile, + haveFn: async () => true, // acme's detection.bin will never be on a test machine's PATH + }); + + const [tier] = report.tiers; + assert.equal(tier.tier, 'activity-routing'); + assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2)); + assert.match(tier.evidence, /succeeded/); + assert.match(tier.evidence, /acme/); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['activity-routing'].status, 'passed'); + assert.ok(record.tiers['activity-routing'].evidence.length > 0); +}); + +test('activity-routing tier is honestly skipped when the manifest declares neither canRouteActivities nor an execution.run hook', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-no-execution', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source, so the fixture's own relative + // lifecycle.detect.hook.command would be refused as unanchorable (F1) + // and fail admission for a reason unrelated to what this test isolates + // (canRouteActivities/execution.run absence) — drop lifecycle too, so + // the skip below is provably the activity-routing check's own, not a + // side effect of the admission prerequisite failing. + delete raw.lifecycle; + return raw; + }, + tiers: ['admission', 'activity-routing'], + grantsFile, + }); + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + const tier = report.tiers.find((t) => t.tier === 'activity-routing'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.equal(tier.status, 'skipped'); + assert.match(tier.checks[0].detail, /not declared/); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); // admission legitimately recorded + assert.equal(record.tiers['activity-routing'], undefined); // the skipped tier recorded nothing +}); + +// ── session-driving tier: honest skipped/gated, never faked ──────────────── + +test('session-driving tier is skipped when the manifest does not declare canDriveSession', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['session-driving'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped'); + assert.notEqual(tier.status, 'passed'); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('session-driving tier is honestly gated (never passed) when canDriveSession is declared but no external session-driving path exists', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-session-driving', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source (nothing to fs.realpathSync), + // so the fixture's own relative lifecycle.detect.hook.command would be + // refused as unanchorable (F1) and fail admission — orthogonal to what + // this test isolates (session-driving semantics), so drop lifecycle too. + delete raw.lifecycle; + return raw; + }, + tiers: ['admission', 'session-driving'], + grantsFile, + }); + const admissionTier = report.tiers.find((t) => t.tier === 'admission'); + const tier = report.tiers.find((t) => t.tier === 'session-driving'); + assert.equal(admissionTier.status, 'passed', JSON.stringify(admissionTier.checks, null, 2)); + assert.equal(tier.status, 'gated'); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /ruflo/i); + // No upstream ref supplied -> nothing persisted for THIS tier (there is no + // real tracking issue to pin a recordTierGate() call to yet) — admission + // still legitimately recorded, since it genuinely passed. + assert.equal(tier.gatedBy, undefined); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers['session-driving'], undefined); +}); + +test('session-driving tier persists via recordTierGate when a real upstream ref is supplied', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://acme-session-driving-ref', + readManifest: async () => { + const raw = rawAcmeManifest({ host: { capabilities: { canDriveSession: true, canRouteActivities: false } } }); + delete raw.execution; + // No baseDir exists for a mem:// source (nothing to fs.realpathSync), + // so the fixture's own relative lifecycle.detect.hook.command would be + // refused as unanchorable (F1) and fail admission — orthogonal to what + // this test isolates (session-driving semantics), so drop lifecycle too. + delete raw.lifecycle; + return raw; + }, + tiers: ['session-driving'], + grantsFile, + sessionDrivingUpstreamRef: 'ruvnet/ruflo#9001', + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated'); + assert.equal(tier.gatedBy, 'ruvnet/ruflo#9001'); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['session-driving'].status, 'gated'); + assert.equal(record.tiers['session-driving'].gatedBy, 'ruvnet/ruflo#9001'); +}); + +// ── primary-eligible / statusline: honest gated, ungranted or unbuilt ────── + +test('primary-eligible and statusline tiers are gated (never passed) against acme with no grant recorded', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible', 'statusline'], + grantsFile, + }); + for (const tier of report.tiers) { + assert.equal(tier.status, 'gated', `${tier.tier}: ${JSON.stringify(tier.checks)}`); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /ak-local/); + } + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('primary-eligible tier stays honestly gated even when GRANTED, because no lead/escalation runtime path is built yet', async () => { + const grantsFile = tempGrantsFile(); + // Seed a real grant the way the (future) promotion command would. + const hash = (await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + })).hash; + recordTierResult('acme', 'primary-eligible', { hash, evidence: 'seeded for test' }, { file: grantsFile }); + grantCapability('acme', 'canBePrimary', { hash }, { file: grantsFile }); + + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated'); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /granted/); + assert.match(tier.detail, /not built/); +}); + +test('primary-eligible tier PASSES when granted AND a real exercise path is injected (proves the injection seam, not a built-in fake)', async () => { + const grantsFile = tempGrantsFile(); + const hash = (await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + })).hash; + recordTierResult('acme', 'primary-eligible', { hash, evidence: 'seeded for test' }, { file: grantsFile }); + grantCapability('acme', 'canBePrimary', { hash }, { file: grantsFile }); + + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + exercisePrimaryEligible: async () => ({ ok: true, detail: 'led a run and received a simulated escalation' }), + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'passed'); + assert.equal(tier.evidence, 'led a run and received a simulated escalation'); + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'].evidence, 'led a run and received a simulated escalation'); +}); + +test('statusline tier is skipped/gated (never passed) when the admission prerequisite tier is not part of this run and the manifest never admitted', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: 'mem://does-not-admit', + readManifest: async () => { throw new Error('unreadable on purpose'); }, + tiers: ['statusline'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped'); + assert.notEqual(tier.status, 'passed'); +}); + +// ── full sequence + persistence summary ───────────────────────────────── + +test('running all five tiers together against acme yields exactly one passed-non-admission tier (activity-routing) plus admission, with nothing faked', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + grantsFile, + haveFn: async () => true, + }); + assert.equal(report.tiers.length, 5); + 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['primary-eligible'], 'gated'); + assert.equal(byTier.statusline, 'gated'); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers['activity-routing'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'], undefined, 'an ak-local gate with no upstream ref persists nothing'); + assert.equal(record.tiers.statusline, undefined); +}); + +// ── persist:false opt-out ─────────────────────────────────────────────── + +test('persist:false runs the full sequence without writing anything to the grant store', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['admission'], + grantsFile, + persist: false, + }); + assert.equal(report.tiers[0].status, 'passed'); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── self-contained + repeatable ───────────────────────────────────────── + +// ── F7 (Wave C security review): the lifecycle overlay must not leak ─────── +// checkAdmission's detect-hook check registers a real lifecycle adapter via +// registerAdmittedLifecycle. Before F7 the finally block only reset the host +// and execution overlays, leaving that registration live in +// LIFECYCLE_ADAPTERS after runTieredConformance returned — an +// already-bootstrapped-looking 'acme' lifecycle adapter surviving a +// conformance run that (in another test) may have refused it. + +test('does not leak an admitted lifecycle registration after returning (F7)', async () => { + const grantsFile = tempGrantsFile(); + assert.equal(lifecycleAdapterFor('acme'), null, 'sanity: nothing registered before this test runs'); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'passed'); + assert.equal(lifecycleAdapterFor('acme'), null, 'the admitted lifecycle registration must not survive the run'); +}); + +test('is safe to call repeatedly and cleans up its own temp consent store when none is supplied', async () => { + for (let i = 0; i < 2; i += 1) { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['admission'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'passed'); + } +}); diff --git a/tests/kit/external-lifecycle.test.mjs b/tests/kit/external-lifecycle.test.mjs new file mode 100644 index 0000000..d4ab26f --- /dev/null +++ b/tests/kit/external-lifecycle.test.mjs @@ -0,0 +1,438 @@ +// ADR-0031 P3 — external lifecycle execution wired into setup/sync/uninstall. +// A synthetic ADMITTED host ('globex') with real, standalone node-script +// lifecycle hooks (apply/undo — no injected runHook, spawned through the +// REAL hook-runner, same black-box posture as adapter-conformance.test.mjs's +// acme fixture) proves the three command loops now iterate hostsWithLifecycle() +// safely: the hook actually runs and lifecycle-render.mjs's generic one-line +// summary prints, gated by lifecycleExecutionEnabled (cfg enablement AND the +// experimental flag — an admitted host is never exercised without both). +// +// setup.mjs and uninstall.mjs's admitted-host branch is reachable through a +// real command call (setup.run_machine / uninstall.run). sync.mjs's branch +// is additionally gated by `subsystems.has(hostId)`, sourced from +// status.mjs's collect() — which has no admitted-host awareness yet +// (HOST_DETAIL_RENDERERS is hardcoded to opencode; status.mjs is out of this +// wave's scope). That pre-existing gate means an admitted host's row never +// enters sync's plan today, so its lifecycle loop body is honestly +// unreachable through a real `ak sync` — pinned below as documented, current +// behavior (not a P3 regression: the gate and its rationale predate this +// wave, and generalizing status.mjs is a separate follow-up). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + sandboxHome, assertSandboxed, captureLog, rmrf, writeKitConfig, offlineKitConfig, fakeGlobalRoot, +} from './helpers/home-sandbox.mjs'; + +const HOME = sandboxHome('ak-external-lifecycle'); +const paths = await import('../../src/lib/paths.mjs'); +const setup = await import('../../src/commands/setup.mjs'); +const sync = await import('../../src/commands/sync.mjs'); +const uninstall = await import('../../src/commands/uninstall.mjs'); +const { loadKitConfig } = await import('../../src/lib/config.mjs'); +const { validateAdapterManifest } = await import('../../src/lib/adapters/manifest.mjs'); +const { applyAdmitted, resetAdmitted } = await import('../../src/lib/adapters/admitted.mjs'); +const { registerAdmittedLifecycle, lifecycleAdapterFor } = await import('../../src/lib/adapters/lifecycle-registry.mjs'); +assertSandboxed(paths, HOME); + +const PKG_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../..'); +const FLAG = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +function globexHost(overrides = {}) { + return { + id: 'globex', + label: 'Globex', + install: { bin: 'globex-cli', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: false, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +/** Writes a real, standalone marker-writing hook script (ESM `.mjs`, no + * injected runHook — spawned through the REAL hook-runner) into `dir` under + * `filename`, and returns the RELATIVE `[process.execPath, filename]` + * command anchored to `dir` via baseDir. The marker path lives in FILE + * CONTENT, never a spawn argv element — a `node -e '