diff --git a/docs/AUTHORING-HOST-ADAPTERS.md b/docs/AUTHORING-HOST-ADAPTERS.md new file mode 100644 index 0000000..9918adb --- /dev/null +++ b/docs/AUTHORING-HOST-ADAPTERS.md @@ -0,0 +1,393 @@ +# Authoring a host adapter + +A walkthrough for adding a new agent CLI to `ak` without touching `ak`. The running example is +**Hermes** — the fourth-host candidate this contract was designed against, and the adapter that +would graduate it. Substitute your own host throughout. + +Read this alongside [ADR-0029](adr/0029-host-adapter-extension-point.md) (the contract) and +[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md) (how a capability is earned). +For what the kit does with a host once it knows about one, see +[HOST-SUPPORT.md](HOST-SUPPORT.md) and [PROVIDERS.md](PROVIDERS.md). + +> **Experimental.** The whole surface is inert unless `AK_EXPERIMENTAL_HOST_ADAPTERS=1` is set, and +> `contract: 1` can still change between alpha releases. See [The freeze](#9-the-freeze-and-why-you-matter). + +## 1. What you're actually building + +Two things: + +- **One JSON manifest** — a description of your host. Every value is JSON-serializable. A manifest + that cannot express a closure cannot smuggle one in. +- **A handful of small hook scripts** — the only part that ever executes. + +**Nothing you write runs inside the `ak` process.** Every hook is a subprocess `ak` spawns, +supervises, and owns the termination of. `ak` never calls `import()` or `require()` on a path you +supply, and registering your adapter adds no npm dependency to `@pacphi/agentic-kit`. + +That constraint is also your leverage. ADR-0029 records what adding a host used to cost: a dedicated +owner module, native surfaces reverse-engineered from scratch, and edits across roughly a dozen +files and eight test suites — plus a permanent maintenance obligation on a maintainer who may not +run your CLI. That collapses to a manifest and some hooks. You write no `ak` code, own no module in +this repository, and reverse-engineer no internals. + +What you give up in exchange is real and is covered in [section 8](#8-what-earning-actually-gets-you-today). + +## 2. Write the manifest + +Name it whatever you like as a file; name it exactly `ak-adapter.json` at the package root if you +publish it on npm. Here is a complete, valid one — this shape validates against `ak`'s real +validator: + +```json +{ + "name": "hermes", + "version": "0.1.0", + "contract": 1, + "host": { + "id": "hermes", + "label": "Hermes", + "install": { "bin": "hermes", "externalInstallPolicy": "detect-never-overwrite" }, + "capabilities": { + "canDriveSession": false, + "canBePrimary": false, + "canRouteActivities": true, + "commandStatusline": false, + "transcripts": false, + "usage": false, + "nativeMcpConfig": false, + "nativeGuidance": false + }, + "trust": { "approvalPolicy": "unchanged", "changes": [] }, + "enabledByDefault": false, + "configProjection": "ruflo", + "observability": [] + }, + "detection": { + "bin": "hermes", + "versionArgs": ["--version"], + "versionPattern": "\\d+\\.\\d+\\.\\d+" + }, + "driving": { "surfaces": ["cli-subprocess"] }, + "lifecycle": { + "detect": { "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } } + }, + "execution": { + "run": { "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 120000 } } + }, + "trust": { + "changes": [ + { + "id": "hermes-subprocess-hooks", + "kind": "third-party-adapter", + "scope": "project", + "owner": "hermes", + "value": "subprocess hooks", + "effect": "run consented lifecycle and execution hooks for hermes" + } + ] + } +} +``` + +Field by field: + +| Field | What it's for | +| --- | --- | +| `name` / `host.id` | Your host's id. They must agree, and must not collide with a built-in. | +| `host.label` | Human-readable name shown in status output. | +| `host.install.bin` | Your CLI's binary name. `externalInstallPolicy: "detect-never-overwrite"` is the honest posture — `ak` finds your CLI, never installs or upgrades it. | +| `host.capabilities` | All eight keys are **required**. Declare what your host legitimately does. | +| `host.trust` / `trust.changes` | Up-front disclosure of what your adapter touches. `trust.changes` is what the user reads before consenting. | +| `detection` | How `ak` proves your CLI is present: the binary, the version arguments, and a regular-expression source for the version. | +| `driving.surfaces` | Declare `cli-subprocess`. See below. | +| `lifecycle` / `execution` | Your hooks ([section 3](#3-write-the-hooks)). Both are optional; a manifest with neither is a pure description. | + +### Driving surfaces + +The vocabulary has three names — `cli-subprocess`, `acp`, `mcp` — but **`cli-subprocess` is the only +one with a working implementation.** Declare an `execution` block without `cli-subprocess` in +`driving.surfaces` and you get no execution adapter at all — the registration is refused +`surface-unsupported`, never silently downgraded to a surface you didn't test against. `acp` and +`mcp` are reserved for forward compatibility, and nothing drives them today. + +### The three capabilities you cannot claim + +`canBePrimary`, `commandStatusline`, and `aqeProvider` are **not yours to assert**: + +- `canBePrimary` and `commandStatusline` are required keys, and the schema accepts them **only at + `false`**. Writing `true` is rejected before anything runs (`cap-can-be-primary`, + `cap-command-statusline`). You cannot write down the claim; you **earn** the capability through a + conformance tier plus an explicit maintainer grant, recorded outside your manifest entirely + (ADR-0031 §1). +- `host.legacy.aqeProvider` must be absent — `cap-aqe-provider` refuses any value. This one is not + earnable at all through `ak`: agentic-qe's provider set is a closed upstream enumeration, so being + an AQE provider type is upstream's to grant. See [section 9](#9-the-freeze-and-why-you-matter). + +Self-declaration is the attack surface, so it stays closed permanently. The *capability* is a ladder; +the *declaration* is a wall. + +### One structural coupling worth knowing + +Declaring `execution` while `canRouteActivities` is `false` is a contradiction the schema refuses +outright (`execution-not-routable`). The converse is fine: a routable host with no `execution` block +is legal and degrades honestly at run time as `cli_unavailable`. + +## 3. Write the hooks + +Hooks are ordinary executables. They read stdin, read a few environment variables, print to stdout, +and exit with a code. No SDK, no imports from `ak`. + +### The `detect` hook (lifecycle) + +Reports that your host is present and describes what it found. It prints a JSON object on stdout; +`ak` parses it and returns it verbatim as the `detect` result. + +```js +// detect-hook.mjs +process.stdout.write(JSON.stringify({ + observed: { host: 'hermes', bin: 'hermes', version: '1.4.2' }, +})); +``` + +`detect`, `plan`, `apply`, `verify`, and `undo` are the five lifecycle verbs. Declare only the ones +you need — an undeclared verb is an honest no-op, not a fabricated success. `apply` and `undo` are +what wire (and unwire) your host's own configuration when the user runs `ak setup` / `ak uninstall`. + +### The `execution.run` hook + +This is the one that actually drives your host as a worker under `ak run`. + +- **stdin** carries the worker prompt. +- **the environment** carries `AK_WORKER_ID`, `AK_WORKER_ACTIVITY`, `AK_WORKER_ROLE`, + `AK_WORKER_MODEL`, and `AK_WORKER_CWD` (the repository being worked on — your hook does *not* + spawn there, see below). +- **stdout** carries either a JSON object — `{summary, observedModel, provider, usage}`, all + optional — or plain text, which is taken as the summary. +- **the exit code** is the sole authority for success. + +```js +// run-hook.mjs +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { prompt += chunk; }); +process.stdin.on('end', async () => { + const outcome = await driveHermes(prompt, { + cwd: process.env.AK_WORKER_CWD, + model: process.env.AK_WORKER_MODEL || undefined, + }); + if (outcome.needsLogin) process.exit(78); + process.stdout.write(JSON.stringify({ + summary: outcome.text, + observedModel: outcome.model, + provider: 'hermes', + })); +}); +``` + +### Exit codes + +| Code | Meaning | What the runner does | +| --- | --- | --- | +| `0` | Success | Reads your stdout payload. | +| `77` | Permission refused — you need consent you don't have | Records `blocked` / `permission_required` and **never escalates**. Escalating around a consent boundary is the safety violation ADR-0019 forbids. | +| `78` | Authentication required | Records `failed` / `auth_required`; the runner may retry on another host. | +| other non-zero | Ordinary failure | Records `failed` / `worker_error`; may be escalated. | + +Codes `77` and `78` exist so you can say "I refused" or "I'm not logged in" honestly, instead of a +bare non-zero exit that reads as a generic failure. + +### Rules `ak` enforces on every hook + +These aren't suggestions — they're the supervision that makes running your code safe, and they shape +how you write it: + +- **Anchored working directory.** A hook spawns with its cwd pinned to *your adapter's own resolved + directory*, never `ak`'s. So `["node", "run-hook.mjs"]` resolves to **your** `run-hook.mjs`, and a + file planted in the operator's cwd is unreachable. This is why `AK_WORKER_CWD` exists: it's how + you learn which repository to work on. A *remote*-sourced manifest (`npm:` / `https://`) has no + local directory to anchor to, so a relative command from such a source is refused + (`execution-unanchored` / `lifecycle-unanchored`) — publish remotely and you must use absolute + paths or bare PATH binaries. +- **Minimal environment.** Your hook gets `PATH`, `HOME`, and whatever `ak` injects for that verb — + never `ak`'s full environment. Don't expect to inherit the operator's secrets. +- **Bounded output.** Captured output is capped at 256 KB and truncated with a marker beyond that. +- **Bounded time.** Your declared `timeoutMs` and `ak`'s own budget both apply, and the tighter one + wins; with neither, a hook gets 30 seconds. On timeout the whole process group is killed — there is + no grace period, because the budget is already spent. +- **No shell.** `command` is an argv array, spawned directly. There is no shell to quote against. +- **stderr is never promoted.** Diagnostics you write to stderr are captured for the operator but + never folded into a downstream worker's prompt. Practical consequence: **write your JSON payload + to stdout only** — stderr chatter won't corrupt a valid JSON parse, but a plain-text summary is + discarded if you also wrote to stderr. +- **Results never launder trust.** A `provider` you declare in your payload is stamped `inferred`, + never `observed` — `ak` didn't verify it against anything. + +## 4. Publish it, and let a user opt in + +Ship the manifest any of three ways: + +| Source form | Example | Note | +| --- | --- | --- | +| File | `~/.config/ak/adapters/hermes.json` | Fully anchored; relative hook commands work. | +| npm | `npm:@you/ak-adapter-hermes` | Must ship `ak-adapter.json` at the package root. `ak` reads it from the tarball to stdout — nothing is extracted to disk and your package scripts never run. | +| URL | `https://example.com/hermes.json` | No redirects, bounded time and bytes, no credentials attached. | + +A user then registers it in their own `kit.json`: + +```json +{ + "hostAdapters": [ + { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 } + ] +} +``` + +and opts in: + +```bash +export AK_EXPERIMENTAL_HOST_ADAPTERS=1 +ak host adapters trust hermes +``` + +`trust` prints the full validated manifest — every hook command that will spawn is right there in it +— then asks for confirmation before pinning a hash of that content. **Edit the manifest afterwards +and consent invalidates**: the adapter is not admitted again until the user re-confirms the new +content. Consent lives outside your code, attached to a specific byte sequence, never to a name your +content could drift underneath. + +Three more notes for your install docs: + +- Unattended consent to a **remote** source (`npm:` / `https://`) requires `--yes --expect-hash + `. `--yes` alone is refused for a non-file origin, so a CI run can never blanket-consent to + whatever the remote happens to serve. Publish the hash alongside your adapter. +- With the flag unset, a `hostAdapters` entry is parsed and preserved but never admitted. It is + reported present-but-inactive, not silently dropped. +- Your **lifecycle** hooks only run during `ak setup` / `ak sync` / `ak uninstall` when the user has + *also* explicitly enabled your host under `integrations.hosts` in `kit.json`. There is no pick-UI + for external hosts yet. + +## 5. Self-test with the conformance kit + +```bash +ak host adapters conformance hermes +``` + +This runs the tiered black-box harness against your **real** host — spawning your actual hooks — and +prints an honest per-tier verdict. It warns first, listing every hook command it is about to run as +a real subprocess. Here is a real run against the repository's conformance fixture +(an adapter shaped exactly like the manifest in section 2): + +```text +host adapter conformance — acme (56fa107674d2) +admission passed host id 'acme', contract 1 +session-driving skipped not declared — nothing to prove +activity-routing passed registered for 'acme' +primary-eligible passed 'acme' completed a direct (non-escalated) run to a succeeded result, and… +statusline gated ak-local: awaiting maintainer grant for 'commandStatusline' (not an… +``` + +(Detail columns elided at the right margin; the real ones run longer.) + +What each tier means for you: + +| Tier | What it proves | What to expect | +| --- | --- | --- | +| `admission` | Your manifest validates, admits through the fail-closed gate, joins the registry, and your `detect` hook runs as a real subprocess. | **Can genuinely pass.** A failure here short-circuits every downstream tier, so nothing gets laundered. | +| `session-driving` | Gates `canDriveSession`. | `skipped` if you don't declare it. **`gated` if you do** — `ak` has no external session-driving path, and being a native ruflo backend is upstream-owned. | +| `activity-routing` | Gates `canRouteActivities`. A real one-worker `ak run` routed to your host returns a succeeded `WorkerResult`. | **Can genuinely pass.** | +| `primary-eligible` | Earns `canBePrimary`. Your host anchors a real run *and* receives a genuine ADR-0019 escalation onto itself — a second real subprocess. | **Can genuinely pass**, with no pre-existing grant. | +| `statusline` | Earns `commandStatusline`. | **`gated`.** There is no admitted-host footer-render path yet, so even a granted capability has nothing real to drive. | + +**A `gated` or `skipped` result on `session-driving` and `statusline` is expected, not your adapter +failing.** The harness never fabricates a pass, and there is no injection seam through which a caller +could substitute one. Only `failed` means something is wrong with your adapter. + +Evidence is hash-pinned to your manifest, so any edit voids it. And it's a two-way street: if a +grant-bearing tier later re-runs `failed` at the same manifest hash, the stored evidence *and* the +live capability are auto-voided. + +## 6. Propose it for graduation + +You don't need a maintainer to ship. Publish, and any user can opt in behind the flag with pinned +consent — that path is entirely yours. + +To go further, hand a maintainer your conformance report. They: + +1. **Re-run the conformance kit** themselves — conformance is objective, and trust rests on + reproduced evidence rather than your report. +2. **Read your hook scripts** — the only part that executes. +3. **Decide** the tier and the destination. + +Two destinations, the maintainer's call: + +- **Blessed external adapter** — `ak host adapters bless hermes ` (`grant` is the same + command). Your adapter stays out-of-tree and experimental, holding exactly the capabilities its + tiers earned. A grant is refused unless the gating tier is recorded `passed` at the current + manifest hash, and it's re-checked at read time, not just at write time. +- **Promoted built-in** — your host descriptor is adopted into the first-party registry. This is now + an ordinary PR: a registry entry, a lifecycle adapter, an About card. Once built-in, the caps no + longer apply, because it is first-party code the maintainer vouches for. That's what promotion + means. + +A tier you can't clear because the capability lives upstream is recorded as +**`gated: #NNN`**, not failed: + +```bash +ak host adapters gate hermes session-driving ruvnet/ruflo#1234 +ak host adapters status hermes +``` + +The maintainer champions that request upstream with a named extension point, and `status` shows +exactly what your adapter is waiting on. + +## 7. The commands, in one place + +```bash +ak host adapters list # configured adapters and their consent state +ak host adapters trust # disclose the manifest, confirm, pin the hash + # (--yes --expect-hash for unattended/remote) +ak host adapters revoke # withdraw consent (works with the flag off) +ak host adapters conformance # run the tiered harness +ak host adapters status # per-tier state + granted capabilities +ak host adapters grant # maintainer: confer an earned capability (alias: bless) +ak host adapters revoke-grant [cap] # withdraw a granted capability +ak host adapters gate # record an upstream blocker against a tier +``` + +## 8. What earning actually gets you today + +Be clear-eyed about this, because the machinery is ahead of its consumers: + +- **A granted `canBePrimary`** raises the capability on your host's effective registry entry, so + `hostTierLabel` and the primary-eligible host set reflect it. But **no path yet *selects* an + external host as primary** — `ak host pick` is still built-in-scoped. You show as eligible; nothing + acts on it. +- **A granted `commandStatusline` is currently inert.** There is no runtime reader for an + admitted host's command-backed footer. The grant records what you earned; nothing renders it yet. + +Both gaps are disclosed at grant time, and both are `ak`-local work rather than upstream ceilings — +they will light up without needing anything from you. What works end-to-end today is +`activity-routing`: a real `ak run` worker on your host, supervised, with a structured result. + +## 9. The freeze, and why you matter + +Two ceilings are genuinely not `ak`'s to lift: + +- **Native ruflo backend** (`session-driving`). ruflo's backend enablement is per-host and defined + inside ruflo, not through an outside registration surface. *Interim:* your host runs through `ak`'s + own supervised execution — just not as a ruflo-native backend. +- **agentic-qe provider type** (`aqeProvider`). agentic-qe's provider set is a closed upstream + enumeration, extended only by an upstream code change. *Interim:* quality still runs through the + model provider underneath your host, so QE isn't blocked — only your host's own AQE identity is. + +Neither is faked, hidden, or shimmed. Each gets a tracked capability request and an honest interim +behaviour ([ADR-0031 §4](adr/0031-capability-graduation-and-upstream-requests.md)). + +And the contract itself stays experimental until a **real external adapter clears the full +conformance kit and survives one release of soak in the field with no contract-shape change**. That +adapter could be yours. Until then, `contract: 1` may change between alpha releases — bounded +instability that exists precisely so the first real adapter's conformance run can surface shape +problems cheaply. + +So a real Hermes isn't just a beneficiary of this machinery. It's the test that graduates the +contract from experimental to frozen. diff --git a/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md new file mode 100644 index 0000000..5004312 --- /dev/null +++ b/docs/HOST-ADAPTER-FREEZE-CHECKLIST.md @@ -0,0 +1,76 @@ +# Host-adapter contract freeze checklist (ADR-0029 §graduation, ADR-0031 §6) + +The external host-adapter contract (`contract: 1`) is **experimental** and behind +`AK_EXPERIMENTAL_HOST_ADAPTERS=1`. This is the falsifiable checklist for freezing it — +turning `contract: 1` into a guaranteed-stable shape and dropping the flag. **Do not claim +the freeze until every box is genuinely checked against a real adapter.** The freeze is not +a maintainer's assertion; it is an earned, evidenced event. + +## The freeze criterion (ADR-0031 §6, ADR-0029 graduation gate) + +Freeze requires **all** of: + +1. A **real external adapter** — maintained *outside this repository, by someone other than + an agentic-kit maintainer* (Hermes is the expected first) — passes the full conformance + kit against its real host. +2. That adapter completes **one full release's worth of soak** in the field with **no + contract-shape change** required to keep it working. +3. Only then does the manifest **contract integer** freeze: `contract: 1` becomes stable, + and any later breaking change ships as `contract: 2`, admitted alongside `contract: 1`. + +Tier graduation and the contract freeze are distinct: an adapter can earn capability tiers +while the contract is still experimental. + +## Readiness — machinery the freeze rests on (all shipped; verify before soak) + +- [ ] Admission gate, hash-pinned consent, subprocess hook-runner, `admission` tier + (ADR-0029). +- [ ] `ak host adapters trust` / `list` / `revoke` (+ `--expect-hash`). +- [ ] Remote manifest sources (file / `https` / `npm:`), resolve-before-hash. +- [ ] `ak run` drives an admitted routable host (cwd-anchored, exit-code authority, + reserved 77/78, no trust laundering). +- [ ] Admitted lifecycle execution through setup / sync / uninstall. +- [ ] Tiered conformance kit: `admission`, `activity-routing`, `primary-eligible` pass + against a real fixture; `session-driving` / `statusline` honestly gated. +- [ ] Capability-grant store + `grant`/`bless`, `gate`, `status`, `revoke-grant`; earned + capability enforced at read time; granted caps live in the effective registry. + +## Freeze gate — the real-adapter run (fill in with evidence, not intent) + +- [ ] **Real external adapter identified**, maintained outside this repo by a non-maintainer. + Adapter: `__________` Maintainer: `__________` Source: `__________` +- [ ] **Conformance report captured** from `ak host adapters conformance ` against the + real host (attach or link the per-tier verdict). + - [ ] `admission` passed + - [ ] `activity-routing` passed + - [ ] `primary-eligible` passed *(or consciously out of scope for this adapter)* + - [ ] `session-driving` — passed **iff** the upstream ruflo backend-registration request + (`docs/upstream-requests/ruflo-backend-registration.md`) has shipped; otherwise + legitimately `gated` and recorded via `ak host adapters gate`. + - [ ] `statusline` — `gated` remains acceptable at freeze (its render path is a later wave); + the freeze is of the **contract shape**, not of every tier passing. +- [ ] **Hooks read** by a maintainer (the only executing part). +- [ ] **Grant/bless decision** recorded (blessed external adapter, or promoted built-in). +- [ ] **Soak: one full release** elapsed with the adapter in the field and **no + contract-shape change** required. Release soaked through: `__________`. +- [ ] **No `contract: 1` shape change** was needed during soak (if one was, the clock resets). + +## Only when every box above is genuinely checked + +- [ ] Set the manifest `contract` shape to frozen; document that a breaking change now ships + as `contract: 2` admitted alongside `contract: 1`. +- [ ] Drop `AK_EXPERIMENTAL_HOST_ADAPTERS` gating for the frozen surface (or flip its default), + per the ADR-0029/0031 freeze decision. +- [ ] Update ADR-0029 (graduation gate) and ADR-0031 §6 status to **frozen**, dated, with the + adapter + release that earned it. + +## Honest ceilings that do NOT block the freeze + +The freeze is of the **contract shape**, not of universal tier passage. These stay open by +design and are recorded as gated, not as failures: + +- `session-driving` until ruflo ships a backend-registration surface (upstream request drafted). +- An `aqeProvider` identity until agentic-qe ships a provider-plugin API (upstream request drafted). +- `statusline` runtime rendering (no `ak` render surface for a third-party TUI yet). +- Grant *consumption* last-mile: selecting an external host as primary, and a `commandStatusline` + runtime reader. diff --git a/docs/HOST-EXTENSIBILITY-EXPLAINER.html b/docs/HOST-EXTENSIBILITY-EXPLAINER.html index 5c4fd3a..f079c33 100644 --- a/docs/HOST-EXTENSIBILITY-EXPLAINER.html +++ b/docs/HOST-EXTENSIBILITY-EXPLAINER.html @@ -199,7 +199,7 @@

Room for more hosts

  • Two kinds of host — built-in today vs. the external door
  • Why this was worth doing — the end of open-heart surgery
  • What an external adapter actually is — data plus arm's-length hooks
  • -
  • Where we are — and what's honestly next — the rollout, and the limits
  • +
  • Where we are today — what's real, and the honest limits
  • For implementers: the shape of an adapter — the manifest
  • From a contributor's idea to a built-in host — the exact sequence
  • Some ceilings aren't ours to lift — the upstream path to ruflo & agentic-qe
  • @@ -214,12 +214,18 @@

    1 · The one-sentence version

    Is this what lets us use Gemini CLI, Hermes, and others?
    -

    Yes — this is the foundation that makes those possible. - It is not yet a finished on-ramp an external assistant can be driven across today. What exists now: - the kit can recognize and register an external host from a manifest, safely and - reversibly, behind an experimental switch. What comes next is turning that recognition into - running work through it. The staging is spelled out in - section 5 — honestly, so nobody expects more than is there.

    +

    Yes — and the on-ramp is open. Behind an experimental switch, an + external host described by a manifest can be approved + (ak host adapters trust discloses it and pins its exact content), run + (ak run drives it as a supervised worker, as a real subprocess), and + certified (a tiered conformance kit exercises it against your actual CLI). What's + still genuinely ahead is narrower than it used to be: two conformance tiers remain gated + — session-driving waits on upstream, statusline on a render surface that isn't + built — and an earned capability is only partly consumed, since nothing yet selects an external + host as primary. The one thing the kit can't supply is the adapter itself: no Hermes or Gemini CLI + manifest exists yet, so using one of those still starts with someone writing it + (the authoring guide is the walkthrough). + Section 5 has the honest limits, stated plainly.

    2 · Two kinds of host

    @@ -308,13 +314,14 @@

    Built-in hosts here now

    -

    External adapters the foundation

    +

    External adapters experimental

    Hermes · Gemini CLI · anything CLI-shaped

    • Described by a manifest the adapter author publishes — not bundled in the kit.
    • Enter only through the admission gate, only behind an experimental switch.
    • Cannot self-declare that they lead, bill, or own a status line.
    • -
    • Today: can be recognized and registered. Running work through them is the next step.
    • +
    • Today: trusted, run via ak run, and able to earn capabilities through + conformance — behind the experimental flag.
    @@ -476,8 +483,8 @@

    What the kit guarantees about an external host

    status line; OpenCode: none). "Primary" means leading the session — a separate axis from taking part in ak run, which OpenCode does as a supervised worker (unpacked in "Leading vs. being supervised" just below). The safety point stands: -an external adapter cannot self-declare any of these — the schema has no field to say so — -but a capability can be earned via conformance +an external adapter cannot self-declare any of these — the schema refuses the claim outright, +before anything runs — but a capability can be earned via conformance (section 7).

    Leading vs. being supervised

    @@ -572,36 +579,39 @@

    Leading vs. being supervised

    do is lead one — be the default, own the reasoning roles, or be the host escalation resolves toward. That authority stays with a host that can be primary.

    -

    5 · Where we are — and what's honestly next

    -

    The door is built and its safety is proven. But a door a host can be registered through is -not yet a door work can be run through. Here is the truthful staging.

    +

    5 · Where we are today

    +

    The door is built, its safety is proven, and work now runs through it. What remains is not +plumbing — it's evidence: a real outside adapter has to walk the whole path before the +contract can stop being experimental. Here is the truthful staging.

    + aria-label="Rollout stages: admitting a host, approving it with the trust command, and running work through it are all shipped behind the experimental flag. Still ahead: a real adapter passing the conformance kit, then freezing the contract and dropping the flag."> - HERE NOW + SHIPPED — behind the flag + STILL AHEAD Admit register an external host from a manifest - - - Trust - a command to - approve an adapter - (smallest next step) + + Trust + disclose it, then + pin its exact + content by hash - - Run - actually drive - work through it + + Run + drive work through + it as a supervised + subprocess + Prove a real adapter @@ -620,9 +630,11 @@

    5 · Where we are — and what's honestly next

    the experimental switch stays ON until the last step -
    Foundation laid, on-ramp staged. Only the first box is done. Each later -box is a small, self-contained increment behind the same experimental switch — so nobody is exposed -to a half-built external host by accident.
    +
    On-ramp open; the proof is what's left. Admitting, approving, and running +an external host all work today. The last two boxes can't be built — they have to be +earned, by a real outside adapter clearing the conformance kit and then surviving a release +in the field. The experimental switch stays on until that happens, so nobody meets a half-proven +external host by accident.

    What can be expected today

    @@ -631,36 +643,42 @@

    What can be expected today

    shipped payoff, and it needs no flag.
  • The internals are ready for more — a new host is a registry entry or a manifest, not a rewrite.
  • -
  • An external host can be recognized and registered from a manifest, safely and - reversibly, behind AK_EXPERIMENTAL_HOST_ADAPTERS=1.
  • +
  • An external host can be trusted, run, and can earn capabilities — from a + manifest, safely and reversibly, behind AK_EXPERIMENTAL_HOST_ADAPTERS=1: + ak host adapters trust records hash-pinned consent, ak run drives it as a + supervised subprocess, and the conformance kit certifies it tier by tier.
  • Limitations — read before expecting an external host to "just work"
      -
    • Work can't be run through an external host yet. The kit can admit one into - its registry, but driving a task through it (the ak run path) is not built — an - admitted external host currently reports "no execution available" rather than running.
    • -
    • There's no command to approve one yet. Approval is checked at admission, but - the ak host adapters trust command that would record it isn't built — so in practice - admission refuses until that lands. This is the intended next step.
    • -
    • Manifests are local files only. Fetching an adapter from npm or a URL isn't - built; a manifest is a file path today.
    • An external host can't self-declare that it's primary, an AQE provider, or a status-line owner. That block on self-declaration is permanent — it's the safety invariant. But the capability itself is earnable: pass the matching conformance tier and - get a maintainer's grant, and an adapter reaches full parity (the graduation ladder in - section 7). One honest exception — being an AQE provider - type isn't the kit's to grant; agentic-qe's provider list is defined upstream - (section 8).
    • -
    • It's experimental. The contract version can still change; nothing about the - external-adapter surface is promised stable until the final "Open" stage above.
    • + get a maintainer's grant (the graduation ladder in section 7). One + honest exception — being an AQE provider type isn't the kit's to grant; agentic-qe's + provider list is defined upstream (section 8). +
    • Two conformance tiers can't be cleared yet, by design. session-driving + is gated on an upstream ruflo backend, and statusline is gated + because the kit has no footer-render surface for a third-party host. They report + gated/skipped honestly — never a fabricated pass.
    • +
    • An earned capability isn't fully consumed yet. A granted + canBePrimary makes a host show as primary-eligible, but no path yet selects + an external host as primary (ak host pick stays built-in-scoped); a granted + commandStatusline is currently inert (nothing reads it at runtime).
    • +
    • It's experimental. contract: 1 can still change between alpha + releases; nothing about the external-adapter surface is promised stable until a real external + adapter clears the full kit and soaks — the final "Open" stage above.

    6 · For implementers: the shape of an adapter

    -

    When the on-ramp is complete, authoring a host adapter will mean shipping one manifest and a few -small hook scripts. The manifest looks like this (illustrative):

    +

    Authoring a host adapter means shipping one JSON manifest and a few small +hook scripts — nothing else, and no code of yours ever runs inside the kit's own process. +The step-by-step walkthrough lives in +docs/AUTHORING-HOST-ADAPTERS.md; this is the +shape of it.

    +

    The manifest (abridged — the guide has a complete, validating one):

    {
       "name": "hermes",
       "version": "0.1.0",
    @@ -668,35 +686,73 @@ 

    6 · For implementers: the shape of an adapter

    "host": { "id": "hermes", "label": "Hermes", + "install": { "bin": "hermes", "externalInstallPolicy": "detect-never-overwrite" }, "capabilities": { - "canDriveSession": true, - "canRouteActivities": true - // canBePrimary / commandStatusline: not allowed — omitted by force + "canDriveSession": false, + "canBePrimary": false, + "canRouteActivities": true, + "commandStatusline": false, + "transcripts": false, "usage": false, + "nativeMcpConfig": false, "nativeGuidance": false }, - "install": { "bin": "hermes", "externalInstallPolicy": "detect-never-overwrite" } + "trust": { "approvalPolicy": "unchanged", "changes": [] }, + "enabledByDefault": false, "configProjection": "ruflo", "observability": [] }, "detection": { "bin": "hermes", "versionArgs": ["--version"] }, "driving": { "surfaces": ["cli-subprocess"] }, "lifecycle": { - "detect": { "hook": { "command": ["hermes", "doctor", "--json"] } } + "detect": { "hook": { "command": ["node", "detect-hook.mjs"] } } }, - "trust": { "changes": [ /* what the adapter will touch, disclosed up front */ ] } + "execution": { + "run": { "hook": { "command": ["node", "run-hook.mjs"] } } + }, + "trust": { "changes": [ ... what the adapter will touch, disclosed up front ... ] } }
    -

    It is registered in the user's own kit.json, and its exact bytes are approved:

    +

    A user registers it in their own kit.json and approves its exact bytes:

    // kit.json
     "hostAdapters": [
       { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 }
    -]
    +] + +$ export AK_EXPERIMENTAL_HOST_ADAPTERS=1 +$ ak host adapters trust hermes # discloses the manifest, then pins its hash +

    A manifest can also be published as an npm package shipping ak-adapter.json at its +root, or as an https URL.

      -
    • The conformance kit is the contract. A committed test harness admits a real - fixture adapter, runs its hooks as real subprocesses, and refuses a corpus of bad manifests with - exact reasons — the same bar a real adapter (Hermes first) must clear to graduate.
    • +
    • The hooks are the only code that runs — as supervised subprocesses. A hook + reads the worker prompt on stdin, gets the worker's identity in its environment, prints a JSON + result, and exits: 0 for success, 77 to refuse on a consent boundary + (never escalated), 78 for "not authenticated". The kit pins each hook's working + directory to the adapter's own bundle, hands it a minimal environment, bounds its output and its + time, and never promotes its stderr into another worker's prompt.
    • Approval is pinned to the bytes. Consent is a hash of the exact manifest; edit one character and the kit asks again. Content is never approved unseen.
    • Refusals are specific. An unknown field, a forbidden capability, a bad contract version, a name that collides with a built-in — each is refused by name, and one bad adapter never disturbs the others or the built-ins.
    • +
    • Self-test before you ask anyone for anything. + ak host adapters conformance hermes runs the tiered harness against your real host and + reports each tier honestly. admission, activity-routing and + primary-eligible can genuinely pass — the last one by driving a real run and receiving + a real escalation onto your host. session-driving and statusline come back + gated (or skipped, if you never declared the capability) because those paths are + not built; that is expected, not your adapter failing. The harness never fabricates a pass.
    +
    +
    What an author should not expect yet
    +
      +
    • cli-subprocess is the only working driving surface. + acp and mcp are named in the vocabulary for forward compatibility; + declaring one is refused, never silently downgraded.
    • +
    • An earned canBePrimary is only partly consumed. A grant does + raise the capability, so the host shows as primary-eligible — but no path yet selects an + external host as primary. An earned commandStatusline is currently inert: there is no + runtime reader for an admitted host's footer.
    • +
    • The contract is still experimental. contract: 1 can change + between alpha releases, and freezes only once a real external adapter clears the full kit and + survives a release of soak.
    • +
    +

    7 · From a contributor's idea to a built-in host

    This is the part that ties it together: the exact sequence from "someone wants to add Hermes" to @@ -1010,8 +1066,8 @@

    What it doesn't — and who owns it

    point and its safety model are ADR-0029 (which supersedes ADR-0016's original "closed registry" stance, narrowly — nothing under a manifest is ever loaded into the kit's process). The graduation ladder — experimental external adapter, earned capabilities via conformance tiers, promotion to -built-in, and the upstream capability-request path to ruflo and agentic-qe — is the proposed next -decision (a prospective ADR-0031), not yet ratified. Upstream facts (agentic-qe's closed provider +built-in, and the upstream capability-request path to ruflo and agentic-qe — is ADR-0031, accepted as +a governance decision with its machinery staged and its own implementation-status table. Upstream facts (agentic-qe's closed provider enum; ruflo's ENABLE_* backend model) are grounded in a source-cited research sweep of agentic-qe@3.13.10 and ruvnet/ruflo@45e65b5. Hermes and Gemini CLI are named here as illustrative candidates for the external door, not as hosts the kit supports today.

    diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index 2c9cdb1..46c5cdb 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -6,11 +6,18 @@ RuvNet Brain without treating those independent layers as interchangeable. Behind an experimental flag, agentic-kit can also admit **external host adapters** that extend this set with a host not shipped in-tree — see -[External host adapters](PROVIDERS.md#external-host-adapters-experimental) and -[ADR-0029](adr/0029-host-adapter-extension-point.md). An admitted external host -picks up the same capability-driven treatment described here, but it is not one -of the three built-ins this reference compares, and it can never claim -primary-host, AQE-provider, or status-line status. +[External host adapters](PROVIDERS.md#external-host-adapters-experimental), +[AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md), +[ADR-0029](adr/0029-host-adapter-extension-point.md), and +[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md). An admitted +external host picks up the same capability-driven treatment described here, but it +is not one of the three built-ins this reference compares. It can never +**self-declare** primary-host, AQE-provider, or status-line status — that ban is +permanent — while `canBePrimary` and `commandStatusline` are **earnable** through +a passed conformance tier plus an explicit maintainer grant. `aqeProvider` stays +upstream-owned and is never `ak`-grantable. See +[External host adapters](#external-host-adapters) below for what a grant does and +does not buy today. Evidence cutoff: **2026-08-04**. The comparison was checked against agentic-kit `4.0.0-alpha.36`, Ruflo `3.34.0`, agentic-qe `3.13.x`, RuvNet Brain `4.0.7`, @@ -218,6 +225,42 @@ subagent permission rules being ignored `AGENTS.md` guidance being forgotten ([#40348](https://github.com/anomalyco/opencode/issues/40348)). +## External host adapters + +An external adapter is data, not code: a hash-pinned manifest plus subprocess +hooks, admitted only behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and only after +`ak host adapters trust ` discloses the full validated manifest and records +your consent. `contract: 1` is still experimental and **not frozen** — the freeze +waits on a real external adapter clearing the conformance kit and soaking. + +`ak host adapters conformance ` reports each graduation tier honestly: + +| Tier | Status today | Gates | Why | +| --- | --- | --- | --- | +| `admission` | Genuinely passes | — | Manifest validation and consent are built | +| `activity-routing` | Genuinely passes | — | Real supervised subprocess worker via `ak run` | +| `primary-eligible` | Genuinely passes | `canBePrimary` | Observes a real escalation | +| `session-driving` | **Gated** | — | Being a native Ruflo backend is upstream's to grant | +| `statusline` | **Gated** | `commandStatusline` | `ak` has no render surface for it yet | + +The two gated tiers are honest ceilings, not failures — they report `gated` or +`skipped` and never `passed`, with `ak host adapters gate #NNN` +recording the upstream issue each waits on. + +What a maintainer grant (`ak host adapters grant`, alias `bless`) buys today, stated +narrowly: the capability goes live in the effective host registry from the next +flagged invocation, so the host's tier label reflects it and it joins +primary-eligibility. No path yet **selects** an external host as primary — `ak host +pick` stays built-in-scoped — and `commandStatusline` has no runtime reader, so a +granted `commandStatusline` is currently inert. Grants are withdrawable with +`revoke-grant`, and every tier result is stale-marked the moment the manifest +changes. + +A graduated adapter ends in one of two places: a **blessed external adapter** that +stays out-of-tree holding exactly the capabilities its tiers earned, or a +**promoted built-in** whose descriptor a maintainer adopts as a first-party registry +entry — an ordinary pull request, not a command. + ## Known contract discrepancies These are intentionally visible rather than hidden behind an over-broad “supported” @@ -239,7 +282,12 @@ ADR-0020 is **Implemented** as of 2026-07-30; ADR-0021 is **Accepted** and was updated 2026-08-03. See [ADR-0017](adr/0017-opencode-host.md), [ADR-0018](adr/0018-generalized-host-worker-execution.md), [ADR-0020](adr/0020-ga-stable-surfaces.md), and -[ADR-0021](adr/0021-inference-provider-provenance.md). +[ADR-0021](adr/0021-inference-provider-provenance.md). For the external-adapter +section above, [ADR-0029](adr/0029-host-adapter-extension-point.md) is the +extension point and [ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md) +amends it with capability graduation — replacing ADR-0029's permanent +capability caps with the earn-then-grant model, except for the permanent ban on +self-declaring them. ## Operational guidance diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 3a22058..11ff5c9 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -88,14 +88,58 @@ like Hermes, say? Set `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and declare it as **data ``` An adapter is a manifest plus a handful of subprocess hooks — nothing an adapter declares ever -runs inside the `ak` process itself. Registering one asks you to confirm a content hash of the -manifest; edit the manifest afterward and that consent is invalidated until you confirm again. A -broken adapter is reported and skipped — it never takes down the hosts that already work. - -An external adapter can never claim to be the primary host, an AQE provider, or the status-line -owner; those stay first-party. Nothing here installs itself: you declare the adapter, you consent -to it, and teardown remains reversible. See -[ADR-0029](adr/0029-host-adapter-extension-point.md). +runs inside the `ak` process itself. `source` may be a local file path, an `https://` URL (HTTPS +only, no redirects, bounded), or `npm:[@version]`, fetched with `npm pack --ignore-scripts` +and extracted to stdout — nothing runs and nothing lands on disk. The source is resolved *before* +hashing, so a mutated remote surfaces as `consent-stale` rather than sliding in quietly. + +Consent is explicit and hash-pinned. `ak host adapters trust ` discloses the full validated +manifest and records your consent against its content hash (`--expect-hash` pins it +non-interactively); `list` shows trust state, and `revoke` works even with the flag off. Once a +host is admitted *and* explicitly enabled in `kit.json`, its lifecycle hooks run through +`ak setup`, `ak sync`, and uninstall — a failed detect or plan aborts the apply. A broken adapter +is reported and skipped; it never takes down the hosts that already work. + +### What an external adapter can earn + +An external adapter can never **self-declare** that it is the primary host, an AQE provider, or +the status-line owner. That ban is permanent — it is the safety invariant the whole extension +point rests on. But `canBePrimary` and `commandStatusline` are **earnable**: passing the gating +conformance tier is evidence, and `ak host adapters grant ` (alias `bless`) is +a maintainer's explicit grant, refused unless that tier is recorded passed at the adapter's +current manifest hash. `aqeProvider` stays upstream-owned and is never `ak`-grantable. + +`ak host adapters conformance ` runs the tiered black-box kit: + +| Tier | Status today | Gates | +| --- | --- | --- | +| `admission` | Genuinely passes against a real adapter | — | +| `activity-routing` | Genuinely passes — real supervised subprocess worker | — | +| `primary-eligible` | Genuinely passes — observes a real escalation | `canBePrimary` | +| `session-driving` | **Gated** — a native Ruflo backend is upstream's to grant | — | +| `statusline` | **Gated** — `ak` has no render surface for it yet | `commandStatusline` | + +The two gated tiers are honest ceilings, not failures: they report `gated`/`skipped` and never +`passed`. `ak host adapters gate #NNN` records the upstream issue the ceiling +is waiting on, `status [name]` shows per-tier state (stale-marked the moment the manifest changes) +alongside granted capabilities, and `revoke-grant [capability]` withdraws one or all. + +Be precise about what a grant buys **today**. A granted capability goes live in the effective host +registry from the next flagged invocation — the host's tier label reflects it and it joins +primary-eligibility. But no path yet *selects* an external host as primary (`ak host pick` stays +built-in-scoped), and `commandStatusline` has no runtime reader. So a granted `canBePrimary` is +visible and eligible, while a granted `commandStatusline` is currently inert. + +Graduation has two destinations: a **blessed external adapter** stays out-of-tree holding exactly +the capabilities its tiers earned, or a maintainer **promotes it to a built-in** by adopting its +descriptor as a first-party registry entry — an ordinary PR, not a command. + +Nothing here installs itself: you declare the adapter, you consent to it, and teardown remains +reversible. `contract: 1` is still experimental and **not frozen** — freezing waits on a real +external adapter clearing the conformance kit and soaking. Writing one? See +[AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md). The governing decisions are +[ADR-0029](adr/0029-host-adapter-extension-point.md) and +[ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md). --- @@ -391,6 +435,8 @@ just makes the good default automatic and the customization reversible. - Capability-driven integration axes, bindings, and provenance: [ADR-0016](adr/0016-capability-driven-integration-adapters.md). - The generic local OpenAI-compatible provider: [ADR-0028](adr/0028-local-openai-compatible-providers.md). -- External host adapters (experimental): [ADR-0029](adr/0029-host-adapter-extension-point.md). +- External host adapters (experimental): [ADR-0029](adr/0029-host-adapter-extension-point.md), + amended by [ADR-0031](adr/0031-capability-graduation-and-upstream-requests.md) — capability + graduation. Authoring guide: [AUTHORING-HOST-ADAPTERS.md](AUTHORING-HOST-ADAPTERS.md). - Host env flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`): upstream ruflo ADR-034, "Optional MCP Backends". diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md index 1998718..36ac07a 100644 --- a/docs/adr/0029-host-adapter-extension-point.md +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -141,7 +141,45 @@ names: An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet supported" diagnostic. It is never silently downgraded to `cli-subprocess` — a silent downgrade would run a hook the adapter author never tested against that surface, exactly the kind of guessed -success ADR-0016 §5 and ADR-0023 already forbid elsewhere. +success ADR-0016 §5 and ADR-0023 already forbid elsewhere. An admitted host is given a +`cli-subprocess` **execution** adapter only when it declares that surface; a manifest missing it is +refused (`surface-unsupported`) rather than downgraded. + +**`cli-subprocess` execution details** (settled while wiring [ADR-0031](0031-capability-graduation-and-upstream-requests.md)'s +external-execution row, after an adversarial review of the surface): + +- **Command resolution is anchored, not ambient.** A hook subprocess runs with its working + directory pinned to the adapter's own resolved directory (a file-sourced manifest's `realpath` + directory), never `ak`'s current working directory — so a manifest declaring + `["node", "run-hook.mjs"]` runs *the adapter's* `run-hook.mjs`, and a file planted in the + operator's cwd is unreachable. A remote-sourced manifest (`npm:`/`https://`) has no persistent + local directory, so a *relative* hook command from such a source is refused + (`execution-unanchored`) rather than resolved against an ambient path; a bare PATH binary + (`node`, `hermes`) stays legal. The consent hash still pins the manifest text verbatim; the + resolution is a pure function of that text plus the (already-pinned) source, so it cannot drift + without the hash changing. + - *Boundary of the anchorability check for remote sources.* When a remote-sourced adapter has no + local directory to anchor to, its hook command spawns in the repository `ak run` was invoked in + (which the operator already runs at full trust, per ADR-0018), and the `execution-unanchored` + refusal is a **best-effort** screen for path-shaped tokens (separators, script extensions, flag + values), not a complete one: an *extensionless, separator-free* relative token + (`["node", "runhook"]`) is indistinguishable by inspection from an ordinary positional argument + (`["hermes-run", "build"]`), so it is not refused and would resolve against the repo. A complete + rule would have to reject every non-absolute, non-flag argument, which would also reject + legitimate positional arguments — a false-positive cost this contract does not pay by default. + The exposure is bounded on every axis that matters: it requires a remote (`npm:`/`https://`) + source, a consented manifest the operator hash-pinned with that exact relative token, and write + access to the operator's repo. A **file-sourced** adapter — the fixture, and every adapter that + ships a bundle — is fully anchored and unaffected. A remote-sourced adapter should declare + absolute paths or PATH binaries; a future contract revision may make that a hard requirement. +- **Reserved exit codes carry consent/auth boundaries.** Hook exit `77` maps to + `permission_required` (a blocked, never-escalated result — escalating around a consent boundary + is the safety violation ADR-0019 already forbids) and `78` to `auth_required`. This gives an + external host an honest way to say "I refused" or "I am not logged in" instead of a bare + non-zero exit that would be re-run on another host. +- **Results never launder trust.** Exit code is the sole authority for success; a self-declared + `provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into + a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s. ### 3. Capability caps are schema-structural, not runtime-checked diff --git a/docs/adr/0031-capability-graduation-and-upstream-requests.md b/docs/adr/0031-capability-graduation-and-upstream-requests.md index c912d9a..9d0b908 100644 --- a/docs/adr/0031-capability-graduation-and-upstream-requests.md +++ b/docs/adr/0031-capability-graduation-and-upstream-requests.md @@ -68,6 +68,16 @@ host, assert the real behaviour, against an installed layout — that gates one Passing a tier records evidence; the maintainer's grant turns evidence into capability. A tier the adapter cannot meet because the capability is upstream is marked **gated** (§4), not failed. +The recorded evidence is hash-pinned to the **manifest** — the same content pin consent uses +(ADR-0029 §6) — so any edit to the manifest voids the evidence and the grants that rest on it. It is +**not** pinned to the bytes of the hook *scripts* the manifest references: a file-sourced adapter can +rewrite `run-hook.mjs` under an unchanged manifest hash. This is the identical boundary ADR-0029 +already accepted for consent, carried forward here; the grant confirmation states it explicitly so a +maintainer granting `primary-eligible`/`statusline` knows the pin covers the declaration, not the +script that produced the observation. Tightening this — hashing the referenced hook files into the +tier evidence and re-verifying at grant time — is a tracked follow-up, warranted before the freeze +(§6) if a real adapter's graduation depends on it. + ### 3. Two graduation destinations A conformed adapter lands in one of two places, the maintainer's call: @@ -149,16 +159,16 @@ Per the ADR discipline this repository adopted (a dated, self-graded table befor rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly unbuilt. This table is the source of truth for what is real. -| Piece | Status (2026-08-16) | Note | +| Piece | Status | Note | | ----- | ------------------- | ---- | | Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) | -| `ak host adapters trust` CLI (records consent/grants) | **Proposed — not built** | Smallest next step; today admission refuses `consent-required` | -| External execution (`ak run` drives an admitted host) | **Proposed — not built** | The seam is a comment-only lookup today | -| 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 | -| Capability-grant store + promotion command | **Proposed — not built** | Hash-pinned, mirrors consent | -| Remote manifest sources (npm / URL) + resolve→hash ordering | **Proposed — not built** | File-path manifests only today | -| Upstream request tracking (`gated: #NNN` against a tier) | **Proposed — not built** | Needs a place to record per-tier gating | +| `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 | **Working** (2026-08-16, wave C) | The loops iterate `hostsWithLifecycle()` (built-ins + admitted) through a shape-agnostic renderer; an admitted host's lifecycle runs only when explicitly enabled in `kit.json` **and** the flag is set. Admitted lifecycle hooks are cwd-anchored to the adapter's own directory (per-verb `lifecycle-unanchored` refusal for a relative hook on a remote source), the same F-1 protection as execution. `setup`, `uninstall`, **and now `sync`** are fully live: `status.mjs`'s collector emits a subsystem-tagged row for an enabled admitted lifecycle host, so `sync`'s convergence plan reaches its admitted-host branch (wave D4 closed the earlier `sync`-only reachability gap) | +| Tiered conformance harness (`session-driving` … `statusline`) | **Working** (2026-08-16, waves C+D2) | `runTieredConformance` + `ak host adapters conformance`: `admission`, `activity-routing`, and now `primary-eligible` genuinely pass black-box against a real fixture — `primary-eligible` drives a real `executeRunPlan` where the host anchors a run and receives a genuine ADR-0019 escalation onto itself (a real second subprocess), recorded with no pre-existing grant. `session-driving`/`statusline` stay honestly `gated`/`skipped` (external session driving and the statusline render path are not built) — the harness never fabricates a pass, and there is no injection seam through which a caller could substitute one. A failed `admission` tier short-circuits every downstream tier so no evidence is laundered. A grant-bearing tier that re-runs `failed` under the same manifest hash now auto-voids the stored tier **and** the live granted capability (wave D4, N-1) — the un-earn path mirrors the gated-downgrade; a `skipped` result never voids (prerequisite not evaluated ≠ disproof). Capabilities can also be withdrawn per-capability with `ak host adapters revoke-grant [capability]`. *Bounded (tracked with the hook-bytes-pinning work before §6 freeze):* the auto-void covers a same-hash failure of the *grant-bearing* tier itself, not of its *prerequisite* (`activity-routing`) — an adapter could retain `canBePrimary` by regressing the prerequisite instead; closing this needs the `cli_unavailable`-vs-real-failure distinction (so a machine merely lacking the host CLI never false-voids a legitimate grant) and is the same manifest-vs-hook-bytes boundary the hashing work addresses. `statusline` un-earn lands with its render path | +| Capability-grant store + promotion command | **Working** (2026-08-16, waves D+D2) | `grants.mjs` (hash-pinned, evidence-gated, edit-invalidated like consent — the earned capability is enforced at **read** time, not only at grant time) plus `ak host adapters grant`/`bless`: the maintainer's explicit grant of a tier-earned capability, refused unless the gating tier is recorded `passed` at the current manifest hash. **Wave D2 makes a grant live:** at bootstrap the admitted-host overlay reads `grantedCapabilitiesFor` at the fresh current hash and raises `canBePrimary`/`commandStatusline` on the effective-registry entry (through a local allow-list that can raise only those two, never `aqeProvider` or any other key), so `hostTierLabel` and `effectivePrimaryHostIds()` reflect it. Two consumption gaps remain, honestly disclosed at grant time: no path yet *selects* an external host as primary (`ak host pick` stays built-in-scoped), and `commandStatusline` has no runtime reader yet (its render path is a later wave) | +| 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) | **Working** (2026-08-16, wave D) | `ak host adapters gate ` records a ref-format-validated upstream gate; `ak host adapters status` surfaces per-tier passed/gated state (stale-marked on a manifest edit) and the granted capabilities | | A real external adapter (Hermes) clearing the kit → contract freeze | **Not started** | Freeze criterion (§6) | ## Alternatives considered diff --git a/docs/upstream-requests/agentic-qe-provider-plugin.md b/docs/upstream-requests/agentic-qe-provider-plugin.md new file mode 100644 index 0000000..02c2ac2 --- /dev/null +++ b/docs/upstream-requests/agentic-qe-provider-plugin.md @@ -0,0 +1,72 @@ + + +# Feature request: a provider-plugin registration API for `AQE_LLM_PROVIDER` + +## Summary + +agentic-qe's LLM provider set is a closed, upstream-defined enumeration. A downstream +integrator (agentic-kit) can select any *existing* provider via `AQE_LLM_PROVIDER`, but +cannot introduce a *new* provider type without an upstream code change. We'd like a +documented registration surface so a host that a downstream tool supervises can be +recognized as its own provider identity, rather than only borrowing the model provider +underneath it. + +## Where this stands today (please re-verify against HEAD) + +Grounded against `agentic-qe@3.13.10` (as cited in agentic-kit's ADR-0031): + +- `ALL_PROVIDER_TYPES` is a fixed union of provider type strings. +- `createProvider` is a `switch` over those types; an unrecognized type has no arm. +- So the provider set is extended only by editing that enum + switch — there is no + runtime/plugin registration path. + +If any of this has changed on HEAD (a registration hook, an open provider map, a plugin +entrypoint), this request may already be satisfied — please close it as such. + +## The concrete ask + +A minimal, documented way for a downstream tool to register an additional provider type at +runtime, e.g.: + +- a `registerProvider(type, factory)` entrypoint (factory conforming to the same interface + `createProvider` returns), and inclusion of registered types in `ALL_PROVIDER_TYPES` + for validation; **or** +- a documented "external provider" adapter interface a downstream package can implement + and hand to the `HybridRouter` / `ProviderManager`. + +We are not asking to widen the *default* provider set — only for a sanctioned extension +point so `AQE_LLM_PROVIDER=` can resolve to a provider we supply, instead of us +either forking the enum or projecting our host onto an unrelated provider identity (which +would misrepresent which vendor served the work). + +## Why (the downstream context) + +agentic-kit admits external host adapters (declarative manifest + consented subprocess +hooks) and runs a tiered conformance kit against them. Quality still runs through the +*model provider underneath* a host, so QE is never blocked — but a host cannot earn an +**AQE-provider identity** of its own, because that identity is yours to define, not ours to +extend. agentic-kit deliberately refuses to fabricate one (projecting an unknown host into +your config would assert an identity you never declared you understood). This request is +the honest alternative: a real extension point, so the capability can light up when you +release it. + +## Non-goals + +- Not asking agentic-kit's hosts to be bundled into agentic-qe. +- Not asking to bypass any provider validation — registered providers should be validated + exactly like built-in ones. + +## References + +- agentic-kit ADR-0031 §4 (the upstream-request path) and ADR-0029 (the host-adapter + contract): the design that motivates this. +- `AQE_LLM_PROVIDER` / `HybridRouter` / `createProvider` / `ALL_PROVIDER_TYPES` in + agentic-qe (re-verify paths against HEAD). diff --git a/docs/upstream-requests/ruflo-backend-registration.md b/docs/upstream-requests/ruflo-backend-registration.md new file mode 100644 index 0000000..c9f46e2 --- /dev/null +++ b/docs/upstream-requests/ruflo-backend-registration.md @@ -0,0 +1,65 @@ + + +# Feature request: a documented backend-registration surface for host CLIs + +## Summary + +ruflo's set of agent **backends** (the host CLIs that drive the loop) is enabled per-host +through fixed `ENABLE_*` flags. There is no documented way for a downstream tool to +register an *additional* backend, so a host CLI that a downstream integrator supervises +cannot become a ruflo-native backend without an upstream code change. We'd like a +documented registration surface for that. + +## Where this stands today (please re-verify against HEAD) + +Grounded against `ruvnet/ruflo@45e65b5` (as cited in agentic-kit's ADR-0031): + +- Backend enablement is per-host and fixed: `ENABLE_CLAUDE_CODE`, `ENABLE_CODEX`, + `ENABLE_GEMINI_MCP` (ADR-034 "Optional MCP Backends"). +- A new backend is added by defining a new `ENABLE_*` target inside ruflo — there is no + outside/plugin registration path. + +If HEAD already exposes a backend-registration API or a documented plugin entrypoint, this +request may already be satisfied — please close it as such. + +## The concrete ask + +A documented way to register an additional agent backend from outside the ruflo tree, e.g.: + +- a backend descriptor interface (how ruflo detects the CLI, launches an interactive / + oneshot session, and observes completion) that a downstream package can implement and + register, honored by the same loop the `ENABLE_*` backends drive; **or** +- a documented convention for an external backend entrypoint ruflo will discover and drive. + +The goal is that a host CLI can *drive a ruflo session* as a first-class backend, not only +run under a downstream tool's own supervision. + +## Why (the downstream context) + +agentic-kit admits external host adapters and certifies them with a tiered conformance kit. +The `session-driving` tier — "the host actually drives an interactive/oneshot session" — +is the one an external adapter cannot clear on its own, because being a ruflo-native +backend is defined inside ruflo, not registered from outside. agentic-kit's honest interim +is to run such a host through its *own* supervised execution (`ak run`), and to mark the +tier `gated` on this request rather than fake a pass. A documented backend-registration +surface upstream is what lets that tier genuinely light up. + +## Non-goals + +- Not asking to bundle agentic-kit's hosts into ruflo. +- Not asking to loosen the trust model — a registered backend should be subject to the same + enablement/consent posture as the built-in `ENABLE_*` backends. + +## References + +- agentic-kit ADR-0031 §4 (the upstream-request path) and ADR-0029 (the host-adapter + contract). +- ruflo ADR-034 "Optional MCP Backends" and the `ENABLE_CLAUDE_CODE` / `ENABLE_CODEX` / + `ENABLE_GEMINI_MCP` backend model (re-verify paths against HEAD). 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/status.mjs b/src/commands/status.mjs index 3594881..e5dc63a 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -24,6 +24,7 @@ import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; import { HOSTS, settingsTarget, isDefault, managedEnv, MANAGED_ENV_KEYS, hostInstallState, hostAuthState, bothHostsEnabled, aqeRouterFile, aqeSupportsAgentOverrides, credentialGaps, collectIntegrationFacts } from '../lib/providers.mjs'; +import { hostsWithLifecycle, isBuiltinHost, lifecycleExecutionEnabled } from '../lib/adapters/lifecycle-registry.mjs'; import { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; @@ -185,6 +186,51 @@ export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HO return rows; } +/** Sync reachability gap (ADR-0031 P3 known limitation): setup.mjs and + * uninstall.mjs's admitted-host lifecycle loops (ADR-0031 P3) already iterate + * hostsWithLifecycle() and run for real; sync.mjs's twin loop is gated on + * BOTH lifecycleExecutionEnabled(hostId, cfg) AND `subsystems.has(hostId)`, + * where subsystems is `new Set(plan.map(p => p.subsystem))` derived straight + * from THIS collector's rows (sync.mjs). An admitted host with no + * HOST_DETAIL_RENDERERS entry (only opencode has one) produced no row at + * all, so its subsystem could never appear in the plan and sync's branch was + * unreachable for a real admitted host — even fully enabled, flag on, CLI + * present. This closes that gap: any admitted (never built-in) lifecycle + * host that lifecycleExecutionEnabled() actually gates IN for this run gets + * exactly one subsystem-tagged row, `subsystem === hostId` — deliberately + * the same identity opencode's own renderer uses (subsystem 'opencode' === + * HOST_DETAIL_RENDERERS key 'opencode'), so sync's `subsystems.has(hostId)` + * finds it. + * + * Deliberately lean, not a per-surface renderer like opencodeDetailRows: an + * arbitrary admitted host's only introspection surface is its own declared + * detect/verify hooks (a subprocess spawn), which this read-only, cheap + * collector does not invoke — so the row cannot report real drift and always + * carries a `fix` while the gate holds. Convergence is left to the adapter's + * own apply, which lifecycle.mjs's contract requires to be idempotent. + * Excludes built-in hosts (opencode already has a bespoke renderer above; + * a future built-in lifecycle host with no renderer is a gap for its own + * renderer to close, not this fallback) and any host already present in + * `renderers` (never double-reports one host under two mechanisms). Isolated + * per host, mirroring the per-renderer try/catch contract above — one + * admitted host's failure must not take down collect() or any other host's + * row. */ +function admittedLifecycleFallbackRows(cfg, renderers = HOST_DETAIL_RENDERERS) { + const rows = []; + for (const hostId of hostsWithLifecycle()) { + if (isBuiltinHost(hostId) || hostId in renderers) continue; + try { + if (!lifecycleExecutionEnabled(hostId, cfg)) continue; + rows.push(row(hostId, 'warn', + `${hostId}: external lifecycle host, enabled — sync will converge its hooks`, + `sync applies the ${hostId} lifecycle adapter`)); + } catch (e) { + rows.push(row(hostId, 'warn', `${hostId} lifecycle status unavailable: ${e.message}`)); + } + } + return rows; +} + export async function collect({ pkgRoot, cwd = process.cwd() }) { const rows = []; const cfg = loadKitConfig(); @@ -534,6 +580,7 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { // absent is the CLI-presence branch inside its own renderer, sourced from // the shared facts snapshot — no extra probing here or in the loop). rows.push(...(await renderHostDetailRows({ cfg, pkgRoot, facts: integrationFacts }))); + rows.push(...admittedLifecycleFallbackRows(cfg)); // hosts (install-if-missing) — cheap: file read + `which`, no network. // An enabled host that is entirely absent is installable by sync; an external 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-grants.mjs b/src/commands/x/host-adapters-grants.mjs new file mode 100644 index 0000000..b2b5be4 --- /dev/null +++ b/src/commands/x/host-adapters-grants.mjs @@ -0,0 +1,296 @@ +// x host adapters grant/gate/status/revoke-grant — the maintainer's P5 +// promotion CLI (ADR-0031 §1, §2, §3) and the P7 upstream-gate CLI (§4). +// Split out of host-adapters.mjs (which keeps list/trust/revoke/conformance) +// purely to stay under the house 500-line-per-file budget; both files are +// one logical surface dispatched from the same `run()` in host-adapters.mjs. +// +// Nothing here loads third-party code. grant/gate/status call grants.mjs, a +// pure data layer over a hash-pinned JSON store (adapter-grants.json). +// grantCapability there REFUSES unless the gating tier is already recorded +// 'passed' at the exact current manifest hash: a capability is conferred by +// already-recorded evidence plus this explicit maintainer act, never by the +// CLI itself exercising a path (a caller-supplied exercise result would be +// both the pass and its own evidence) — so no subcommand here accepts or +// forwards any 'exercise'/callback option. +import { + CONFORMANCE_TIERS, TIER_GRANTS, recordTierGate, grantCapability, revokeGrants, revokeCapability, + grantsFor, grantedCapabilitiesFor, gatedTiersFor, +} from '../../lib/adapters/grants.mjs'; +import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; +import { + findEntry, loadAndHash, stripControl, hookCommandsFor, +} from './host-adapters.mjs'; + +// ── grant (P5 promotion command; alias `bless`) ───────────────────────── +// The maintainer's explicit act that turns already-recorded conformance +// evidence into a live capability (ADR-0031 §1). This blesses an EXTERNAL +// adapter's grant (§3's "Blessed external adapter" — hence the `bless` +// alias), not a built-in promotion — promoting to a built-in is a manual +// registry PR (§3), not this command. + +/** TIER_GRANTS' values are the only capabilities `ak` can ever grant — + * critically this excludes 'aqeProvider', an upstream-owned enumeration + * (ADR-0031 §4) that must never appear grantable here. */ +function grantableCapability(capability) { + return Object.values(TIER_GRANTS).includes(capability); +} + +function gatingTierFor(capability) { + return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; +} + +/** F-2 (security re-review, HIGH): what a grant of `capability` ACTUALLY + * does today — precisely bounded, not overclaimed and not underclaimed. The + * D2 keystone (admission.mjs) makes grantedCapabilitiesFor overlay into + * effectiveHostRegistry() on every AK_EXPERIMENTAL_HOST_ADAPTERS=1 + * invocation, so a granted canBePrimary is genuinely live from the next + * invocation: hostTierLabel() shows 'drives sessions · can lead' and the + * host joins effectivePrimaryHostIds(). What is still deferred: no + * production path SELECTS an external host as primary today (`ak host pick + * --primary-host` only accepts claude|codex — built-in-scoped), so a + * granted canBePrimary is visible/eligible but not yet auto-consumed. + * commandStatusline reaches the same overlay but has NO runtime reader + * anywhere in src/ (grep-verified) — its statusline render path is a later + * wave, so it is currently inert. F-5: this is also the one-sentence + * distinction between the two grantable caps the disclosure owes the + * maintainer, so both call sites (pre-confirm disclosure, post-grant + * success) share this single source of truth rather than drifting. */ +function capabilityStatusNote(capability) { + return capability === 'canBePrimary' + ? "canBePrimary is live: from the next ak invocation this host's tier label shows 'can lead' and it joins effectivePrimaryHostIds() — but no production path yet SELECTS an external host as primary ('ak host pick' stays built-in-scoped), so this is visible/eligible, not yet auto-consumed." + : 'commandStatusline is currently inert: it reaches the effective host registry, but no runtime path reads it anywhere yet — the statusline render path is a later wave.'; +} + +export async function grant({ + name, capability, cfg, consent, reader, ask, isTTY, yes, grantsFile, +}) { + if (typeof name !== 'string' || !name || typeof capability !== 'string' || !capability) { + fail('usage: ak host adapters grant '); + return 2; + } + const safeCapability = stripControl(capability); + if (!grantableCapability(capability)) { + fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}. 'aqeProvider' in particular is an upstream-owned identity (agentic-qe's own provider enumeration) and is never ak-grantable — see ADR-0031 §4.`); + return 1; + } + const safeName = stripControl(name); + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { manifest, hash } = loaded; + const tier = gatingTierFor(capability); + const safeTier = stripControl(tier); + + // F-3 (security review): this is the highest-privilege act in the model — + // a hex hash alone told the operator nothing about what they were actually + // converting to a live capability. Disclose the ACTUAL evidence backing + // the gating tier, the manifest's declared hooks, and the manifest's trust + // state, all BEFORE the confirmation prompt. + const record = grantsFor(name, { file: grantsFile, currentHash: hash }); + const tierEntry = record?.tiers?.[tier]; + const evidence = tierEntry?.status === 'passed' ? tierEntry.evidence : undefined; + + console.log(bold(`grant '${safeCapability}' to '${safeName}'`)); + console.log(` gating tier: ${safeTier}`); + console.log(` manifest hash: ${hash}`); + console.log(` tier evidence: ${evidence ? stripControl(evidence) : '(no passed-tier evidence recorded at this hash — the grant below will be refused)'}`); + const hooks = hookCommandsFor(manifest); + console.log(` manifest hooks:${hooks.length ? '' : ' (none)'}`); + for (const line of hooks) console.log(` ${stripControl(line)}`); + // N-1 (security re-review): derive trust state from the hash this call + // ALREADY resolved above — never re-resolve the manifest source (stateFor + // would call loadAndHash a second time). On a mutable remote source + // (https:/npm:) a second resolve can return different bytes than the + // first, which would (a) disclose a trust state describing content that + // isn't what's actually being granted, and (b) double the fetch cost (an + // npm: source runs `npm pack` twice). Same three-state logic as stateFor, + // just computed from data already in hand. + let trustState; + try { + const recordedHash = consent.recordedHashFor(name); + if (recordedHash === null || recordedHash === undefined) trustState = 'not consented'; + else trustState = recordedHash === hash ? 'trusted' : 'consent-stale'; + } catch (error) { + trustState = `manifest error (consent-error: ${error?.message ?? String(error)})`; + } + console.log(` manifest trust state: ${trustState}`); + info('this grant pins the MANIFEST content (its hash), not the hook script bytes it references — see ADR-0031 §2 for that boundary.'); + info("granting a capability is a trust act, same posture as 'trust': it takes effect in the effective host registry from the next ak invocation."); + info(capabilityStatusNote(capability)); + + if (!yes) { + if (!isTTY) { + fail('grant needs confirmation — re-run with --yes after reviewing the summary above (non-interactive session)'); + return 2; + } + const confirmed = await ask(`Grant '${safeCapability}' to '${safeName}' at this manifest content? [y/N] `); + if (!confirmed) { info(`grant for '${safeName}' left unchanged`); return 0; } + } + + try { + grantCapability(name, capability, { hash }, { file: grantsFile }); + } catch (error) { + fail(`grant refused: ${stripControl(error?.message ?? String(error))} — earn it first with \`ak host adapters conformance ${safeName}\` (needs a passed '${safeTier}' tier at this exact manifest hash)`); + return 1; + } + + ok(`granted '${safeCapability}' to '${safeName}' at ${hash}`); + info('an edit to the manifest voids this grant until the tier is re-earned and re-granted'); + // F-2 (security re-review, HIGH — corrects the prior F-7 wording, which + // was accurate when written but was made FALSE by the D2 keystone + // (admission.mjs) that landed since: grantedCapabilitiesFor is now read on + // every flagged ak invocation and overlaid into effectiveHostRegistry(), + // so a granted capability is NOT inert in general — see + // capabilityStatusNote's header comment for exactly what is and isn't + // live yet, per capability. + info("this grant takes effect from the next ak invocation (with AK_EXPERIMENTAL_HOST_ADAPTERS=1 set): it is reflected in the effective host registry and this host's tier label."); + info(capabilityStatusNote(capability)); + return 0; +} + +// ── gate (P7): record an upstream capability request ──────────────────── + +export async function gate({ + name, tier, ref, cfg, reader, grantsFile, +}) { + if (typeof name !== 'string' || !name || typeof tier !== 'string' || !tier || typeof ref !== 'string' || !ref) { + fail('usage: ak host adapters gate '); + return 2; + } + const safeName = stripControl(name); + const safeTier = stripControl(tier); + if (!CONFORMANCE_TIERS.includes(tier)) { + fail(`'${safeTier}' is not a valid conformance tier (one of ${CONFORMANCE_TIERS.join(', ')})`); + return 1; + } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${safeName}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${safeName}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + + try { + recordTierGate(name, tier, { hash: loaded.hash, gatedBy: ref }, { file: grantsFile }); + } catch (error) { + fail(`gate refused: ${stripControl(error?.message ?? String(error))}`); + return 1; + } + + ok(`recorded '${safeName}' tier '${safeTier}' as gated on ${stripControl(ref)} at ${loaded.hash}`); + info(`\`ak host adapters status ${safeName}\` will show this as waiting-on-upstream until the tracked issue ships and the gate is cleared`); + return 0; +} + +// ── status (P7 display) ─────────────────────────────────────────────── +// A read-only report over grants.mjs's reporting surfaces — never the +// hash-unaware capability reader (see grants.mjs's module-header invariant). +// Staleness (a manifest edit since evidence was recorded) must be obvious, +// not buried: it voids every passed tier and every grant until re-earned. + +async function statusOne(name, entry, { reader, grantsFile }) { + const safeName = stripControl(name); + console.log(bold(`host adapter status — ${safeName}`)); + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(` manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { hash } = loaded; + console.log(` manifest hash: ${hash}`); + + const record = grantsFor(name, { file: grantsFile, currentHash: hash }); + const passed = record ? Object.entries(record.tiers).filter(([, t]) => t?.status === 'passed') : []; + if (!passed.length) { + info(' passed tiers: (none)'); + } else { + console.log(' passed tiers:'); + for (const [tier] of passed) { + const line = ` ${stripControl(tier)}${record.stale ? ' — STALE (manifest changed since this evidence was recorded; void until re-earned)' : ''}`; + if (record.stale) warn(line); else ok(line); + } + } + + const gated = gatedTiersFor(name, { file: grantsFile, currentHash: hash }); + if (!gated.length) { + info(' gated tiers: (none)'); + } else { + console.log(' gated tiers (waiting on upstream):'); + for (const { tier, gatedBy } of gated) info(` ${stripControl(tier)} -> ${stripControl(gatedBy)}`); + } + + const granted = Object.keys(grantedCapabilitiesFor(name, hash, { file: grantsFile })); + console.log(` granted capabilities: ${granted.length ? granted.map(stripControl).join(', ') : '(none)'}`); + return 0; +} + +export async function status({ + name, cfg, reader, grantsFile, +}) { + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + if (typeof name === 'string' && name) { + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${stripControl(name)}' in kit.json hostAdapters`); return 1; } + return statusOne(name, entry, { reader, grantsFile }); + } + if (!entries.length) { info('no host adapters configured in kit.json'); return 0; } + for (const entry of entries) { + await statusOne(entry?.name ?? '(unnamed)', entry, { reader, grantsFile }); + console.log(''); + } + return 0; +} + +// ── revoke-grant: withdraw conformance evidence + grants ──────────────── +// Distinct from `revoke`, which only touches the adapter-consent store +// (whether admission accepts the manifest at all). This touches +// adapter-grants.json (tier evidence, upstream gates, granted capabilities) +// — an operator revoking a manifest's TRUST does not automatically revoke +// capabilities EARNED separately, and vice versa; they are separate stores +// for separate questions. Fail-safe like `revoke`: reachable with the +// experimental flag off, so a disabled surface can still be voided. +// +// F-9 (deferred nit, now taken): `revoke-grant ` alone stays +// all-or-nothing (revokeGrants — the whole record, forcing a full +// conformance re-run to recover every OTHER capability too). An optional +// `[capability]` third positional narrows this to just that one capability +// (revokeCapability), leaving every other tier result and granted capability +// untouched. +// +// F-6 (security-review follow-up): the two forms differ in what they leave +// behind, not just in scope. `revoke-grant ` is UN-GRANT, +// not un-earn — it drops the capability but leaves the gating tier 'passed', +// so a later re-grant needs no new conformance evidence. `revoke-grant +// ` (no capability) also wipes the tier evidence itself, forcing a +// fresh conformance run before anything can be re-granted. An operator +// revoking because they no longer TRUST the recorded evidence (not just +// withdrawing the grant) wants the whole-record form. + +export function revokeGrant({ name, capability, grantsFile }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant [capability]'); return 2; } + const safeName = stripControl(name); + + if (typeof capability === 'string' && capability) { + const safeCapability = stripControl(capability); + if (!grantableCapability(capability)) { + fail(`'${safeCapability}' is not a grantable capability — ak can only grant ${Object.values(TIER_GRANTS).join(', ')}.`); + return 1; + } + const existed = revokeCapability(name, capability, { file: grantsFile }); + if (existed) { ok(`revoked capability '${safeCapability}' for '${safeName}' — other tiers and capabilities are untouched`); return 0; } + info(`no recorded '${safeCapability}' grant for '${safeName}'`); + return 0; + } + + const existed = revokeGrants(name, { file: grantsFile }); + if (existed) { ok(`revoked all conformance evidence and grants for '${safeName}'`); return 0; } + info(`no recorded grants for '${safeName}'`); + return 0; +} diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs new file mode 100644 index 0000000..3a25109 --- /dev/null +++ b/src/commands/x/host-adapters.mjs @@ -0,0 +1,451 @@ +// x host adapters — the trust CLI for external host-adapter manifests (ADR-0031 +// P1) plus the tiered conformance runner (P4). The maintainer's grant/gate CLI +// (P5) and the upstream-gate display (P7) live in the sibling +// host-adapters-grants.mjs (split out to stay under the house 500-line-per-file +// budget) and are re-exported into this file's `run()` dispatch below — from +// the operator's point of view it is all one `ak host adapters` surface. +// Nothing in admission.mjs ever writes consent; this closes that gap by +// recording hash-pinned consent the same way Codex pins an MCP server's +// content (ADR-0029 §6). No adapter code ever executes here — this module +// only reads, validates, hashes, discloses, and (on confirmation) records. +// +// Mirrors admitOne's refusal semantics exactly: a manifest is hashed only +// after validateAdapterManifest accepts it (hashManifest(validateAdapterManifest(raw))), +// so the hash a user consents to is always the VALIDATED shape, never the +// raw file — an invalid manifest (e.g. one claiming canBePrimary) is refused +// with its .reason and nothing is ever recorded for it. +import readline from 'node:readline/promises'; +import { hashManifest, SUPPORTED_CONTRACT } from '../../lib/adapters/admission.mjs'; +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'; +import { + grant as grantCap, gate as gateTier, status as statusReport, revokeGrant, +} from './host-adapters-grants.mjs'; + +const FLAG_ENV_VAR = 'AK_EXPERIMENTAL_HOST_ADAPTERS'; + +const flagEnabled = (env) => env?.[FLAG_ENV_VAR] === '1'; + +/** Lazy dynamic import so this file loads even before sources.mjs lands (a + * sibling work item this same wave) and so tests never pay for it unless + * they choose to — same pattern admission.mjs uses for consent.mjs. */ +async function defaultReader(source) { + const { resolveManifestSource } = await import('../../lib/adapters/sources.mjs'); + return resolveManifestSource(source); +} + +async function defaultAsk(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = (await rl.question(question)).trim().toLowerCase(); + rl.close(); + return answer === 'y' || answer === 'yes'; +} + +/** Untrusted text (manifest trust.changes fields, reader/validator error + * detail) is printed verbatim ahead of a consent prompt — a crafted string + * carrying cursor-movement/erase ANSI escapes or extra newlines could + * visually rewrite the disclosure the operator is about to agree to. A + * codepoint loop, not a control-character regex class (that trips + * `no-control-regex` AND is what sources.mjs's sanitizeDetail already uses, + * so this stays lint-clean the same way). Strips C0 (0x00-0x1F), DEL + * (0x7F), and C1 (0x80-0x9F, e.g. U+009B CSI — a real terminal control that + * JSON.stringify does NOT escape, unlike C0) outright; \n/\t/\r collapse to + * a single space instead of vanishing, so a multi-line/tabbed string stays + * readable as one line rather than silently losing a word boundary. */ +export function stripControl(value) { + const input = String(value ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out; +} + +export function findEntry(cfg, name) { + return (Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []).find((e) => e?.name === name) ?? null; +} + +/** Read + validate + hash one adapter entry. Never throws — reports + * {ok:false, reason, detail} on any failure, the same per-entry isolation + * posture admission.mjs's admitOne holds. `reader` may return either the + * sources.mjs `{raw, origin}` shape or a bare raw document (tests are free + * to stub either). */ +export async function loadAndHash(entry, { reader }) { + let raw; + // Fail-closed, not fail-open: a bare-raw reader result (no {raw,origin} + // wrapper — including one whose `origin` is missing/malformed) is + // 'unknown', never assumed to be 'file'. Defaulting to 'file' would let + // --yes silently skip the --expect-hash pin (finding 8) for a source that + // was never actually proven local; only an explicit origin:'file' counts. + let origin = 'unknown'; + try { + const resolved = await reader(entry.source); + if (resolved && typeof resolved === 'object' && 'raw' in resolved) { + raw = resolved.raw; + origin = typeof resolved.origin === 'string' && resolved.origin ? resolved.origin : 'unknown'; + } else { + raw = resolved; + } + } catch (error) { + return { ok: false, reason: error?.reason ?? 'manifest-unreadable', detail: error?.message ?? String(error) }; + } + + // Same distinct failure admitOne draws: cfg's pinned contract disagreeing + // with the manifest's self-declared one means the file changed underneath + // the operator, not merely "unsupported version". + if (entry.contract !== undefined && raw?.contract !== undefined && entry.contract !== raw.contract) { + return { + ok: false, reason: 'contract-mismatch', + detail: `cfg declares contract ${entry.contract}, manifest declares ${raw.contract}`, + }; + } + + let manifest; + try { + manifest = validateAdapterManifest(raw); + } catch (error) { + return { ok: false, reason: error?.reason ?? 'manifest-invalid', detail: error?.message ?? String(error) }; + } + + if (manifest.contract !== SUPPORTED_CONTRACT) { + return { ok: false, reason: 'contract-version', detail: `unsupported contract ${manifest.contract}` }; + } + if (manifest.host.id !== entry.name) { + return { + ok: false, reason: 'name-mismatch', + detail: `cfg entry '${entry.name}' does not match manifest host id '${manifest.host.id}'`, + }; + } + // Same check admitOne applies before ever computing a hash: a manifest + // whose host id collides with a built-in can never actually be admitted, + // so trusting it would record a standing consent for content admission + // will always refuse — misleading UX for nothing gained. + if (HOST_REGISTRY.some((host) => host.id === manifest.host.id)) { + return { ok: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` }; + } + + return { ok: true, manifest, hash: hashManifest(manifest), origin }; +} + +/** Trust state for one entry — never throws. One of 'trusted', 'consent-stale', + * 'not consented', or 'manifest error ()'. */ +export async function stateFor(entry, { consent, reader }) { + if (typeof entry?.name !== 'string' || !entry.name) return 'manifest error (invalid-entry: missing name)'; + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) return `manifest error (${stripControl(loaded.reason)})`; + let recorded; + try { + recorded = consent.recordedHashFor(entry.name); + } catch (error) { + return `manifest error (consent-error: ${error?.message ?? String(error)})`; + } + if (recorded === null || recorded === undefined) return 'not consented'; + return recorded === loaded.hash ? 'trusted' : 'consent-stale'; +} + +async function list({ cfg, consent, reader }) { + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + if (!entries.length) { info('no host adapters configured in kit.json'); return 0; } + console.log(bold('host adapters') + dim(' (trust state)')); + for (const entry of entries) { + const name = entry?.name ?? '(unnamed)'; + console.log(` ${String(name).padEnd(16)} ${dim(entry?.source ?? '')}`); + console.log(` ${await stateFor(entry, { consent, reader })}`); + } + return 0; +} + +function discloseManifest(name, manifest, hash) { + console.log(bold(`host adapter manifest — ${name}`)); + // Full content FIRST, decision-critical summary LAST (finding 16): a + // large-but-legal manifest can run many screens of JSON, and whatever + // prints immediately before the [y/N] prompt is what the operator's eyes + // are actually on. Burying the summary above the JSON block meant it + // scrolled off-screen entirely on a big manifest, well before the prompt. + // + // Consent is granted over the WHOLE validated manifest (ADR-0029 §6), + // including fields the curated summary below doesn't call out by name + // (host.legacy.*, host.trust.approvalPolicy, detection, driving.surfaces, + // ...) — printing it in full means nothing hashed is ever hidden from the + // thing being consented to. + console.log(bold('full manifest (exactly the content being hashed):')); + // JSON.stringify escapes every C0 control character (0x00-0x1F) WITHIN A + // STRING VALUE by construction, but leaves C1 (0x80-0x9F, e.g. U+009B + // CSI) untouched — those can only reach this text as a raw byte that + // leaked from an untrusted manifest field, never from the pretty-printer's + // own structural indentation (spaces/real newlines are the ONLY raw + // control-range bytes stringify itself emits). Splitting on that + // structural newline before sanitizing each line and rejoining after + // means stripControl only ever sees within-line content — it strips a + // leaked C1 byte (and is a no-op on \n/\t/\r, since none survive stringify + // raw inside a line) without collapsing the block's own line breaks. + console.log(JSON.stringify(manifest, null, 2).split('\n').map(stripControl).join('\n')); + console.log(''); + console.log(` version: ${manifest.version}`); + console.log(` contract: ${manifest.contract}`); + console.log(` host id: ${manifest.host.id}`); + const trueCaps = Object.entries(manifest.host.capabilities ?? {}) + .filter(([, v]) => v === true).map(([k]) => k); + console.log(` capabilities: ${trueCaps.length ? trueCaps.join(', ') : '(none)'}`); + const lifecycle = manifest.lifecycle ?? {}; + const verbs = Object.keys(lifecycle); + console.log(` lifecycle hooks:${verbs.length ? '' : ' (none)'}`); + for (const verb of verbs) { + const { hook } = lifecycle[verb]; + const timeout = hook.timeoutMs !== undefined ? ` (timeout ${hook.timeoutMs}ms)` : ''; + console.log(` ${verb}: ${JSON.stringify(hook.command)}${timeout}`); + } + const changes = manifest.trust?.changes ?? []; + console.log(` trust changes:${changes.length ? '' : ' (none)'}`); + for (const change of changes) { + console.log(` [${change.scope}] ${stripControl(change.owner)}: ${stripControl(change.value)} — ${stripControl(change.effect)}`); + } + console.log(` sha256: ${hash}`); +} + +async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters trust '); return 2; } + const entry = findEntry(cfg, name); + if (!entry) { fail(`no host adapter named '${name}' in kit.json hostAdapters`); return 1; } + + const loaded = await loadAndHash(entry, { reader }); + if (!loaded.ok) { + fail(`'${name}' manifest refused: ${stripControl(loaded.reason)} — ${stripControl(loaded.detail)}`); + return 1; + } + const { manifest, hash, origin } = loaded; + + // --expect-hash pinning (finding 8): required whenever --yes is paired + // with a non-file origin, so an unattended (CI) run can never blanket- + // consent to "whatever content the remote currently serves" — that + // defeats hash pinning exactly where it matters. Checked before any + // consent-store lookup or disclosure. + if (yes && origin !== 'file' && !expectHash) { + fail(`'${name}' resolved from a non-file origin ('${stripControl(origin)}') — --yes needs --expect-hash so an unattended run pins exact content instead of trusting whatever the remote serves right now (drop --yes to review interactively, or pass the hash from a prior interactive trust)`); + return 2; + } + if (expectHash !== undefined && expectHash !== hash) { + fail(`'${name}' hash mismatch — --expect-hash ${expectHash} does not match the resolved manifest hash ${hash}; refusing to record consent for unexpected content`); + return 1; + } + + let recorded; + try { + recorded = consent.recordedHashFor(name); + } catch (error) { + fail(`consent store error: ${error?.message ?? String(error)}`); + return 1; + } + if (recorded === hash) { + ok(`'${name}' is already trusted at this exact content (${hash}) — nothing to do`); + return 0; + } + if (recorded !== null && recorded !== undefined) { + warn(`'${name}' consent is stale — previously trusted hash ${recorded}, current manifest hash ${hash}`); + } + + discloseManifest(name, manifest, hash); + + if (!yes) { + if (!isTTY) { + fail('trust needs confirmation — re-run with --yes after reviewing the manifest above (non-interactive session)'); + return 2; + } + const confirmed = await ask('Trust this manifest content exactly as disclosed above? [y/N] '); + if (!confirmed) { info(`consent for '${name}' left unchanged`); return 0; } + } + + consent.recordConsent(name, hash); + ok(`consent recorded for '${name}' at ${hash}`); + info('admission will now accept this exact manifest content — ANY edit to the manifest invalidates this consent; re-run `ak host adapters trust` after an edit'); + return 0; +} + +function revoke({ name, consent }) { + if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke '); return 2; } + const existed = consent.revokeConsent(name); + if (existed) { ok(`revoked consent for '${name}'`); return 0; } + info(`no recorded consent for '${name}'`); + 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))}`; +} + +export 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, 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]; + + // 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 or grant record, or it silently + // reactivates the next time the flag is turned back on. `list`/`trust`/ + // `conformance`/`grant`/`gate`/`status` stay gated — they're the surface + // that reads/records new trust, evidence, or capability. + if (sub === 'revoke') return revoke({ name, consent }); + if (sub === 'revoke-grant') return revokeGrant({ name, capability: positionals[2], grantsFile }); + + if (!flagEnabled(env)) { + fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); + return 2; + } + + const resolvedCfg = cfg ?? loadKitConfig(); + + if (sub === 'list') return list({ cfg: resolvedCfg, consent, reader }); + if (sub === 'trust') { + return trust({ + name, cfg: resolvedCfg, consent, reader, ask, isTTY, + yes: !!flags.yes, expectHash: flags['expect-hash'], + }); + } + if (sub === 'conformance') { + return conformance({ + name, cfg: resolvedCfg, reader, runTiered: runTieredConformance, consentFile, grantsFile, + }); + } + // F-8 (security review, ADR-0031-accurate naming): `bless` is the alias — + // §3 calls this exact out-of-tree grant path a "Blessed external adapter"; + // "Promoted built-in" is the separate manual registry PR this command does + // NOT do. `grant` stays the primary spelling; `promote` was dropped. + if (sub === 'grant' || sub === 'bless') { + return grantCap({ + name, capability: positionals[2], cfg: resolvedCfg, consent, reader, ask, isTTY, + yes: !!flags.yes, grantsFile, + }); + } + if (sub === 'gate') { + return gateTier({ + name, tier: positionals[2], ref: positionals[3], cfg: resolvedCfg, reader, grantsFile, + }); + } + if (sub === 'status') return statusReport({ name, cfg: resolvedCfg, reader, grantsFile }); + + fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`); + return 2; +} diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 5e48455..bcbc8e4 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -50,6 +50,7 @@ export const options = { provider: { type: 'string' }, // csv of ruflo API providers, optional id:model (openai:gpt-5.6) route: { type: 'string', multiple: true }, // repeatable: 'activity:host[:model]' per-activity routing override activity: { type: 'string' }, // refresh: csv of activities to re-seed (default = prompt) + 'expect-hash': { type: 'string' }, // adapters trust: required sha256 pin when --yes resolves a non-file source yes: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, }; @@ -81,6 +82,12 @@ Subcommands: (per-activity, opt-in; user pins are never touched, and \`ak sync\` never does this for you) off reversible teardown (reset to claude-only; strip managed env keys) + adapters record hash-pinned consent for external host-adapter manifests + (experimental — set AK_EXPERIMENTAL_HOST_ADAPTERS=1; revoke + always works, list/trust need the flag) + list show each configured adapter's trust state (default) + trust [--expect-hash ] grant consent (required + with --yes against a non-file source); revoke Options (pick, all optional — omit for interactive): --host the complete desired enabled-host set, e.g. @@ -148,8 +155,12 @@ export async function run({ flags, positionals, pkgRoot }) { if (sub === 'off') return off({ cwd, pkgRoot }); if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); if (sub === 'refresh') return refresh({ flags, cwd }); + if (sub === 'adapters') { + const { run: runHostAdapters } = await import('./host-adapters.mjs'); + return runHostAdapters({ flags, positionals: positionals.slice(1) }); + } - fail(`unknown host subcommand: ${sub} (status|pick|refresh|off)`); + fail(`unknown host subcommand: ${sub} (status|pick|refresh|off|adapters)`); return 2; } diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs index a35d812..84bbe7a 100644 --- a/src/lib/adapters/admission.mjs +++ b/src/lib/adapters/admission.mjs @@ -7,11 +7,36 @@ // entry or the built-in registries (try/caught per entry, in admitOne AND as // a belt-and-suspenders net in admitAdapters). import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; import { HOST_REGISTRY } from './registries.mjs'; import { validateAdapterManifest } from './manifest.mjs'; export const SUPPORTED_CONTRACT = 1; +/** The adapter's own directory (F-1, ADR-0031): where its execution/lifecycle + * hooks resolve a relative command FROM, never the operator's process.cwd() + * when `ak run` was invoked. A file-sourced manifest anchors to its own + * directory — `fs.realpathSync` so a symlinked manifest can't relocate that + * pin out from under consent. An npm/https source has no persistent local + * bundle (resolved, hashed, and discarded per admission pass — sources.mjs) + * so there is nothing to anchor to: `null`. buildAdmittedExecutionAdapter + * (execution/admitted.mjs) then refuses a relative hook command outright + * for a `null` baseDir rather than guessing a cwd. An unreadable/vanished + * file source also resolves to `null` — the same honest refusal, not a + * silent fallback to process.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; + } +} + /** Deterministic, key-sorted JSON — same stable-stringify shape used * elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests * that differ only in key order or incidental whitespace hash identically. @@ -128,9 +153,18 @@ export async function admitAdapters({ cfg, readManifest, consent }) { } async function defaultReadManifest(source) { - const fs = await import('node:fs/promises'); - const raw = await fs.readFile(source, 'utf8'); - return JSON.parse(raw); + // Lazy dynamic import — keeps bootstrapHostAdapters's flag-off zero-cost + // property intact (no import happens unless a readManifest call actually + // runs, which only happens during admission with the flag set and + // adapters configured). Ordering is mandated by security review: resolve + // -> validate -> hash -> consent. The resolver (sources.mjs) runs here, + // BEFORE admitOne ever hashes the manifest, so consent pins the RESOLVED + // bytes — a mutable remote source (an npm dist-tag moving, a URL's + // content changing) invalidates consent automatically ('consent-stale') + // on the next admission pass, rather than being silently re-trusted. + const { resolveManifestSource } = await import('./sources.mjs'); + const { raw } = await resolveManifestSource(source); + return raw; } /** @@ -183,7 +217,134 @@ export async function bootstrapHostAdapters({ if (admitted.length) { const { applyAdmitted } = await import('./admitted.mjs'); - applyAdmitted(admitted); + + // Keystone (ADR-0031 §1): build the per-host granted-capability lookup + // BEFORE applying the overlay, so an earned canBePrimary/commandStatusline + // is live in effectiveHostRegistry() from process start. Guarded and + // non-fatal, lazy import — the same posture as the execution/lifecycle + // registration blocks below, and a NEW sibling concern to them (it does + // not touch either). One host's grant lookup failing must never block + // another host's, or the admission result itself. + // + // CRITICAL: the hash passed to grantedCapabilitiesFor is computed FRESH + // here, via hashManifest(result.manifest) — never read from a cache or + // carried over from admitOne's own internal hash — because a stale hash + // would silently defeat grants.mjs's edit-invalidation pin (a manifest + // edited since the grant must re-hash to a different value and come back + // {}, dropping the capability). grantedCapabilitiesFor is the ONLY + // sanctioned reader for this (see grants.mjs's module-header invariant); + // reading grantsFor(name).capabilities directly would return the + // unfiltered set and is exactly the bug that invariant exists to prevent. + let grantsByName; + try { + const { grantedCapabilitiesFor } = await import('./grants.mjs'); + // Object.create(null), not {} (F-6): admitted host ids come from + // consented adapter names, which are attacker-influenceable in + // principle — a plain object literal's prototype chain would make + // 'constructor' a live (if inert) key collision. No inherited + // properties at all closes that off entirely; admitted.mjs's own + // Object.hasOwn guard on the read side is the belt to this suspenders. + grantsByName = Object.create(null); + for (const result of admitted) { + try { + grantsByName[result.name] = grantedCapabilitiesFor(result.name, hashManifest(result.manifest)); + } catch (error) { + warnings.push({ name: result.name, reason: 'grant-lookup-failed', detail: error?.message ?? String(error) }); + } + } + } catch { + // grants.mjs unavailable — proceed ungranted (manifest-floor + // capabilities only), exactly like the flag-off path. Never fatal. + grantsByName = undefined; + } + + applyAdmitted(admitted, { grantsByName }); + + // 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 + // guarded, non-fatal posture as the rest of bootstrap: one adapter's + // registration failure never blocks the others or the admission result. + const executionCandidates = admitted.filter((result) => ( + result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true + )); + if (executionCandidates.length) { + try { + const { registerAdmittedExecution } = await import('../execution/admitted.mjs'); + for (const result of executionCandidates) { + // F-5 (ADR-0029 §2): a manifest that never declared the + // cli-subprocess driving surface gets no cli-subprocess execution + // adapter — refused with its own reason, before even attempting + // registration (buildAdmittedExecutionAdapter re-checks this too, + // defence-in-depth for any caller that bypasses this filter). + if (!result.manifest?.driving?.surfaces?.includes('cli-subprocess')) { + warnings.push({ + name: result.name, reason: 'surface-unsupported', + detail: `'${result.name}' declares an execution block but not driving.surfaces including 'cli-subprocess'`, + }); + continue; + } + try { + registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) }); + } catch (error) { + warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) }); + } + } + } catch (error) { + for (const result of executionCandidates) { + warnings.push({ name: result.name, reason: 'execution-registration-failed', detail: error?.message ?? String(error) }); + } + } + } + + // 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/admitted.mjs b/src/lib/adapters/admitted.mjs index 21838fe..cad384b 100644 --- a/src/lib/adapters/admitted.mjs +++ b/src/lib/adapters/admitted.mjs @@ -30,6 +30,54 @@ function conformOne(item) { return validateHostAdapter(item?.entry ?? item); } +// The ONLY two capabilities a grant may ever flip true (ADR-0031 §1: the +// permanent self-declaration ban — canBePrimary/commandStatusline can never +// come from the manifest itself, see manifest.mjs's cap-can-be-primary / +// cap-command-statusline refusals — so the sole other path to `true` is an +// explicit maintainer grant, applied here). Kept local to this module (not +// imported from grants.mjs's TIER_GRANTS) so this file's own guarantee does +// not depend on grants.mjs staying correct — belt-and-suspenders, per the +// keystone security review. +const GRANTABLE_CAPABILITIES = Object.freeze(['canBePrimary', 'commandStatusline']); + +/** + * Overlay ONE validated, admitted host entry with its earned capability + * grants. `granted` is the caller-supplied {capability: true, ...} map for + * THIS host — bootstrapHostAdapters (admission.mjs) is the sole production + * caller, and it sources `granted` exclusively from grants.mjs's + * grantedCapabilitiesFor() (hash-pinned to the CURRENT manifest, already + * intersected with TIER_GRANTS at read time — see that module's header + * invariant). This function does not simply trust that upstream filtering: + * it re-intersects with its own local GRANTABLE_CAPABILITIES allow-list + * below, so even a caller that got it wrong (or a future misuse of + * applyAdmitted) can never smuggle a capability other than + * canBePrimary/commandStatusline into the overlay — e.g. aqeProvider, or + * flipping canRouteActivities, can NEVER reach an entry this way, no matter + * what `granted` contains. + * + * Grants may only flip a capped capability TRUE, never back to false: the + * validated manifest capabilities are the floor. With no grant (or a grant + * that changes nothing — every relevant flag already true, or absent), the + * SAME `entry` reference is returned unchanged, so the byte-identity + * guarantee (no grants ⇒ effectiveHostRegistry() entries equal the validated + * manifest entries) holds without a caller having to special-case it. + * @param {any} entry — an already-validated, already-frozen host entry + * @param {Record|undefined} granted + */ +function withGrantedCapabilities(entry, granted) { + if (!granted) return entry; + let changed = false; + const capabilities = { ...entry.capabilities }; + for (const capability of GRANTABLE_CAPABILITIES) { + if (granted[capability] === true && capabilities[capability] !== true) { + capabilities[capability] = true; + changed = true; + } + } + if (!changed) return entry; + return Object.freeze({ ...entry, capabilities: Object.freeze(capabilities) }); +} + /** * Set the admitted overlay. Accepts either raw host entries or admission * results ({name, admitted, entry, manifest}) — whichever admitAdapters @@ -37,10 +85,28 @@ function conformOne(item) { * a non-conforming entry), and stores the result. A second call replaces the * overlay outright (useful for tests via resetAdmitted()); production calls * this at most once per process since the env flag gates it. + * + * `grantsByName` (optional) is a `{ [hostId]: { canBePrimary?: true, + * commandStatusline?: true } }` lookup — the keystone that makes an earned + * capability grant LIVE (ADR-0031 §1). Omitted (or a host absent from it), + * the stored entry is exactly the validated manifest entry — the flag-off / + * nothing-granted byte-identity this module has always guaranteed. * @param {ReadonlyArray} entries + * @param {{ grantsByName?: Record> }} [options] */ -export function applyAdmitted(entries) { - admittedEntries = Object.freeze((entries ?? []).map(conformOne)); +export function applyAdmitted(entries, { grantsByName } = {}) { + admittedEntries = Object.freeze((entries ?? []).map((item) => { + const entry = conformOne(item); + // Object.hasOwn, never bare `grantsByName[entry.id]`/`in` (F-6): + // entry.id is a consented-but-still-adapter-supplied string, so an id + // like 'constructor' must never resolve via the prototype chain to + // Object.prototype.constructor and be misread as a truthy grant record. + // Belt-and-suspenders alongside admission.mjs building grantsByName as + // Object.create(null) — this holds even for a caller (a test, a future + // refactor) that passes a plain object literal instead. + const granted = grantsByName && Object.hasOwn(grantsByName, entry.id) ? grantsByName[entry.id] : undefined; + return withGrantedCapabilities(entry, granted); + })); applied = true; return admittedEntries; } @@ -62,3 +128,46 @@ export function effectiveHostRegistry() { if (!applied || admittedEntries.length === 0) return HOST_REGISTRY; return Object.freeze([...HOST_REGISTRY, ...admittedEntries]); } + +/** Built-ins ∪ admitted hosts whose manifest declares + * capabilities.canRouteActivities — the LAZY set every routing VALIDATION + * path (routing.mjs's isRoutableHost, validateRoute, materializeRunPlan) + * must consult (P2, ADR-0031). routing.mjs's `HOSTS` constant stays frozen + * at import time and built-ins-only — it is display strings only now, never + * a validation source. Fresh on every call, like admittedHostIds() above. */ +export function effectiveRoutableHostIds() { + return effectiveHostRegistry() + .filter((host) => host.capabilities.canRouteActivities === true) + .map((host) => host.id); +} + +/** Built-ins ∪ admitted hosts whose EFFECTIVE capabilities (validated + * manifest floor + any live grant overlay from applyAdmitted) include + * canBePrimary — the primary-eligibility sibling to effectiveRoutableHostIds + * above (ADR-0031 §1 keystone: this is what makes a granted canBePrimary + * actually count somewhere). hosts.mjs's drivingHost() consults this for its + * eligibility check — the primitive is live, though no production path + * currently drives kit.json's routing.primaryHost to an admitted external + * id (the picker that writes it stays built-in-only), so today this has no + * live privileged caller. + * + * Deliberately NOT wired into routing.mjs's PRIMARY_HOSTS constant, which + * stays frozen at import time and built-ins-only: PRIMARY_HOSTS backs the + * primary-host SELECTION UX (`ak setup --primary-host`, `ak host pick`), + * and extending that picker to external hosts is out of scope this wave + * (deferred, same as HOSTS staying display-only in routing.mjs) — an + * eligibility reader and a selection-menu source are different concerns + * even though both currently trace back to the same capability. + * + * Homed here (next to effectiveHostRegistry/effectiveRoutableHostIds), not + * in registries.mjs where the built-in-only primaryHostIds() lives: this + * file already imports registries.mjs for HOST_REGISTRY, so an + * effective-registry-aware selector living in registries.mjs would need the + * reverse import and create a real module cycle; lifecycle-registry.mjs's + * own effectiveHostRegistry import mirrors this same choice. Fresh on every + * call, like effectiveRoutableHostIds() above. */ +export function effectivePrimaryHostIds() { + return effectiveHostRegistry() + .filter((host) => host.capabilities.canBePrimary === true) + .map((host) => host.id); +} diff --git a/src/lib/adapters/conformance.mjs b/src/lib/adapters/conformance.mjs new file mode 100644 index 0000000..31d50e9 --- /dev/null +++ b/src/lib/adapters/conformance.mjs @@ -0,0 +1,652 @@ +// 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, recordTierFailure, + 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 tier: real exercise (ADR-0031 §2, ADR-0019) ─────────── +// The chicken-and-egg this tier exists to break: earning canBePrimary needs +// evidence of leading a run and receiving an escalation, but ak's real +// primary-host machinery (routing.mjs) only ever selects a host that has +// ALREADY got canBePrimary — proving it through that live path would need +// the grant this tier exists to justify. The harness sidesteps that by +// building the lead/escalation plan directly in its OWN sandbox and driving +// it through the real executeRunPlan (ADR-0018/0019) — genuine subprocess +// behaviour, not the live primary-host registry. +// +// F-1 (security review, HIGH, fixed): this exercise runs UNCONDITIONALLY — +// never gated on an existing canBePrimary grant. ADR-0031 §2's own sequence +// is evidence FIRST, grant SECOND ("passing a tier records evidence; the +// maintainer's grant turns evidence into capability"): grantCapability +// (grants.mjs) refuses unless a 'passed' primary-eligible tier is ALREADY +// recorded at this hash, so gating the exercise on the grant it is meant to +// justify is a deadlock — the only way out would be hand-seeding a 'passed' +// tier before ever running the exercise, which defeats the entire earned- +// evidence point. checkPrimaryEligible below records 'passed' straight off a +// genuine exercise result; a maintainer's subsequent `ak host adapters grant +// canBePrimary` is the ONLY thing that turns that recorded evidence +// into a live capability via the keystone overlay (admitted.mjs) — that +// separation, not a pre-exercise grant check, is what ADR-0031 §1 actually +// asks for. (The grant check this replaced existed to stop a caller-supplied +// exercise result from being self-evidence — moot now: there is no +// caller-injectable exercise at all, see below.) +// +// Two things the exercise proves, and what it does NOT claim (F-7): +// - A direct, non-escalated worker completes on the admitted host — the same +// worker-execution path activity-routing already proved, run again here as +// this tier's own independent evidence. +// - A SEPARATE worker starts on a host id this harness invents on the spot — +// never a built-in, never admitted, never registered in ANY adapter +// overlay — which the real runner genuinely cannot route: a real +// `cli_unavailable` WorkerResult, not a fabricated one (no hook trickery, +// no fixture changes needed: the failure is architectural — no adapter +// exists for that host id at all). That worker's one-rung escalation +// ladder points at the admitted host, so a real escalatable failure +// (runner.mjs's ESCALATABLE_STATUSES/BLOCKING_CATEGORIES) genuinely +// advances onto it (ADR-0019) — the `attempts` trail proves more than one +// attempt ran and the admitted host is the LAST rung, succeeded. +// Both completions are the SAME execution.run hook exiting 0 twice, driven +// through the real derived execution adapter — this demonstrates the +// escalation ladder and execution-adapter contract plumbing over an +// already-proven activity-routing path. It is NOT evidence the host can +// "lead agentic work" in any qualitative sense — the evidence string below +// is worded to that precision, not more. +// +// This needs the host's activity-routing to actually work (it runs real +// workers through the same derived execution adapter), so this tier depends +// on activity-routing genuinely passing THIS run — enforced by the caller +// (runTieredConformance) before this function is ever reached. +// +// No caller-injectable seam: unlike the placeholder this replaces, there is +// no `exercisePrimaryEligible` option anymore — the ONLY exercise that can +// ever run is this real one, so a caller cannot launder a fabricated +// {ok:true} into a pass (Wave C's off-the-CLI constraint, hardened: not just +// unreachable from the CLI, structurally unreachable from ANY caller). +const PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX = 'conformance-unrouted-rung'; + +async function runPrimaryEligibleExercise({ + manifest, name, baseDir, haveFn, clock, +}) { + const unroutedHost = `${PRIMARY_ELIGIBLE_UNROUTED_HOST_SUFFIX}-${name}`; + resetAdmittedExecution(); + try { + registerAdmittedExecution(manifest, { haveFn, baseDir }); + const plan = { + workers: [ + { + id: 'primary-eligible-direct', activity: 'implementation', role: 'coder', host: name, + prompt: 'conformance harness probe: primary-eligible direct run (non-escalated)', + }, + { + id: 'primary-eligible-escalation', activity: 'implementation', role: 'coder', host: unroutedHost, + prompt: 'conformance harness probe: primary-eligible escalation (receives an escalation)', + escalate: [{ host: name, model: null }], + }, + ], + }; + const [direct, escalation] = await executeRunPlan(plan, { clock, escalate: true }); + + if (direct?.status !== 'succeeded' || direct.host !== name) { + return { ok: false, detail: `direct (non-escalated) worker did not complete via '${name}' (status=${direct?.status})` }; + } + const attempts = escalation?.attempts ?? []; + const lastAttempt = attempts[attempts.length - 1]; + const genuinelyEscalated = attempts.length > 1 + && lastAttempt?.host === name && lastAttempt?.status === 'succeeded' + && attempts.slice(0, -1).every((a) => a.host !== name); + if (escalation?.status !== 'succeeded' || escalation.host !== name || !genuinelyEscalated) { + return { + ok: false, + detail: `escalation worker did not genuinely advance onto '${name}' via ADR-0019 (status=${escalation?.status}, attempts=${attempts.length})`, + }; + } + return { + ok: true, + detail: `'${name}' completed a direct (non-escalated) run to a succeeded result, and separately received a ` + + `genuine ADR-0019 escalation onto itself after an unrouted host's real cli_unavailable failure ` + + `(${attempts.length} attempts: ${attempts.map((a) => `${a.host}=${a.status}`).join(' -> ')}) — both runs ` + + "completed via the adapter's real execution.run hook (escalation-ladder and execution-contract plumbing " + + 'over the already-proven activity-routing path, not a claim about agentic task quality)', + }; + } catch (error) { + return { ok: false, detail: `primary-eligible exercise threw: ${error?.message ?? String(error)}` }; + } finally { + resetAdmittedExecution(); + } +} + +/** + * primary-eligible's own check (ADR-0031 §2, ADR-0019) — deliberately NOT + * checkGrantGatedTier: F-1 (security review) found that gating this + * exercise on an EXISTING canBePrimary grant deadlocks against + * grantCapability's own requirement of an already-'passed' tier. This runs + * the real exercise unconditionally (once admission and activity-routing + * have genuinely passed) and reports 'passed' straight off a genuine result + * — recordTierResult (the caller's shared persist step) writes that evidence + * regardless of whether anything is granted yet, which is precisely what + * lets a maintainer's later grantCapability succeed at all. + */ +async function checkPrimaryEligible({ + manifest, name, baseDir, haveFn, clock, +}) { + const exerciseLabel = 'leads a run and receives an escalation (ADR-0019)'; + const outcome = await runPrimaryEligibleExercise({ + manifest, name, baseDir, haveFn, clock, + }); + if (outcome.ok) { + return { status: 'passed', checks: [{ name: exerciseLabel, ok: true, detail: outcome.detail }], evidence: outcome.detail }; + } + return { status: 'failed', checks: [{ name: exerciseLabel, ok: false, detail: outcome.detail }] }; +} + +// ── statusline tier ───────────────────────────────────────────────────── +// Gates a capability the manifest can NEVER declare (commandStatusline is +// inexpressible in the schema — ADR-0029/0031). Cannot be exercised +// end-to-end today: there is 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 that path 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, which also VOIDS a live capability the tier + * used to back at this same hash. A genuinely 'failed' grant-bearing tier + * (one of TIER_GRANTS' keys) is recorded via recordTierFailure — the un-earn + * path: a re-run that fails at the SAME hash a capability was earned at + * voids that capability too, mirroring the 'gated' downgrade (N-1, + * security-review follow-up). A 'skipped' result records nothing, even for a + * grant-bearing tier — the tier was never actually evaluated this run (e.g. + * its own prerequisite didn't pass), which is ambiguous, not a disproof, so + * it must never void a live capability. 'gated' tiers with no upstream ref + * also 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. + * + * primary-eligible has no caller-injectable exercise (unlike statusline, + * still a placeholder this wave): it always runs runPrimaryEligibleExercise, + * a real escalation-plus-direct-run probe driven through executeRunPlan + * (ADR-0018/0019), UNCONDITIONALLY — never gated on an existing canBePrimary + * grant (F-1, security review: gating the exercise on the grant it exists to + * justify is a deadlock against grantCapability's own "already passed" + * requirement). It depends on activity-routing genuinely passing in THIS run + * (computed regardless of whether 'activity-routing' is in `tiers`) — + * 'skipped' with "activity-routing did not pass" otherwise, mirroring the + * admission short-circuit above it. A genuine pass is recorded via + * recordTierResult like any other tier; a maintainer's subsequent + * grantCapability call is what turns that evidence into a live capability. + * + * @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, + * 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, + 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 }) }); + } + + // primary-eligible's real exercise runs actual workers through the + // admitted host's derived execution adapter, so it needs activity-routing + // to have genuinely passed THIS run — not merely to have been requested. + // Computed here, once, whenever either tier needs it, so a caller asking + // for `tiers: ['primary-eligible']` alone still gets the real dependency + // check rather than an unconditioned pass/skip. + let activityRoutingResult = null; + if (wantTier('activity-routing') || wantTier('primary-eligible')) { + activityRoutingResult = await checkActivityRouting({ + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + }); + if (wantTier('activity-routing')) { + tierResults.push({ tier: 'activity-routing', ...activityRoutingResult }); + } + } + + if (wantTier('primary-eligible')) { + // F-1 (security review): NOT checkGrantGatedTier — that gates the + // exercise on an already-existing canBePrimary grant, which deadlocks + // against grantCapability's own requirement of an already-'passed' + // tier (see checkPrimaryEligible's header comment). checkPrimaryEligible + // runs the real exercise unconditionally once its own prerequisites + // (admission, activity-routing) are met — evidence first, grant second, + // per ADR-0031 §1/§2. + let result; + if (!effectiveManifest) { + result = { status: 'skipped', checks: [{ name: 'admission prerequisite', ok: false, detail: 'admission tier did not pass — cannot evaluate' }] }; + } else if (activityRoutingResult?.status !== 'passed') { + // Mirrors the admission short-circuit above: when admission passed + // but activity-routing (this tier's own real prerequisite) did not, + // report the SAME 'skipped' shape with a distinct reason, rather + // than attempting an exercise the host cannot actually support yet. + result = { + status: 'skipped', + checks: [{ name: 'activity-routing prerequisite', ok: false, detail: 'activity-routing did not pass — cannot evaluate' }], + }; + } else { + result = await checkPrimaryEligible({ + manifest: effectiveManifest, name: resolvedName, baseDir: derivedBaseDir, haveFn, clock, + }); + } + 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 }); + } else if (tierResult.status === 'failed' && Object.hasOwn(TIER_GRANTS, tierResult.tier)) { + // N-1 (security-review follow-up): a grant-bearing tier that + // RE-RUNS 'failed' at the SAME (unchanged) hash means evidence a + // live capability rests on was just shown non-reproducible — void + // the stored tier and the capability together (grants.mjs's + // recordTierFailure), mirroring recordTierGate's downgrade for + // 'gated' immediately above. Deliberately NOT triggered by + // 'skipped': a skipped tier (e.g. primary-eligible + // short-circuiting because its own prerequisite — activity- + // routing — didn't run/pass THIS run) means the tier was never + // actually EVALUATED this run, which is ambiguous, not disproven + // — downgrading on an ambiguous non-evaluation would void a live + // capability on evidence that says nothing about whether it + // still holds. + recordTierFailure(resolvedName, tierResult.tier, { hash }, { 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/grants.mjs b/src/lib/adapters/grants.mjs new file mode 100644 index 0000000..04937d5 --- /dev/null +++ b/src/lib/adapters/grants.mjs @@ -0,0 +1,393 @@ +// Hash-pinned capability-grant store (ADR-0031 §1, §2, §4). A JSON map of +// adapter name -> record under the kit config dir, mirroring adapter-consent's +// edit-invalidation model: a capability is earned by passing a conformance +// tier and GRANTED by the maintainer at a specific manifest hash, never +// self-declared in the adapter's own manifest (the permanent safety invariant +// this store exists to keep honest — see admission.mjs's schema allow-list). +// Change the manifest and the hash changes, so every prior tier result and +// every granted capability is void until re-earned at the new hash. +// +// No interactive prompting lives here — grants are RECORDED elsewhere (a +// future `ak host adapters trust` / `ak host adapters grant` command). This +// module is the programmatic seam those commands call. +// +// INVARIANT for callers wiring capabilities into runtime behaviour: +// grantedCapabilitiesFor() is the ONLY reader that may feed a capability +// decision. grantsFor() and gatedTiersFor() are reporting/inspection +// surfaces — grantsFor() can return a record pinned to a stale hash (flagged +// `stale: true` when a currentHash is supplied) precisely so a status +// display CAN show stale evidence as stale, not hide it. Reading +// grantsFor(name).capabilities directly to decide what a host may do would +// reintroduce the exact bug grantedCapabilitiesFor's hash pin exists to +// prevent. +import fs from 'node:fs'; +import path from 'node:path'; +import { configDir } from '../paths.mjs'; + +/** The five conformance tiers, in graduation order (ADR-0031 §2). */ +export const CONFORMANCE_TIERS = Object.freeze([ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', +]); + +/** The only capabilities `ak` can grant. `session-driving` and + * `activity-routing` gate capabilities a manifest may already express + * (canDriveSession, canRouteActivities) — their tier records are evidence, + * not grants, so they have no entry here. `aqeProvider` is never grantable by + * `ak` at all (upstream-owned enumeration, ADR-0031 §4) and MUST NOT appear + * in this map. */ +export const TIER_GRANTS = Object.freeze({ + 'primary-eligible': 'canBePrimary', + statusline: 'commandStatusline', +}); + +/** Reverse of TIER_GRANTS: capability -> its gating tier, or undefined if + * `capability` is not one `ak` can actually grant (e.g. a forged/legacy + * 'aqeProvider' key sitting in a hand-edited or merged store). Shared by + * grantCapability (write-time check) and grantedCapabilitiesFor (read-time + * re-check — F-1: the write-time gate alone does not protect a flat JSON + * file an operator can edit directly). */ +function gatingTierForCapability(capability) { + return Object.entries(TIER_GRANTS).find(([, cap]) => cap === capability)?.[0]; +} + +export const adapterGrantsPath = () => path.join(configDir(), 'adapter-grants.json'); + +function readStore(file) { + let raw; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return {}; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** Atomic tmp+rename write at 0600, matching consent.mjs and the rest of the + * kit's user-owned config writes. */ +function writeStore(file, store) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + let renamed = false; + try { + fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, file); + renamed = true; + } finally { + if (!renamed) { + try { fs.rmSync(tmp, { force: true }); } catch { /* cleanup must not mask the write error */ } + } + } + try { fs.chmodSync(file, 0o600); } catch { /* best-effort on platforms without POSIX perms */ } +} + +const GATED_BY_RE = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?#[1-9][0-9]*$/; + +function isValidTier(tier) { + return typeof tier === 'string' && CONFORMANCE_TIERS.includes(tier); +} + +function requireName(name, fnName) { + if (typeof name !== 'string' || !name) throw new TypeError(`${fnName} requires an adapter name`); +} + +function requireHash(hash, fnName) { + if (typeof hash !== 'string' || !hash) throw new TypeError(`${fnName} requires a hash`); +} + +function validRecord(value) { + return !!value && typeof value === 'object' + && typeof value.hash === 'string' && value.hash.length > 0 + && !!value.tiers && typeof value.tiers === 'object' && !Array.isArray(value.tiers); +} + +/** The record for `name` at exactly `hash`, own-property only (Object.hasOwn, + * never `in` — a store containing 'constructor' or '__proto__' must not read + * as a record via the prototype chain). A hash mismatch (or no record at all) + * means "start fresh": the caller REPLACES the whole entry, since stale-hash + * evidence and capabilities must never coexist with fresh-hash evidence. */ +function freshRecordAt(store, name, hash) { + const existing = Object.hasOwn(store, name) ? store[name] : null; + if (existing && existing.hash === hash) return { ...existing, tiers: { ...existing.tiers } }; + return { hash, tiers: {} }; +} + +/** Record a passing conformance-tier result for `name` at `hash`. Evidence is + * truncated at 2048 chars — bounded so a runaway harness output can never + * blow up the store. For a grant-bearing tier (one of TIER_GRANTS' keys — + * 'primary-eligible', 'statusline') non-empty evidence is REQUIRED: ADR-0031 + * §1 is "conformance evidence plus an explicit maintainer grant confers + * [the capability]", so a grant must never trace back to an empty evidence + * string. Evidence-only tiers ('admission', 'session-driving', + * 'activity-routing') keep it optional. Recording at a hash different from + * the stored record silently REPLACES the whole entry: prior tiers and any + * granted capabilities are void (they were earned against a manifest that no + * longer exists at this hash). + * @param {string} name + * @param {string} tier + * @param {{hash?: string, evidence?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierResult(name, tier, { hash, evidence } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierResult'); + requireHash(hash, 'recordTierResult'); + if (!isValidTier(tier)) throw new TypeError(`recordTierResult requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + if (Object.hasOwn(TIER_GRANTS, tier) && (typeof evidence !== 'string' || evidence.trim().length === 0)) { + throw new TypeError(`recordTierResult requires non-empty evidence for grant-bearing tier '${tier}' (gates '${TIER_GRANTS[tier]}')`); + } + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { + status: 'passed', + recordedAt: new Date().toISOString(), + evidence: typeof evidence === 'string' ? evidence.slice(0, 2048) : '', + }; + store[name] = record; + writeStore(file, store); +} + +/** Record that `tier` cannot be met because the capability is upstream + * (ADR-0031 §4) — gated, not failed. `gatedBy` pins the tracking issue, e.g. + * 'agentic-qe#563' or 'ruvnet/ruflo#2962'. Same hash-replace semantics as + * recordTierResult. + * + * F-2: if `tier` is grant-bearing (a TIER_GRANTS key) and currently backs a + * LIVE capability at this same hash, downgrading it to 'gated' must also + * drop that capability — evidence and grant must never disagree (a status + * display must never show a tier as 'gated' while the capability it used to + * back is still listed as granted). A hash change already wipes capabilities + * via freshRecordAt, so this only matters for the same-hash downgrade case. + * @param {string} name + * @param {string} tier + * @param {{hash?: string, gatedBy?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierGate(name, tier, { hash, gatedBy } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierGate'); + requireHash(hash, 'recordTierGate'); + if (!isValidTier(tier)) throw new TypeError(`recordTierGate requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + if (typeof gatedBy !== 'string' || !GATED_BY_RE.test(gatedBy)) { + throw new TypeError(`recordTierGate requires gatedBy in '#' form (e.g. 'agentic-qe#563'), got: ${gatedBy}`); + } + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { status: 'gated', recordedAt: new Date().toISOString(), gatedBy }; + const capability = TIER_GRANTS[tier]; + if (capability && record.capabilities && Object.hasOwn(record.capabilities, capability)) { + const capabilities = { ...record.capabilities }; + delete capabilities[capability]; + record.capabilities = capabilities; + } + store[name] = record; + writeStore(file, store); +} + +/** Record a genuinely FAILED conformance-tier result for `name` at `hash` — + * the un-earn path recordTierGate already implements for the 'gated' case + * (N-1, security-review follow-up), mirrored here for 'failed': a + * grant-bearing tier (a TIER_GRANTS key) that RE-RUNS 'failed' at the SAME + * hash a capability was previously granted at means the evidence a live + * capability rests on no longer reproduces — the stored 'passed' tier and + * the granted capability must both be voided, not left standing on evidence + * just shown non-reproducible. Same hash-replace semantics as + * recordTierResult/recordTierGate: a DIFFERENT hash means "start fresh" + * (freshRecordAt already wipes every prior tier and capability), so this + * only has anything to drop for the same-hash case. + * + * No `evidence` field: a failure records that the tier no longer passes, not + * new evidence of anything — callers wanting the failure detail keep it in + * their own report (conformance.mjs's `checks`), same as recordTierGate + * keeps no evidence field either. + * + * F-4 (security-review follow-up): this function itself is generic over any + * grant-bearing tier, but conformance.mjs's harness only ever calls it for + * 'primary-eligible' today — 'statusline's own check (checkGrantGatedTier) + * can only report 'passed'/'gated'/'skipped', never 'failed', so this un-earn + * path currently has no way to reach commandStatusline through the + * conformance runner. That is fine while commandStatusline stays inert (no + * runtime reader anywhere in src/ yet) — a stale/non-reproducing statusline + * grant can only be voided by a hash change or an explicit `revoke-grant` + * until commandStatusline's own render path lands and its exercise can + * genuinely fail, not just gate. + * @param {string} name + * @param {string} tier + * @param {{hash?: string}} details + * @param {{file?: string}} [options] + */ +export function recordTierFailure(name, tier, { hash } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'recordTierFailure'); + requireHash(hash, 'recordTierFailure'); + if (!isValidTier(tier)) throw new TypeError(`recordTierFailure requires a valid tier (one of ${CONFORMANCE_TIERS.join(', ')}), got: ${tier}`); + const store = readStore(file); + const record = freshRecordAt(store, name, hash); + record.tiers[tier] = { status: 'failed', recordedAt: new Date().toISOString() }; + const capability = TIER_GRANTS[tier]; + if (capability && record.capabilities && Object.hasOwn(record.capabilities, capability)) { + const capabilities = { ...record.capabilities }; + delete capabilities[capability]; + record.capabilities = capabilities; + } + store[name] = record; + writeStore(file, store); +} + +/** Grant `capability` to `name` — the maintainer's act that turns conformance + * evidence into an actual capability (ADR-0031 §1). Refuses unless the tier + * that gates `capability` is recorded 'passed' AT THE SAME hash: evidence + * plus an explicit grant confers capability, never a grant on its own, and + * never evidence recorded against a manifest that has since changed. + * @param {string} name + * @param {string} capability + * @param {{hash?: string}} details + * @param {{file?: string}} [options] + */ +export function grantCapability(name, capability, { hash } = {}, { file = adapterGrantsPath() } = {}) { + requireName(name, 'grantCapability'); + requireHash(hash, 'grantCapability'); + const tier = gatingTierForCapability(capability); + if (!tier) { + throw new TypeError(`grantCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`); + } + const store = readStore(file); + const existing = Object.hasOwn(store, name) ? store[name] : null; + const tierEntry = existing && existing.hash === hash ? existing.tiers?.[tier] : undefined; + if (!tierEntry || tierEntry.status !== 'passed') { + throw new Error(`grantCapability: '${name}' has no passed '${tier}' tier recorded at hash ${hash}`); + } + const record = { ...existing, tiers: { ...existing.tiers }, capabilities: { ...(existing.capabilities ?? {}) } }; + record.capabilities[capability] = true; + record.grantedAt = new Date().toISOString(); + store[name] = record; + writeStore(file, store); +} + +/** Remove the whole record for `name` — every tier result, gate, and granted + * capability. Returns whether an entry existed (Object.hasOwn semantics: a + * prototype-chain name like 'constructor' never reads as existing). */ +export function revokeGrants(name, { file = adapterGrantsPath() } = {}) { + if (typeof name !== 'string' || !name) return false; + const store = readStore(file); + if (!Object.hasOwn(store, name)) return false; + delete store[name]; + writeStore(file, store); + return true; +} + +/** Remove ONLY `capability` from `name`'s record.capabilities — every tier + * result (and any OTHER granted capability) is left intact, unlike + * revokeGrants above which wipes the whole record. Refuses (throws + * TypeError) any `capability` that is not one of TIER_GRANTS' values — + * matches grantCapability's own allow-list, so this can never be asked to + * remove a forged/legacy key (e.g. 'aqeProvider') that grantCapability could + * never have written in the first place. Returns whether the capability + * existed beforehand; false (never throws) for a missing/never-recorded + * `name`. */ +export function revokeCapability(name, capability, { file = adapterGrantsPath() } = {}) { + if (typeof name !== 'string' || !name) return false; + if (!Object.values(TIER_GRANTS).includes(capability)) { + throw new TypeError(`revokeCapability: '${capability}' is not a grantable capability (must be one of ${Object.values(TIER_GRANTS).join(', ')})`); + } + const store = readStore(file); + const existing = Object.hasOwn(store, name) ? store[name] : null; + if (!existing || !existing.capabilities || !Object.hasOwn(existing.capabilities, capability)) return false; + const capabilities = { ...existing.capabilities }; + delete capabilities[capability]; + store[name] = { ...existing, tiers: { ...existing.tiers }, capabilities }; + writeStore(file, store); + return true; +} + +/** The raw validated record for `name`, or null — missing, corrupt, or + * malformed all collapse to null. Never throws. THIS IS A REPORTING SURFACE, + * NOT A CAPABILITY READER — see the module-header invariant; runtime + * capability decisions must go through grantedCapabilitiesFor(). + * + * `currentHash` is optional and only changes what gets ANNOTATED, never what + * gets returned or hidden: omitted, the raw record comes back with no + * `stale` field at all (a caller not doing hash-aware reporting shouldn't + * have to reason about it). Supplied, the record comes back with `stale: + * true`/`false` so a status display can show stale evidence as stale + * (rather than silently dropping it) while still being unambiguous about + * whether it's current. + * @param {string} name + * @param {{file?: string, currentHash?: string}} [options] + * @returns {any} + */ +export function grantsFor(name, { file = adapterGrantsPath(), currentHash } = {}) { + if (typeof name !== 'string' || !name) return null; + try { + const store = readStore(file); + const entry = Object.hasOwn(store, name) ? store[name] : null; + if (!validRecord(entry)) return null; + if (currentHash === undefined) return entry; + return { ...entry, stale: entry.hash !== currentHash }; + } catch { + return null; + } +} + +/** The granted capabilities for `name`, but ONLY when the record's pinned + * hash matches `currentHash` — a manifest edit silently voids every grant + * until re-earned, exactly consent's pin model. {} on any mismatch, missing + * record, or read failure. Never throws. This is the ONLY reader capability- + * wiring code may consume — see the module-header invariant. + * + * F-1: this re-derives the grant invariant at READ time rather than trusting + * record.capabilities verbatim. adapter-grants.json is a flat JSON file an + * operator (or a bad merge) can hand-edit directly — grantCapability's + * write-time gate does not protect against that. A capability is only ever + * returned when it (a) is a real TIER_GRANTS value — never e.g. a forged + * 'aqeProvider' key — AND (b) its gating tier is recorded 'passed' at this + * exact hash. This raises store forgery from "add one key" to "also forge a + * matching passed tier", and makes aqeProvider unreturnable even if present + * in the raw file. */ +export function grantedCapabilitiesFor(name, currentHash, { file = adapterGrantsPath() } = {}) { + try { + const record = grantsFor(name, { file }); + if (!record || record.hash !== currentHash) return {}; + const stored = record.capabilities && typeof record.capabilities === 'object' && !Array.isArray(record.capabilities) + ? record.capabilities + : {}; + const out = {}; + for (const [capability, value] of Object.entries(stored)) { + if (value !== true) continue; + const tier = gatingTierForCapability(capability); + if (!tier || record.tiers?.[tier]?.status !== 'passed') continue; + out[capability] = true; + } + return out; + } catch { + return {}; + } +} + +/** The tiers currently marked 'gated' for `name` — what it's waiting on + * upstream. [] on missing/corrupt/no-gated-tiers. Never throws. + * `currentHash` is optional; when supplied and it mismatches the record's + * pinned hash, returns [] — a changed manifest voids its gated-tier records + * exactly as it voids grants (they describe a manifest that no longer + * exists at this hash), so a hash-aware caller must never surface them as + * live open requests. Omitted, returns every gated tier regardless of hash + * (the same raw-reporting behaviour as grantsFor with no currentHash). + * @param {string} name + * @param {{file?: string, currentHash?: string}} [options] + * @returns {Array<{tier: string, gatedBy: string, recordedAt: string}>} + */ +export function gatedTiersFor(name, { file = adapterGrantsPath(), currentHash } = {}) { + try { + const record = grantsFor(name, { file }); + if (!record) return []; + if (currentHash !== undefined && record.hash !== currentHash) return []; + const out = []; + for (const [tier, entry] of Object.entries(record.tiers)) { + if (entry && entry.status === 'gated') out.push({ tier, gatedBy: entry.gatedBy, recordedAt: entry.recordedAt }); + } + return out; + } catch { + return []; + } +} diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs index 168d6bd..280e1ff 100644 --- a/src/lib/adapters/hook-runner.mjs +++ b/src/lib/adapters/hook-runner.mjs @@ -8,6 +8,7 @@ // summary capture, no graceful-then-forced two-step shutdown. A timed-out // adapter hook gets no cleanup grace period; it already spent its budget. import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process'; +import { isAbsolute as pathIsAbsolute } from 'node:path'; const DEFAULT_TIMEOUT_MS = 30_000; const OUTPUT_CAP_BYTES = 256 * 1024; @@ -88,6 +89,15 @@ function mergeCapture(stdout, stderr) { return `${kept}${TRUNCATION_MARKER}`; } +/** stderr alone, with the same per-stream truncation marker `mergeCapture` + * would append — never folded into `stdout`. F-4: a caller that parses + * `stdout` as a structured payload (e.g. the admitted execution adapter) + * must never see raw stderr promoted into that parse; this is the field it + * reads instead when it needs the process's diagnostic chatter. */ +function boundedText({ text, truncated }) { + return truncated ? `${text}${TRUNCATION_MARKER}` : text; +} + function describeFailure(hostId, verb, error) { const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error)); return `${hostId}:${verb} adapter hook failed to start: ${reason}`; @@ -104,7 +114,12 @@ function raceTimeout(promise, ms) { * child — a timed-out adapter hook may have spawned descendants of its own. * POSIX: the child was spawned detached so its pid is also its process group * id; signalling `-pid` reaches the whole group. Windows has no portable - * signal for arbitrary console trees, so `taskkill /T /F` owns it there. */ + * signal for arbitrary console trees, so `taskkill /T /F` owns it there. + * F-2: this cannot PROVE a double-forked or re-`setsid`'d grandchild died — + * a signal sent is not a death confirmed. Callers that need that proof (the + * admitted execution adapter's cancel path) must treat an unresolved launch + * as honestly unproven (`orphaned`), not assume this function's return means + * the tree is gone. */ async function killGroup(child) { if (!Number.isInteger(child?.pid)) return; if (isWindows) { @@ -127,31 +142,49 @@ async function killGroup(child) { * (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously. * * @param {{hook:{command:string[], timeoutMs?:number}, hostId:string, - * verb:string, timeoutMs?:number, env?:Record}} options - * @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>} + * verb:string, timeoutMs?:number, env?:Record, stdin?:string, + * cwd?:string}} options + * @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string, + * exitCode:number|null, detail:string|null}>} */ -export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) { +export async function runAdapterHook({ + hook, hostId, verb, timeoutMs, env, stdin, cwd, +} = /** @type {any} */ ({})) { if (!hook || !Array.isArray(hook.command) || hook.command.length === 0 || !hook.command.every((part) => typeof part === 'string' && part.length > 0)) { throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings'); } if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId'); if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb'); + // F-1: a relative cwd would resolve against wherever the ak process + // happens to be running, defeating the whole point of pinning the child to + // the adapter's own directory — refused synchronously, same class as the + // arg-shape checks above, never silently reinterpreted as "inherit". + if (cwd !== undefined && (typeof cwd !== 'string' || !cwd || !pathIsAbsolute(cwd))) { + throw new TypeError('runAdapterHook requires cwd to be an absolute path when provided'); + } const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs); const [argv0, ...args] = hook.command; const childEnv = minimalEnv(env); + const wantsStdin = typeof stdin === 'string'; let child; try { child = nodeSpawn(argv0, args, { env: childEnv, shell: false, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: !isWindows, + // Absent cwd falls through to Node's own default (inherit + // process.cwd()) — today's behavior for callers that don't pass one + // yet (B2 threads the real adapter-base-dir cwd through this wave). + ...(cwd === undefined ? {} : { cwd }), }); } catch (error) { - return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) }; + return { + ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error), + }; } const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES); @@ -159,6 +192,21 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /* child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk)); child.stderr?.on('data', (chunk) => stderrCollector.write(chunk)); + if (wantsStdin) { + // A child that exits before (or without) reading stdin makes the pipe + // write EPIPE — that is a normal outcome (the process's own exit code + // already reports what happened), never a reason to crash or reject + // runAdapterHook's promise. The 'close' handler below still fires and + // resolves the race normally regardless of whether this write lands. + child.stdin?.on('error', () => {}); + try { + child.stdin?.end(stdin); + } catch { + // Synchronous throw from an already-closed stream — same non-fatal + // treatment as the async 'error' event above. + } + } + let settled = false; let spawnError = null; const closeResult = new Promise((resolve) => { @@ -175,27 +223,40 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /* if (raced === TIMEOUT_SENTINEL) { await killGroup(child); await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused + const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }; + const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }; return { ok: false, exitCode: null, - stdout: mergeCapture( - { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, - { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, - ), + stdout: mergeCapture(stdoutCaptured, stderrCaptured), + stdoutText: boundedText(stdoutCaptured), + stderrText: boundedText(stderrCaptured), detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`, }; } if (spawnError) { - return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) }; + return { + ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError), + }; } const { code } = raced; - const stdout = mergeCapture( - { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, - { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, - ); + // F-4/R-1: stdout stays the combined stream for diagnostics/back-compat, + // but a caller parsing stdout as a structured payload (the admitted + // execution adapter) must read stdoutText instead — stdout has stderr + // folded in after a separator, which breaks JSON.parse the instant the + // hook writes anything to stderr at all. stderrText is diagnostics-only. + const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }; + const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }; + const stdout = mergeCapture(stdoutCaptured, stderrCaptured); + const stdoutText = boundedText(stdoutCaptured); + const stderrText = boundedText(stderrCaptured); return code === 0 - ? { ok: true, stdout, exitCode: 0, detail: null } - : { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` }; + ? { + ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null, + } + : { + ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`, + }; } 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/adapters/lifecycle.mjs b/src/lib/adapters/lifecycle.mjs index 990935c..221aa6e 100644 --- a/src/lib/adapters/lifecycle.mjs +++ b/src/lib/adapters/lifecycle.mjs @@ -51,7 +51,31 @@ export async function runLifecycle(adapterOrRequest, operation, context = {}) { const facts = request.facts ?? await adapter.detect(request); if (action === 'plan') return adapter.plan({ ...request, facts }); if (action === 'apply') { + // F8 (security-review follow-up): fail closed when detect (or plan) + // signals a hook failure — never let apply run against facts/a plan the + // adapter itself refused to produce. The gate fires on ANY truthy + // `.error` on the detect-facts/plan object — an ADMITTED host's hook + // failure is reported this way ({observed:null, error} from + // detect/verify, {changed:false, operations:[], error} from plan — + // lifecycle-registry.mjs's hookFailureResult/unanchoredResult), but a + // SUCCESSFUL hook's own JSON payload is returned verbatim too (same + // file's buildAdmittedLifecycleAdapter, `return payload`), so `error` is + // effectively RESERVED in the detect/plan payload contract: a hook must + // never use it for a non-fatal note, only genuine failure. opencode's own + // detect()/plan() (see opencode.mjs's createOpencodeLifecycleAdapter) + // never set an `.error` key on any path, so this check is a strict no-op + // for opencode regardless. + if (facts?.error) { + return lifecycleResult({ ok: false, changed: false, errors: [facts.error] }); + } const plan = request.plan ?? await adapter.plan({ ...request, facts }); + if (plan?.error) { + return lifecycleResult({ ok: false, changed: false, errors: [plan.error] }); + } + // The abort above intentionally precedes the dryRun preview below: a + // failed detect/plan has no valid plan to preview, so dryRun on a hook + // failure returns this lifecycleResult (no `.dryRun` field) rather than a + // fabricated {dryRun:true, facts, plan}. if (dryRun) return { dryRun: true, facts, plan }; return adapter.apply({ ...request, facts, plan }); } diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs index 801f89f..a22c22b 100644 --- a/src/lib/adapters/manifest.mjs +++ b/src/lib/adapters/manifest.mjs @@ -52,7 +52,7 @@ export class ManifestRejected extends TypeError { // validateHostAdapter, src/lib/hosts.mjs, src/lib/providers.mjs) consume — // widen them only alongside a new legitimate consumer, never speculatively. const MANIFEST_ALLOWED_KEYS = Object.freeze([ - 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', + 'name', 'version', 'contract', 'host', 'detection', 'driving', 'lifecycle', 'trust', 'execution', ]); // Everything validateHostAdapter itself reads (id, label, install, // capabilities, trust, enabledByDefault, configProjection, observability) @@ -172,6 +172,30 @@ function validateManifestLifecycle(value) { return structuredClone(value); } +// execution.run.hook is the single subprocess `ak run` spawns to drive an +// admitted host as a worker (P2, ADR-0031). Same hook shape and validation +// discipline as a lifecycle verb's hook, but there is exactly one verb +// ('run'), never a caller-named one, so the allowlists are inlined rather +// than looped like validateManifestLifecycle's verb map. +function validateExecution(value) { + assertRecord(value, 'execution'); + assertNoUnknownKeys(value, ['run'], 'execution'); + assertRecord(value.run, 'execution.run'); + assertNoUnknownKeys(value.run, ['hook'], 'execution.run'); + assertRecord(value.run.hook, 'execution.run.hook'); + assertNoUnknownKeys(value.run.hook, ['command', 'timeoutMs'], 'execution.run.hook'); + try { + assertStringArray(value.run.hook.command, 'execution.run.hook.command', { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-execution', error.message); + } + if (value.run.hook.timeoutMs !== undefined + && (!Number.isInteger(value.run.hook.timeoutMs) || value.run.hook.timeoutMs <= 0)) { + throw new ManifestRejected('invalid-execution', 'execution.run.hook.timeoutMs must be a positive integer'); + } + return structuredClone(value); +} + function validateManifestTrust(value) { assertRecord(value, 'trust'); assertNoUnknownKeys(value, ['changes'], 'trust'); @@ -283,11 +307,19 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob throw new ManifestRejected('invalid-guidance-file', error.message); } } + // P2 structural coupling (ADR-0031): an execution hook on a host that + // cannot route activities is a contradiction the schema refuses outright, + // never silently ignores. The converse — routable, no execution block — is + // legal and degrades honestly at run time (cli_unavailable). + if (value.execution !== undefined && host.capabilities.canRouteActivities !== true) { + throw new ManifestRejected('execution-not-routable', 'manifest.execution requires host.capabilities.canRouteActivities: true'); + } const detection = validateDetection(value.detection); const driving = validateDriving(value.driving); const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle); const trust = validateManifestTrust(value.trust); + const execution = value.execution === undefined ? undefined : validateExecution(value.execution); return immutable({ name: value.name, @@ -297,6 +329,11 @@ export function validateAdapterManifest(value, { projections = projectionMap, ob detection, driving, ...(lifecycle === undefined ? {} : { lifecycle }), + // execution rides in the same validated-output object hashManifest + // (admission.mjs) canonicalizes and hashes, so declaring/editing an + // execution block changes consent's covered hash automatically — no + // separate hashing path to keep in sync. + ...(execution === undefined ? {} : { execution }), trust, }); } diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index 29fb535..6a2e67f 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -371,6 +371,20 @@ export function providersWithCapability(entriesOrCapability, maybeCapability) { return entries.filter((provider) => provider.capabilities?.[capability] === true); } +// Built-in-only BY DESIGN, not by oversight (F-4, D2 keystone security +// review): this defaults `hosts` to HOST_REGISTRY, so a caller invoking it +// with no second argument will refuse an admitted-and-granted external host +// even though it has earned canBePrimary. registries.mjs has no knowledge of +// the admitted-overlay/grant machinery (admitted.mjs imports FROM this file, +// not the reverse — see admitted.mjs's effectivePrimaryHostIds() comment), +// so it cannot default to the grant-aware set itself. There is no production +// caller of this function today (fail-closed by omission, not a leak) — but +// if one is ever wired up for an admitted host's eligibility, it MUST pass +// `hosts: effectiveHostRegistry()` explicitly (mirroring how +// materializeRunPlan calls the routing sibling, validateActivityHost, in +// routing.mjs), or use admitted.mjs's effectivePrimaryHostIds() ids-only +// check instead. Do NOT wire this function into a granted-host path using +// its bare default. export function validatePrimaryHost(id, hosts = HOST_REGISTRY) { const host = hosts.find((entry) => entry.id === id); if (!host) return { ok: false, reason: 'unknown-host' }; diff --git a/src/lib/adapters/sources.mjs b/src/lib/adapters/sources.mjs new file mode 100644 index 0000000..ed98fff --- /dev/null +++ b/src/lib/adapters/sources.mjs @@ -0,0 +1,420 @@ +// Remote manifest sources (ADR-0031, work item P6): resolves an adapter +// manifest's `source` (kit.json's hostAdapters[].source) into raw parsed +// JSON bytes. This module is a pure fetch/parse layer — validation, hashing, +// and consent stay admission.mjs's job. +// +// Security-review-mandated ordering: this resolver runs BEFORE hashing. +// admission.mjs's admitOne calls readManifest() (which delegates here) and +// only afterward validates + hashes the result. That means consent pins the +// RESOLVED content, not a name: a mutable remote (an npm dist-tag moving, a +// URL's content changing) produces different bytes on the next admission +// pass, which hashes differently and is refused as 'consent-stale' rather +// than being silently re-trusted. Nothing in this file may cache or memoize +// across calls — every call re-resolves from scratch, which is what makes +// that invalidation automatic instead of something callers must remember to +// force. +// +// Three source forms, dispatched on prefix: +// - a file path (no recognized prefix) — no network, ever (offline-first +// is a repo invariant); an `lstat` gate refuses anything that is not a +// plain regular file (symlinks, directories, FIFOs, devices) and +// enforces `maxBytes` before the content is ever read, then JSON.parse. +// - https://... — HTTPS only, no redirects followed, bounded time and +// bytes (a Content-Length pre-check, a streamed hard cap, and a single +// timeout budget that stays armed across BOTH the initial request and +// the full body read — a response that sends headers and then never +// delivers the rest of the body must not hang forever). +// - npm:[@version|tag] — `npm pack` (never executing the package's +// own scripts) into a throwaway temp dir, then `tar` extracts exactly +// `package/ak-adapter.json` STRAIGHT TO STDOUT — never to disk. A +// tarball member can be a symlink (arbitrary local file read via a +// followed link), a FIFO (extraction hang), or a device node; -O +// sidesteps that whole class by never writing extracted content to the +// filesystem at all, and the subprocess's own `maxBuffer` — bounded +// tight to `maxBytes` for this one call, not the generic subprocess cap +// used elsewhere — rejects an oversized member before it fully lands in +// process memory, closing the decompression-bomb angle a disk-based +// extraction would otherwise have to police after the fact. +// +// Why npm tarball extraction shells npm+tar rather than adding a `tar` +// dependency: this package is zero-runtime-dependency by ADR-0016, npm is +// already shelled elsewhere in this codebase (providers.mjs's installHost, +// heal.mjs's upgradePackage/selfUpdate), and `npm pack` of a remote spec +// downloads the package without ever running ITS scripts (belt: +// --ignore-scripts, on top of the fact that `npm pack` only runs +// prepare/prepack/postpack in the first place — this flag closes that off +// too). Reaching for a tar library would trade a well-audited system binary +// for a new supply-chain dependency to buy nothing extraction-to-stdout +// couldn't already do more simply. +import { execFile as nodeExecFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveShim } from '../exec.mjs'; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_BYTES = 262_144; +// Subprocess stdout/stderr capture cap for `npm pack` itself — its own +// textual/progress output, never the manifest content (which goes through a +// separately, tightly bounded maxBuffer — see resolveNpmSource). +const SUBPROCESS_MAX_BUFFER = 16 * 1024 * 1024; +// SourceError detail text is bounded to this length, matching +// src/lib/execution/runner.mjs's boundedFailure precedent. +const DETAIL_MAX_LENGTH = 240; + +const defaultExecFile = promisify(nodeExecFile); + +export class SourceError extends Error { + constructor(reason, detail) { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'SourceError'; + this.reason = reason; + } +} + +/** Strips C0 control characters (0x00–0x1F) and the full C1 range + * (0x7F–0x9F inclusive — DEL plus every C1 control, e.g. U+009B CSI) from + * external text before it can enter a SourceError detail — subprocess + * stderr, thrown-error messages, and JSON-parse error snippets are all + * attacker-influenced text (npm/tar output, a remote server's response, + * error text derived from a hostile manifest) that later gets printed raw + * by consumers (the bin warning path, the trust CLI). '\n'/'\t'/'\r' + * collapse to a single space rather than vanish, so a multi-line message + * stays readable as one line; every other C0/C1 byte is dropped outright — + * this defuses ANSI escape sequences (ESC-prefixed AND the single-byte C1 + * form, e.g. U+009B in place of ESC+'[') and terminal-control tricks, not + * just newlines. Bounded to DETAIL_MAX_LENGTH chars afterward. */ +function sanitizeDetail(text) { + const input = String(text ?? ''); + let out = ''; + for (const ch of input) { + const code = ch.codePointAt(0); + if (code === 0x09 || code === 0x0a || code === 0x0d) { out += ' '; continue; } + if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue; + out += ch; + } + return out.trim().slice(0, DETAIL_MAX_LENGTH); +} + +/** Single choke point for constructing a SourceError: every detail string, + * however it was built, passes through sanitizeDetail here — callers never + * need to remember to sanitize at each throw site individually. */ +function sourceError(reason, detail) { + return new SourceError(reason, sanitizeDetail(detail)); +} + +// Every character an npm package spec (name[@version-or-tag]) may legally +// contain, checked against the WHOLE spec string before any parsing — +// command-injection surface. A spec containing ';', '$', '`', whitespace, +// or any other shell/argv-hostile character is rejected here, before it is +// ever assembled into an argv array (even though execFile+shell:false +// already blocks shell interpretation — this is belt-and-suspenders, the +// same posture as assertId elsewhere in this codebase). +const NPM_SPEC_CHARS_RE = /^[A-Za-z0-9@/._-]+$/; +// A conservative structural shape for the package-name portion only +// (scope optional): must start with an alphanumeric, never '.' or '_'. +const NPM_NAME_RE = /^(?:@[A-Za-z0-9][\w.-]*\/)?[A-Za-z0-9][\w.-]*$/; +// Exact semver only (no ranges — a range is meaningless for "pin one +// tarball"), mirroring manifest.mjs's SEMVER_RE. +const NPM_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/; +// A dist-tag ('latest', 'next', 'beta', ...): no '/' — that's the exact +// shape npm's own git-shorthand ("user/repo") and local-path ("../x") +// resolvers key off, so excluding '/' here is what keeps `npm:pkg@version` +// from silently reinterpreting the version half as a different resolver +// entirely (finding 7). +const NPM_DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/; + +/** Splits "name[@version-or-tag]" into { name, version }. Scoped names + * (`@scope/pkg`) have a leading '@' that is not a version separator, so the + * search for the separating '@' starts after the scope's '/'. */ +function parseNpmSpec(spec) { + const scoped = spec.startsWith('@'); + const rest = scoped ? spec.slice(1) : spec; + const at = rest.indexOf('@'); + if (at === -1) return { name: spec, version: undefined }; + const name = scoped ? `@${rest.slice(0, at)}` : rest.slice(0, at); + const version = rest.slice(at + 1); + return { name, version }; +} + +function isMaxBufferError(error) { + return error?.code === 'ERR_CHILD_PROCESS_STDOUT_MAXBUFFER' || /maxBuffer/i.test(error?.message ?? ''); +} + +async function resolveFileSource(source, maxBytes) { + // lstat, not stat: a symlink must be refused as itself (isFile() false on + // the link), never silently followed to whatever it points at — the same + // "no arbitrary local read via a followed link" posture the npm path's + // stdout-only extraction enforces (finding 6 mirrors finding 2). + let stat; + try { + stat = await fs.lstat(source); + } catch (error) { + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + if (!stat.isFile()) { + throw sourceError('source-invalid', `'${source}' is not a regular file — symlinks, directories, and special files are refused`); + } + if (stat.size > maxBytes) { + throw sourceError('source-too-large', `'${source}' is ${stat.size} bytes, exceeding the ${maxBytes}-byte cap`); + } + let text; + try { + text = await fs.readFile(source, 'utf8'); + } catch (error) { + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + try { + /** @type {'file'} */ + const origin = 'file'; + return { raw: JSON.parse(text), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } +} + +/** Streamed read with a hard byte cap, mirroring the bounded-read pattern in + * src/lib/execution/opencode.mjs's responseJson: cancel the reader the + * moment the cap is crossed, on top of an early Content-Length rejection. + * Abort-aware: `signal` is the SAME AbortSignal that bounds the whole + * resolve (see resolveHttpsSource) — every reader.read() races against it, + * so a response that sends headers and then never delivers the rest of the + * body still gets cut off at `timeoutMs`, not left to hang forever. */ +async function readBoundedBody(response, maxBytes, label, + { signal, timeoutMs } = /** @type {{signal?: AbortSignal, timeoutMs?: number}} */ ({})) { + const declared = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { + throw sourceError('source-too-large', `${label} declared Content-Length ${declared} exceeds ${maxBytes} bytes`); + } + if (!response.body?.getReader) { + throw sourceError('source-unreachable', `${label} did not expose a bounded response stream`); + } + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + const abortedPromise = signal + ? new Promise((_, reject) => { + const onAbort = () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + if (signal.aborted) { onAbort(); return; } + signal.addEventListener('abort', onAbort, { once: true }); + }) + : null; + try { + for (;;) { + const step = abortedPromise ? Promise.race([reader.read(), abortedPromise]) : reader.read(); + const { done, value } = await step; + if (done) break; + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + total += chunk.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + throw sourceError('source-too-large', `${label} response exceeded ${maxBytes} bytes`); + } + chunks.push(chunk); + } + } catch (error) { + if (error?.name === 'AbortError') { + await reader.cancel().catch(() => {}); + throw sourceError('source-unreachable', `${label} timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + reader.releaseLock?.(); + } + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(combined); +} + +async function resolveHttpsSource(url, { fetchFn, timeoutMs, maxBytes }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // The timer/controller stay alive for the ENTIRE resolve — fetch AND the + // full body read — cleared only in this outer finally. Clearing it right + // after the header fetch (the prior shape) let a 200 response with a + // body that never finishes arriving hang resolveManifestSource forever; + // since bootstrapHostAdapters runs on every `ak` command with the + // experimental flag on, that hang bricked the whole CLI (finding 1). + try { + let response; + try { + // redirect: 'manual' — a redirect is REFUSED outright, never + // followed. Fail-closed and explicit beats chasing a redirect chain + // to who-knows-where (ADR-0023 posture): pin the final URL instead. + response = await fetchFn(url, { redirect: 'manual', signal: controller.signal }); + } catch (error) { + if (error?.name === 'AbortError') { + throw sourceError('source-unreachable', `${url} timed out after ${timeoutMs}ms`); + } + throw sourceError('source-unreachable', error?.message ?? String(error)); + } + + // redirect:'manual' fetch implementations report a redirect either as + // an opaque 'opaqueredirect' response (status 0, browser-shaped fetch) + // or a plain 3xx status (some Node fetch implementations) — cover both. + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + throw sourceError('source-unreachable', 'redirects are not followed for adapter manifests — pin the final URL'); + } + if (!response.ok) { + throw sourceError('source-unreachable', `${url} responded with HTTP ${response.status}`); + } + + const text = await readBoundedBody(response, maxBytes, url, { signal: controller.signal, timeoutMs }); + try { + /** @type {'url'} */ + const origin = 'url'; + return { raw: JSON.parse(text), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } + } finally { + clearTimeout(timer); + } +} + +/** Run `npm pack` as a bounded subprocess. Resolves the command through + * `resolveShimFn` — production always passes src/lib/exec.mjs's real + * resolveShim (a no-op on POSIX, but the difference between a working and + * ENOENT'd npm invocation on Windows); tests inject a passthrough so their + * execFileFn stubs can assert the LOGICAL argv without also having to model + * Windows' PowerShell-wrapped shim shape (that shape is exec.mjs's own test + * responsibility, not this module's). Never shell:true; argv arrays only. */ +async function execBounded(execFileFn, cmd, args, { cwd, timeoutMs, maxBuffer }, label, resolveShimFn) { + const invocation = resolveShimFn(cmd, args); + if (invocation.resolved === false) { + throw sourceError('source-unreachable', `no safe invocation found for ${cmd}`); + } + try { + await execFileFn(invocation.command, invocation.args, { + cwd, timeout: timeoutMs, maxBuffer, shell: false, + }); + } catch (error) { + throw sourceError('source-unreachable', `${label} failed: ${error?.message ?? String(error)}`); + } +} + +async function resolveNpmSource(spec, { + execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn, +}) { + if (!NPM_SPEC_CHARS_RE.test(spec)) { + throw sourceError('source-invalid', `npm spec contains disallowed characters: ${spec}`); + } + const { name, version } = parseNpmSpec(spec); + if (!name || !NPM_NAME_RE.test(name)) { + throw sourceError('source-invalid', `not a valid npm package name: ${name || '(empty)'}`); + } + // The version/tag half is validated SEPARATELY from the character-class + // gate above: that gate alone still permits e.g. 'pkg@attacker/repo' (npm + // git-shorthand) or 'pkg@../../x' (npm's local-path resolver) through, + // silently swapping which resolver npm uses for something that reads like + // a version pin. Restricting it to exact semver or a slash-free dist-tag + // keeps `npm:` sources anchored to "one tarball, from the registry". + if (version !== undefined && !(NPM_SEMVER_RE.test(version) || NPM_DIST_TAG_RE.test(version))) { + throw sourceError('source-invalid', `not a version or dist-tag: ${version}`); + } + + const dir = fsSync.mkdtempSync(path.join(tmpDir ?? os.tmpdir(), 'ak-adapter-src-')); + try { + await execBounded( + execFileFn, + 'npm', + ['pack', spec, '--ignore-scripts', '--pack-destination', dir], + { cwd: dir, timeoutMs, maxBuffer: SUBPROCESS_MAX_BUFFER }, + 'npm pack', + resolveShimFn, + ); + + const entries = await fs.readdir(dir); + const tarball = entries.find((entry) => entry.endsWith('.tgz')); + if (!tarball) { + throw sourceError('source-invalid', `npm pack for '${spec}' produced no tarball`); + } + + // Extract ONLY package/ak-adapter.json, straight to stdout (-O) — + // never to disk. See the module header for why. + const invocation = resolveShimFn('tar', ['-xzOf', tarball, 'package/ak-adapter.json']); + if (invocation.resolved === false) { + throw sourceError('source-unreachable', 'no safe invocation found for tar'); + } + let stdout; + try { + // maxBuffer bounded tight to maxBytes (plus small encoding slack), + // not the generic SUBPROCESS_MAX_BUFFER above: this stdout IS the + // manifest content, so an oversized member is rejected by execFile's + // own buffer ceiling before it fully lands in process memory. + ({ stdout } = await execFileFn(invocation.command, invocation.args, { + cwd: dir, timeout: timeoutMs, maxBuffer: maxBytes + 4096, shell: false, + })); + } catch (error) { + if (isMaxBufferError(error)) { + throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`); + } + // npm pack already proved the package itself was reachable; a + // subsequent failure extracting this ONE specific member is, in + // practice, always "the member isn't in the archive" — refuse it as + // an invalid source, not an unreachable one. + throw sourceError('source-invalid', `package '${spec}' does not ship an ak-adapter.json at its root`); + } + + // A symlink (or other non-regular) tar entry carries no data blocks, so + // -O extraction of one yields empty stdout. Refuse that explicitly and + // honestly, rather than letting an opaque JSON.parse('') error stand in + // for "this entry produced nothing, on purpose". + if (!stdout || !stdout.trim()) { + throw sourceError('source-invalid', `package '${spec}' member 'package/ak-adapter.json' produced no content — symlinks and other non-regular tar entries are not followed`); + } + if (Buffer.byteLength(stdout, 'utf8') > maxBytes) { + throw sourceError('source-too-large', `ak-adapter.json for '${spec}' exceeds ${maxBytes} bytes`); + } + + try { + /** @type {'npm'} */ + const origin = 'npm'; + return { raw: JSON.parse(stdout), origin }; + } catch (error) { + throw sourceError('source-invalid-json', error?.message ?? String(error)); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** + * Resolve an adapter manifest `source` string into raw parsed JSON. + * Dispatches on prefix: `https://...`, `npm:[@version|tag]`, or (no + * recognized prefix) a local file path. Throws SourceError with a named + * `.reason` on any failure — never returns partial or best-effort content. + * + * @param {string} source + * @param {{fetchFn?:typeof fetch, execFileFn?:(cmd:string,args:string[],opts:any)=>Promise<{stdout:string,stderr:string}>, + * resolveShimFn?:(cmd:string,args:string[])=>{command:string,args:string[],resolved:boolean}, + * timeoutMs?:number, maxBytes?:number, tmpDir?:string}} [options] + * @returns {Promise<{raw:any, origin:'file'|'url'|'npm'}>} + */ +export async function resolveManifestSource(source, { + fetchFn = globalThis.fetch, + execFileFn = defaultExecFile, + resolveShimFn = resolveShim, + timeoutMs = DEFAULT_TIMEOUT_MS, + maxBytes = DEFAULT_MAX_BYTES, + tmpDir, +} = {}) { + if (typeof source !== 'string' || !source) { + throw sourceError('source-invalid', 'source must be a non-empty string'); + } + if (source.startsWith('https://')) { + return resolveHttpsSource(source, { fetchFn, timeoutMs, maxBytes }); + } + if (source.startsWith('http://')) { + throw sourceError('source-insecure', 'http:// sources are not permitted for adapter manifests — use https://'); + } + if (source.startsWith('npm:')) { + return resolveNpmSource(source.slice('npm:'.length), { + execFileFn, timeoutMs, maxBytes, tmpDir, resolveShimFn, + }); + } + return resolveFileSource(source, maxBytes); +} diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs index 27e9bb9..3afde5e 100644 --- a/src/lib/execution/adapters.mjs +++ b/src/lib/execution/adapters.mjs @@ -4,6 +4,7 @@ import { OPENCODE_EXECUTION_ADAPTER } from './opencode.mjs'; import { CLAUDE_EXECUTION_ADAPTER } from './claude.mjs'; import { CODEX_EXECUTION_ADAPTER } from './codex.mjs'; import { routableHostIds } from '../adapters/index.mjs'; +import { admittedExecutionAdapterFor } from './admitted.mjs'; export const EXECUTION_ADAPTERS = Object.freeze(new Map([ ['claude', CLAUDE_EXECUTION_ADAPTER], @@ -43,11 +44,10 @@ assertBuiltinAdaptersRoutable(); * single worker instead of failing an entire run. */ export function executionAdapterFor(hostId) { if (EXECUTION_ADAPTERS.has(hostId)) return EXECUTION_ADAPTERS.get(hostId); - // Wave 4 (adapter door): an admitted external host whose manifest declares - // driving.surfaces including 'cli-subprocess' MAY, in a later wave, get a - // constructed execution adapter here. This wave builds none: admitted - // hosts have no execution adapter yet, full stop, so they fall through to - // the same null return (and the runner's existing cli_unavailable - // degradation) as any other routable-but-unadapted host. - return null; + // P2 (ADR-0031): an admitted external host whose manifest declared an + // execution block gets its adapter derived and registered at bootstrap + // (admission.mjs) into execution/admitted.mjs's overlay. A routable host + // with no execution block (or nothing admitted at all) still returns null + // here — the runner's existing cli_unavailable degradation, unchanged. + return admittedExecutionAdapterFor(hostId); } diff --git a/src/lib/execution/admitted.mjs b/src/lib/execution/admitted.mjs new file mode 100644 index 0000000..0854f4a --- /dev/null +++ b/src/lib/execution/admitted.mjs @@ -0,0 +1,366 @@ +// Derived execution adapter for an ADMITTED external host (P2, ADR-0031). +// This is the ONLY place an admitted manifest's execution.run.hook ever runs: +// a single-shot supervised subprocess through hook-runner.mjs — no in-process +// third-party code, ever. Deliberately simpler than opencode.mjs: one spawn, +// no server lifecycle, no streaming — launch() completes when the child exits. +import path from 'node:path'; +import { runAdapterHook } from '../adapters/hook-runner.mjs'; +import { resetAdmitted } from '../adapters/admitted.mjs'; +import { have } from '../exec.mjs'; +import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs'; +import { redactHandoffData } from './handoff.mjs'; + +const DEFAULT_TIMEOUT_MS = 120_000; +// hook-runner's own inner timeout is what actually kills the child; the +// runner's outer phase deadline must never race it. Both timers would be +// registered for the same duration if we handed hook-runner the raw +// remaining budget, but hook-runner's timer starts a beat later (spawn +// overhead) — so it would lose that race. Shaving a small margin off what we +// hand the hook keeps the inner kill strictly first. +const LAUNCH_TIMEOUT_MARGIN_MS = 250; +const REASON_MAX_BYTES = 240; +const PROVIDER_MAX_CHARS = 64; +// Wave B security review (F-6): reserved hook exit codes give an admitted +// host an honest consent/auth boundary instead of a silent re-run — part of +// the adapter contract, alongside stdin/env/exit-0 in the module header. +// 77 -> the hook needs interactive/out-of-band consent it does not have +// (mirrors sysexits.h EX_NOPERM in spirit): status 'blocked', +// exitCategory 'permission_required' — already non-escalating +// (runner.mjs's BLOCKING_CATEGORIES). +// 78 -> the hook needs authentication it does not have: status 'failed', +// exitCategory 'auth_required'. +const EXIT_PERMISSION_REQUIRED = 77; +const EXIT_AUTH_REQUIRED = 78; + +const nowIso = () => new Date().toISOString(); + +function boundedReason(text, maxBytes = REASON_MAX_BYTES) { + const value = typeof text === 'string' ? text : String(text ?? ''); + return value.length > maxBytes ? `${value.slice(0, maxBytes - 1)}…` : value; +} + +function execError(reason, message) { + return Object.assign(new Error(message), { reason }); +} + +// F-1: a relative 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() when `ak run` was invoked — 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. +// 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; + if (path.isAbsolute(token)) return false; + if (token.includes('/') || token.includes('\\')) return true; + return SCRIPT_LIKE_RE.test(token); +} + +// R-3: EVERY arg is inspected, not just non-flag ones — a `--flag=value` +// token starts with '-' but its value half can still be a relative path +// (`--import=./evil.mjs`), and looksRelative's own `.includes('/')` check +// already catches that once the whole token is examined (no separate '=' +// split needed: the token as a whole still contains '/'). Skipping +// '-'-prefixed args entirely (the pre-R-3 shape) let exactly this slip past +// with a null baseDir. A bare flag with no path-like value (`--verbose`, +// `-v`) is unaffected — it matches neither check and stays legal. +function commandIsUnanchorable(command) { + const [argv0, ...args] = command; + if (looksRelative(argv0)) return true; + return args.some((arg) => looksRelative(arg)); +} + +/** stdout is EITHER a JSON object with optional {summary, observedModel, + * provider, usage} — read when the whole trimmed `stdoutText` (R-1: the + * UNMERGED stdout hook-runner reports; never `stdout`, which folds stderr + * in after a separator and would break JSON.parse the instant the hook + * writes anything to stderr at all) parses as a JSON object — OR plain + * text, treated as the summary capture (ADR-0029 §2). + * F-4: `stderrText` gates ONLY the plain-text path — a hook that wrote to + * stderr (warnings, a crash trace, interpreter noise) never has that text + * silently promoted into the cross-vendor dependency handoff. A genuine + * JSON payload parses and is trusted REGARDLESS of stderr (R-1: a stray + * deprecation warning must not blank out an otherwise-valid summary and + * cascade into "required worker handoff was missing" for every dependent). + * F-7: a self-declared `provider` is bounded (a payload cannot claim an + * unbounded-length vendor name). */ +function parseStdout(stdoutText, stderrText) { + const trimmed = typeof stdoutText === 'string' ? stdoutText.trim() : ''; + const hasStderr = typeof stderrText === 'string' && stderrText.trim() !== ''; + if (!trimmed) return { summary: null, observedModel: null, provider: null, usage: null }; + let parsed; + try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + summary: typeof parsed.summary === 'string' ? parsed.summary : null, + observedModel: typeof parsed.observedModel === 'string' ? parsed.observedModel : null, + provider: typeof parsed.provider === 'string' ? parsed.provider.slice(0, PROVIDER_MAX_CHARS) : null, + usage: parsed.usage && typeof parsed.usage === 'object' && !Array.isArray(parsed.usage) ? parsed.usage : null, + }; + } + if (hasStderr) return { summary: null, observedModel: null, provider: null, usage: null }; + return { summary: trimmed, observedModel: null, provider: null, usage: null }; +} + +/** + * Build a host-neutral execution adapter for one admitted manifest declaring + * `execution.run.hook`. `runHook`/`haveFn`/`clock` are injectable for tests; + * production defaults spawn the real subprocess. `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(). + */ +export function buildAdmittedExecutionAdapter(manifest, { + runHook = runAdapterHook, haveFn = have, clock = nowIso, baseDir = null, +} = {}) { + if (!manifest || typeof manifest !== 'object') throw new TypeError('buildAdmittedExecutionAdapter requires a manifest'); + const hostId = manifest.host?.id; + if (typeof hostId !== 'string' || !hostId) throw new TypeError('buildAdmittedExecutionAdapter requires manifest.host.id'); + // F-8 (defence-in-depth, INEXPRESSIBLE-not-refused doctrine): the manifest + // schema already couples execution -> canRouteActivities (manifest.mjs's + // 'execution-not-routable'), but this is the construction site that + // actually wires a subprocess spawn — it re-asserts the invariant itself + // rather than trusting every caller to have validated upstream. + if (manifest.host?.capabilities?.canRouteActivities !== true) { + throw execError('not-routable', `'${hostId}' execution adapter requires host.capabilities.canRouteActivities:true`); + } + // F-5 (ADR-0029 §2): a manifest that never declared the cli-subprocess + // driving surface gets no cli-subprocess execution adapter — refused, not + // silently downgraded. Re-checked here even though the bootstrap filter + // (admission.mjs) already screens candidates, so no other caller can skip it. + if (!Array.isArray(manifest.driving?.surfaces) || !manifest.driving.surfaces.includes('cli-subprocess')) { + throw execError('surface-unsupported', `'${hostId}' execution adapter requires driving.surfaces to include 'cli-subprocess'`); + } + const hook = manifest.execution?.run?.hook; + if (!hook || !Array.isArray(hook.command) || hook.command.length === 0) { + throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.execution.run.hook for '${hostId}'`); + } + const detectionBin = manifest.detection?.bin; + if (typeof detectionBin !== 'string' || !detectionBin) { + throw new TypeError(`buildAdmittedExecutionAdapter requires manifest.detection.bin for '${hostId}'`); + } + if (baseDir == null && commandIsUnanchorable(hook.command)) { + throw execError('execution-unanchored', + `'${hostId}' declares a relative execution.run.hook.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'); + } + + function terminalResult(state, base) { + return validateWorkerResult({ + workerId: state.worker.id, activity: state.worker.activity, role: state.worker.role, host: hostId, + startedAt: state.startedAt, endedAt: clock(), + durationMs: Math.max(0, Date.parse(clock()) - Date.parse(state.startedAt)), + provider: null, providerProvenance: 'unknown', configuredModel: state.worker.configuredModel ?? null, + observedModel: null, sessionId: null, transcriptRefs: [], failure: null, usage: null, + ...base, + }); + } + + const adapter = { + id: `${hostId}-adapter`, + + async readiness({ signal, timeoutMs } = /** @type {{signal?:AbortSignal,timeoutMs?:number}} */ ({})) { + signal?.throwIfAborted?.(); + const ready = await haveFn(detectionBin, { signal, timeout: timeoutMs }); + signal?.throwIfAborted?.(); + return ready ? { ready: true } : { ready: false, exitCategory: 'cli_unavailable' }; + }, + + async prepare({ worker, cwd = process.cwd() } = /** @type {{worker?:any,cwd?:string}} */ ({})) { + if (!worker || worker.host !== hostId) throw new TypeError(`${hostId} adapter requires a ${hostId} worker`); + // R-2: state.cwd doubles as the fallback spawn cwd (below) AND rides + // in AK_WORKER_CWD — runAdapterHook itself throws on a non-absolute + // cwd, so this assertion (mirroring opencode.mjs's own worker-cwd + // check) is what makes that downstream guarantee hold, not an + // incidental duplicate of it. + if (!path.isAbsolute(cwd)) throw new TypeError(`${hostId} worker cwd must be absolute`); + return { worker, cwd, prompt: worker.prompt, startedAt: clock() }; + }, + + async launch(state, { timeoutMs, signal } = /** @type {{timeoutMs?:number,signal?:AbortSignal}} */ ({})) { + signal?.throwIfAborted?.(); + const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + const innerTimeoutMs = Math.max(1, budget - LAUNCH_TIMEOUT_MARGIN_MS); + const env = { + AK_WORKER_ID: state.worker.id, + AK_WORKER_ACTIVITY: state.worker.activity, + AK_WORKER_ROLE: state.worker.role, + AK_WORKER_MODEL: state.worker.configuredModel ?? '', + // R-2: F-1's cwd pin (below) took away the hook's only implicit + // channel for learning which repo it's working on — the spawn cwd is + // now the adapter's own baseDir, not the caller's. Told explicitly + // instead, so a hook that needs the target repo can still find it + // without reopening F-1 by spawning there. + AK_WORKER_CWD: state.cwd, + }; + state.hookResult = await runHook({ + hook, hostId, verb: 'run', timeoutMs: innerTimeoutMs, env, stdin: state.prompt, + // R-2: the spawn cwd is uniform and explicit, never Node's own + // "inherit ak's process.cwd()" default (runAdapterHook's own + // fallback for an omitted cwd) — baseDir anchors a relative script + // to the adapter's own directory when one was declared (F-1); + // otherwise the construction-time check above already proved the + // command has no relative component that a cwd could redirect, so + // falling back to the repo cwd (state.cwd) here is safe (only bare + // PATH binaries reach this branch) and gives the hook a normal + // "run me from the target repo" default. + cwd: baseDir ?? state.cwd, + }); + return state; + }, + + async observe(state) { + const result = state.hookResult; + if (!result) throw new Error(`${hostId} adapter observed before launch completed`); + return { + type: 'exit', ok: result.ok, stdout: result.stdout, + // R-1: stdoutText is the UNMERGED stdout — see parseStdout's header + // comment for why the payload must never be parsed from `stdout`. + stdoutText: result.stdoutText ?? '', + exitCode: result.exitCode, detail: result.detail, + stderrText: result.stderrText ?? result.stderr ?? '', + }; + }, + + interpret(state, observation) { + // Runner-injected terminal events (outer phase deadline aborting + // launch/observe before our own {type:'exit'} observation lands, or an + // unexpected cancel/cleanup failure). See LAUNCH_TIMEOUT_MARGIN_MS above + // for why the 'exit' path is the expected one in practice. + if (observation?.type === 'timeout') { + return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(observation.reason ?? 'timeout') } }); + } + if (observation?.type === 'orphaned') { + return terminalResult(state, { status: 'failed', exitCategory: 'orphaned', failure: { reason: 'admitted host subprocess did not terminate' } }); + } + if (observation?.type !== 'exit') { + return terminalResult(state, { status: 'failed', exitCategory: 'protocol_error', failure: { reason: 'unrecognized observation from admitted host adapter' } }); + } + + const { + ok, exitCode, stdout, stdoutText, detail, stderrText, + } = observation; + if (ok && exitCode === 0) { + const parsed = parseStdout(stdoutText, stderrText); + return terminalResult(state, { + status: 'succeeded', exitCategory: 'success', failure: null, + observedModel: parsed.observedModel, + provider: parsed.provider, + // F-7: a payload's self-declared provider is NEVER 'observed' — the + // hook asserted it, ak didn't verify it against anything. + providerProvenance: parsed.provider ? 'inferred' : 'unknown', + usage: parsed.usage, + }); + } + // F-6: reserved hook exit codes (see module header) — checked ahead of + // the generic non-zero-exit fallback below. + if (exitCode === EXIT_PERMISSION_REQUIRED) { + return terminalResult(state, { + status: 'blocked', exitCategory: 'permission_required', + failure: { reason: boundedReason(detail ?? 'adapter hook requires consent it does not have (exit 77)') }, + }); + } + if (exitCode === EXIT_AUTH_REQUIRED) { + return terminalResult(state, { + status: 'failed', exitCategory: 'auth_required', + failure: { reason: boundedReason(detail ?? 'adapter hook requires authentication it does not have (exit 78)') }, + }); + } + if (exitCode === null && typeof detail === 'string' && /timed out/i.test(detail)) { + return terminalResult(state, { status: 'timed_out', exitCategory: 'timeout', failure: { reason: boundedReason(detail) } }); + } + if (exitCode === null) { + return terminalResult(state, { status: 'failed', exitCategory: 'cli_unavailable', failure: { reason: boundedReason(detail ?? 'adapter hook failed to start') } }); + } + // F-3: the stdout tail folded into a worker_error reason is untrusted + // hook output — it may itself contain a `` block (or + // other private-protocol payload) that must never leak through a + // public WorkerResult's failure.reason. Redact before bounding, same + // as subprocess.mjs's failureFor. + const tail = typeof stdout === 'string' ? stdout.trim().slice(-200) : ''; + const raw = tail ? `${detail} — ${tail}` : (detail ?? `adapter hook exited with code ${exitCode}`); + return terminalResult(state, { status: 'failed', exitCategory: 'worker_error', failure: { reason: boundedReason(redactHandoffData(raw)) } }); + }, + + // The hook script's own protocol is simpler than the tagged-block one + // LLM-driven built-in hosts use: a JSON payload's `summary` field (or + // plain-text stdout, when stderr is empty) IS the outcome, verbatim — no + // `` parsing. Wrap it into normalizeHandoff's exact + // accepted shape rather than returning the bare `{summary}` it was read + // from. + summarize(_state, observation) { + if (observation?.type !== 'exit' || !observation.ok || observation.exitCode !== 0) return null; + const { summary } = parseStdout(observation.stdoutText, observation.stderrText); + if (!summary) return null; + return { outcome: summary, artifacts: [], decisions: [], risks: [] }; + }, + + // hook-runner owns the kill (group SIGKILL on its own inner timeout); + // launch() only resolves once the subprocess has already exited, so by + // the time cancel/cleanup can run there is normally no live resource + // left to terminate. The one exception (F-2): if the runner's OUTER + // phase deadline fires WHILE launch() is still pending — state.hookResult + // never got set — the subprocess may still be alive and its termination + // is unproven. Reporting plain 'cancelled' there would let the runner's + // escalation ladder treat this as an ordinary escalatable timed_out and + // fire a SECOND attempt against a worker that might still be running. + // 'orphaned' is honest (uncertain, non-escalating) and matches + // runner.mjs's own BLOCKING_CATEGORIES. + async cancel(state) { + if (!state?.hookResult) return { type: 'cancelled', orphaned: true }; + return { type: 'cancelled' }; + }, + async cleanup() { return { cleaned: true }; }, + }; + + return validateExecutionAdapter(adapter); +} + +// ── overlay registry (mirrors adapters/admitted.mjs's discipline) ────────── +// Module-level Map, not the built-in EXECUTION_ADAPTERS Map: an admitted +// external host's adapter is DERIVED from its manifest at bootstrap time, not +// hand-authored in-tree. A second register for the same host id replaces the +// first (re-admission, or a test re-registering); production populates this +// only from bootstrapHostAdapters (admission.mjs). +let admittedExecutionAdapters = new Map(); + +/** Build and register an execution adapter for an admitted manifest. Returns + * the built adapter. Throws (uncaught) on a manifest with no execution + * block or a malformed one — callers (bootstrap) are expected to guard this + * per-adapter and treat a throw as a non-fatal warning, not a crash. */ +export function registerAdmittedExecution(manifest, options) { + const adapter = buildAdmittedExecutionAdapter(manifest, options); + admittedExecutionAdapters.set(manifest.host.id, adapter); + return adapter; +} + +/** Test-only reset back to the unregistered, built-ins-only state. */ +export function resetAdmittedExecution() { + admittedExecutionAdapters = new Map(); +} + +/** One admitted host's execution adapter, or null when none is registered — + * never throws, mirroring executionAdapterFor's degrade-one-worker posture. */ +export function admittedExecutionAdapterFor(hostId) { + return admittedExecutionAdapters.get(hostId) ?? null; +} + +// F-9: the host overlay (adapters/admitted.mjs) and this execution overlay +// are two independently-mutable module singletons that a caller could reset +// out of step (e.g. a test resetting only one), leaving the other stale — an +// admitted-but-execution-orphaned or execution-registered-but-unadmitted +// state neither overlay's own reset guards against alone. Pairing them here +// (rather than reaching into adapters/admitted.mjs to add an upward +// dependency on this module) keeps that file untouched. +export function resetAllAdmitted() { + resetAdmittedExecution(); + resetAdmitted(); +} diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index ee86490..8d6bc5c 100644 --- a/src/lib/hosts.mjs +++ b/src/lib/hosts.mjs @@ -23,7 +23,7 @@ // ~/.codex/auth.json (key overrides login). claude auth on macOS lives in the // Keychain (no readable file); ANTHROPIC_API_KEY, when used, is not a simple // override of a subscription login, so we label it conservatively. -import { HOST_REGISTRY, effectiveHostRegistry } from './adapters/index.mjs'; +import { HOST_REGISTRY, effectiveHostRegistry, effectivePrimaryHostIds } from './adapters/index.mjs'; /** Per-host adapter descriptors. Logical names (`guidanceFile`, `loginFile` * segments) are resolved to real paths by callers so this stays pure. */ @@ -53,6 +53,11 @@ export function adapterFor(id) { * (heuristic) → configured primary (kit.json routing.primaryHost) → 'claude'. * Pure: reads only env + the passed cfg; never spawns. * + * Always returns a host id HOST_ADAPTERS/adapterFor can resolve (a built-in, + * canDriveSession host) — never an id that is merely eligible per + * effectivePrimaryHostIds() (below) but that this module has no session- + * driving descriptor for. + * * @param {NodeJS.ProcessEnv} [env] * @param {any} [cfg] kit.json config (for routing.primaryHost) * @returns {'claude'|'codex'|'opencode'} @@ -65,8 +70,27 @@ export function drivingHost(env = process.env, cfg = null) { // heuristic because the fallback below covers the miss. if (Object.keys(env).some((k) => k.startsWith('CODEX_'))) return 'codex'; const primary = cfg?.routing?.primaryHost; - const primaryCapable = HOST_REGISTRY.some((host) => host.id === primary && host.capabilities.canBePrimary); - if (primary && primaryCapable) return /** @type {'claude'|'codex'} */ (primary); + // effectivePrimaryHostIds() reads the EFFECTIVE (built-in + grant-overlaid + // admitted) set for ELIGIBILITY, not HOST_REGISTRY directly (ADR-0031 §1) — + // that primitive is live, so a hand-edited kit.json pointing + // routing.primaryHost at a host that has since earned a canBePrimary grant + // is recognized as eligible here. (No production path drives kit.json's + // primaryHost to an admitted external id today — `ak host pick`'s + // selection menu stays built-in-scoped this wave — so this is a live + // primitive with no live caller yet, not an active privilege boundary.) + // + // Eligibility alone is not enough to RETURN the host, though: HOST_ADAPTERS + // (built from HOST_REGISTRY, filtered by canDriveSession) is what this + // module actually knows how to drive a session for — guidanceFile, + // statusline, envMarkers, auth — and stays built-in-only; no admitted + // external host has that wiring registered. Without the adapterFor(primary) + // guard, an eligible-but-unresolvable host would be returned here and a + // caller doing `adapterFor(drivingHost(...)).guidanceFile` would crash on + // null. Both checks must agree, so this function's contract (always a + // HOST_ADAPTERS-resolvable id) holds even once effectivePrimaryHostIds() + // can name a host HOST_ADAPTERS doesn't. + const primaryCapable = effectivePrimaryHostIds().includes(primary); + if (primary && primaryCapable && adapterFor(primary)) return /** @type {'claude'|'codex'} */ (primary); return 'claude'; } diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index 2f95f8b..6b91a5b 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -6,7 +6,9 @@ // (no I/O) so the projectors and defaults are unit-testable in isolation; the // writers/UX that consume it live in providers.mjs / the commands. import { vendorOf } from './qeCourt.mjs'; -import { routableHostIds, primaryHostIds, validateActivityHost } from './adapters/index.mjs'; +import { + routableHostIds, primaryHostIds, validateActivityHost, effectiveHostRegistry, effectiveRoutableHostIds, +} from './adapters/index.mjs'; // ── Vocabulary ─────────────────────────────────────────────────────────────── // Canonical development activities ak routes (ADR-0002). Array order = display order. @@ -21,8 +23,14 @@ export const AK_ORIGINATED = new Set(['packaging', 'release']); // Host → aqe/router provider type. OpenCode deliberately has no entry: its // execution provider is observed per worker and must never be inferred from the -// host or silently projected into AQE's separate provider vocabulary. +// host or silently projected into AQE's separate provider vocabulary. An +// admitted external host (P2, ADR-0031) gets no entry either, same reasoning. export const HOST_PROVIDER = { claude: 'claude-code', codex: 'codex' }; +// Frozen at import time — built-ins only. Display strings and built-in +// listings ONLY (formatModelHelp, model catalogs below): every VALIDATION +// path (isRoutableHost, validateRoute, materializeRunPlan) consults the lazy +// effectiveRoutableHostIds()/effectiveHostRegistry() instead, so an admitted +// external host routes without this constant ever needing to change. export const HOSTS = routableHostIds(); // Providers aqe's ProviderManager can construct — grounded in agentic-qe 3.13.1 @@ -164,6 +172,20 @@ export function formatModelHelp() { // reasoning roles). When codex is chosen as PRIMARY, we mirror each default route // to the opposite host so codex takes the lead and claude becomes the alternate — // a defaults/policy change only (DualModeOrchestrator workers are symmetric). +// Frozen at import time — built-ins only, deliberately (audited for the D2 +// keystone wave, ADR-0031 §1). Every current reader of PRIMARY_HOSTS +// (providers.mjs's applySetupHostFlags `--primary-host` validation, and +// x/host.mjs's `ak host pick` primary-host flag + selection menu) is the +// primary-host SELECTION UX, not an eligibility-VALIDATION path — extending +// that picker surface to an admitted external host is explicitly out of +// scope this wave (the deferred pick surface), mirroring how HOSTS above +// stays display-only. hosts.mjs's drivingHost() does NOT use this constant — +// it consults admitted.mjs's effectivePrimaryHostIds() (fresh per call) +// instead, so the eligibility PRIMITIVE is live there. That said, no +// production path drives kit.json's routing.primaryHost to an admitted +// external id today (the picker that writes it stays built-in-only, as +// above), so this is a live primitive with no live privileged caller yet — +// not an active validation gate anything currently depends on. export const PRIMARY_HOSTS = primaryHostIds(); export const DEFAULT_PRIMARY_HOST = 'claude'; @@ -234,9 +256,12 @@ export const AGENT_ACTIVITY_MAP = { // ── Policy resolution + projections (pure) ────────────────────────────────── -/** True when both frontier hosts appear in a route (a route's host is valid). */ +/** True when `host` is routable: a built-in, or an admitted external host + * whose manifest declared capabilities.canRouteActivities (P2, ADR-0031). + * Lazy — re-reads the effective registry on every call, so it reflects an + * overlay applied after this module first loaded. */ export function isRoutableHost(host) { - return HOSTS.includes(host); + return effectiveRoutableHostIds().includes(host); } /** Substitute a retired model for its replacement, recording what was swapped. @@ -576,11 +601,15 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = ' const nodes = RUN_TEMPLATES[template]; if (!nodes) throw new Error(`unknown template "${template}" (expected: ${RUN_TEMPLATE_NAMES.join(', ')})`); const routes = resolveRoutes(policy); + // Snapshot once per materialization (not per validateActivityHost call): an + // admitted overlay applied mid-call must not be able to make one worker's + // eligibility check see a different registry than another's in the same plan. + const hosts = effectiveHostRegistry(); return { template, workers: nodes.map((n) => { const r = routes[n.activity]; - const eligibility = validateActivityHost(r.host); + const eligibility = validateActivityHost(r.host, hosts); if (!eligibility.ok) { throw new Error(`route for "${n.activity}" cannot materialize: host "${r.host}" requires canRouteActivities`); } @@ -591,7 +620,7 @@ export function materializeRunPlan(policy = {}, { template = 'feature', task = ' const ladder = (r.escalation ?? []) .filter((rung) => rung && (rung.host !== r.host || (rung.model ?? null) !== (r.model ?? null))) .map((rung) => { - const rungEligibility = validateActivityHost(rung.host); + const rungEligibility = validateActivityHost(rung.host, hosts); if (!rungEligibility.ok) { throw new Error(`escalation rung for "${n.activity}" cannot materialize: host "${rung.host}" requires canRouteActivities`); } @@ -647,7 +676,7 @@ export function routingSummary(policy = {}) { export function validateRoute(route = {}) { const { host, model } = route; const errs = []; - if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${HOSTS.join('|')})`); + if (!isRoutableHost(host)) errs.push(`unknown host "${host}" (expected: ${effectiveRoutableHostIds().join('|')})`); else if (HOST_PROVIDER[host] && !AQE_CONSTRUCTIBLE_PROVIDERS.includes(HOST_PROVIDER[host])) errs.push(`host "${host}" maps to a non-constructible provider`); if (model != null && (typeof model !== 'string' || model.trim() === '')) errs.push('model must be a non-empty string'); return errs; diff --git a/tests/fixtures/adapters/acme/manifest.json b/tests/fixtures/adapters/acme/manifest.json index d2919a6..98bd98b 100644 --- a/tests/fixtures/adapters/acme/manifest.json +++ b/tests/fixtures/adapters/acme/manifest.json @@ -9,7 +9,7 @@ "capabilities": { "canDriveSession": false, "canBePrimary": false, - "canRouteActivities": false, + "canRouteActivities": true, "commandStatusline": false, "transcripts": false, "usage": false, @@ -32,6 +32,11 @@ "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } } }, + "execution": { + "run": { + "hook": { "command": ["node", "run-hook.mjs"], "timeoutMs": 5000 } + } + }, "trust": { "changes": [ { diff --git a/tests/fixtures/adapters/acme/run-hook.mjs b/tests/fixtures/adapters/acme/run-hook.mjs new file mode 100644 index 0000000..31e0b83 --- /dev/null +++ b/tests/fixtures/adapters/acme/run-hook.mjs @@ -0,0 +1,28 @@ +// Fixture execution hook for the "acme" conformance adapter (P2, ADR-0031). +// A real, standalone subprocess — no dependencies, no network, no filesystem +// writes — invoked exactly as an admitted external adapter's declared +// execution.run.hook would be, through the real runAdapterHook. It reads the +// worker prompt from stdin (proving the stdin wiring) and echoes a JSON +// result whose `summary` names the AK_WORKER_* metadata env vars it received +// (proving the env wiring), matching the {summary, observedModel, provider} +// shape buildAdmittedExecutionAdapter (execution/admitted.mjs) parses. +// +// ACME_RUN_HOOK_FAIL=1 makes the hook fail deliberately, for a negative test +// of the worker_error mapping path. +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + if (process.env.ACME_RUN_HOOK_FAIL === '1') { + process.stderr.write('acme fixture run hook: deliberate failure\n'); + process.exit(3); + return; + } + process.stdout.write(JSON.stringify({ + summary: `acme ran worker=${process.env.AK_WORKER_ID ?? ''} activity=${process.env.AK_WORKER_ACTIVITY ?? ''} ` + + `role=${process.env.AK_WORKER_ROLE ?? ''} model=${process.env.AK_WORKER_MODEL ?? ''} ` + + `promptBytes=${Buffer.byteLength(input, 'utf8')}`, + observedModel: process.env.AK_WORKER_MODEL || null, + provider: 'acme', + })); +}); diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs index f318ee7..99c8fd1 100644 --- a/tests/kit/adapter-admission.test.mjs +++ b/tests/kit/adapter-admission.test.mjs @@ -7,12 +7,16 @@ // 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'; import { applyAdmitted, resetAdmitted, admittedHostIds, effectiveHostRegistry, } from '../../src/lib/adapters/admitted.mjs'; +import { recordTierResult, grantCapability } from '../../src/lib/adapters/grants.mjs'; import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; import { validateLifecycleAdapter } from '../../src/lib/adapters/lifecycle.mjs'; import { @@ -236,6 +240,93 @@ test('flag-on with a trusted entry admits it and applies the overlay', async () assert.equal(effectiveHostRegistry().length, HOST_REGISTRY.length + 1); }); +// ── D2 keystone: bootstrapHostAdapters wires REAL grants.mjs grants into the +// overlay, live (ADR-0031 §1). Unlike admitted-grants.test.mjs (which injects +// grantsByName directly to test applyAdmitted's own guarantee in isolation), +// these two exercise the actual bootstrap -> grantedCapabilitiesFor(name, +// freshly-computed hash) -> applyAdmitted wiring end to end, against the +// REAL grants.mjs file store — redirected via XDG_CONFIG_HOME to a throwaway +// directory so they never touch the developer's real +// ~/.config/agentic-kit/adapter-grants.json. + +test('bootstrapHostAdapters: a real, currently-hashed grant for primary-eligible makes canBePrimary live in effectiveHostRegistry()', async (t) => { + const prevXdg = process.env.XDG_CONFIG_HOME; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-live-')); + process.env.XDG_CONFIG_HOME = dir; + t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + + const name = 'hermes-grant-live'; + const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); + const hash = hashManifest(manifest); + recordTierResult(name, 'primary-eligible', { hash, evidence: 'harness-driven lead + escalation, real WorkerResult trail' }); + grantCapability(name, 'canBePrimary', { hash }); + + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-live' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + const entry = effectiveHostRegistry().find((h) => h.id === name); + assert.ok(entry, 'the admitted host must be in the effective registry'); + assert.equal(entry.capabilities.canBePrimary, true, 'the real, currently-hashed grant must be live'); +}); + +test('bootstrapHostAdapters: a grant recorded against a STALE manifest hash never lights up (edit-invalidation)', async (t) => { + const prevXdg = process.env.XDG_CONFIG_HOME; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-stale-')); + process.env.XDG_CONFIG_HOME = dir; + t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + + const name = 'hermes-grant-stale'; + const oldManifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); + const oldHash = hashManifest(oldManifest); + recordTierResult(name, 'primary-eligible', { hash: oldHash, evidence: 'led a run, escalated to' }); + grantCapability(name, 'canBePrimary', { hash: oldHash }); + + // The manifest changes underneath the grant (a real edit — a bumped + // version string) WITHOUT the tier being re-earned/re-granted at the new + // hash. Bootstrap must compute the hash of the manifest it just admitted + // (newHash), never reuse oldHash — so grantedCapabilitiesFor sees a + // mismatch and returns {}. + const newManifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }), version: '1.0.1' })); + const newHash = hashManifest(newManifest); + assert.notEqual(oldHash, newHash, 'sanity: the edit must actually change the hash'); + + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-stale' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => newManifest, + consent: trustingConsent({ [name]: newHash }), + }); + assert.equal(result.admitted.length, 1); + const entry = effectiveHostRegistry().find((h) => h.id === name); + assert.equal(entry.capabilities.canBePrimary, false, 'a grant pinned to the OLD hash must not apply to the new manifest'); +}); + +test('bootstrapHostAdapters: with no grant recorded at all, an admitted host stays at the manifest floor (canBePrimary false)', async (t) => { + const prevXdg = process.env.XDG_CONFIG_HOME; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-d2-grant-none-')); + process.env.XDG_CONFIG_HOME = dir; + t.after(() => { process.env.XDG_CONFIG_HOME = prevXdg; fs.rmSync(dir, { recursive: true, force: true }); }); + + const name = 'hermes-grant-none'; + const manifest = validateAdapterManifest(validManifest({ name, host: validHost({ id: name }) })); + const hash = hashManifest(manifest); + + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name, source: 'mem://hermes-grant-none' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: trustingConsent({ [name]: hash }), + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, []); + const entry = effectiveHostRegistry().find((h) => h.id === name); + assert.equal(entry.capabilities.canBePrimary, false); +}); + // ── overlay ────────────────────────────────────────────────────────────── test('effectiveHostRegistry returns the built-in registry (same reference) when nothing is admitted', () => { @@ -285,7 +376,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 +408,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 +461,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/adapter-conformance.test.mjs b/tests/kit/adapter-conformance.test.mjs index 9483396..c64b8b0 100644 --- a/tests/kit/adapter-conformance.test.mjs +++ b/tests/kit/adapter-conformance.test.mjs @@ -28,6 +28,8 @@ import { recordConsent, recordedHashFor, isTrusted, revokeConsent, } from '../../src/lib/adapters/consent.mjs'; import { registerAdmittedLifecycle } from '../../src/lib/adapters/lifecycle-registry.mjs'; +import { registerAdmittedExecution, resetAdmittedExecution } from '../../src/lib/execution/admitted.mjs'; +import { executeRunPlan } from '../../src/lib/execution/runner.mjs'; // Resolved relative to THIS file via fileURLToPath, never a hardcoded // repo-absolute or monorepo-sibling path (ruflo #2912 counter-example) — so @@ -42,28 +44,42 @@ const NEGATIVE_CORPUS = [ ]; /** - * The manifest contract has no path-resolution policy of its own yet - * (hook.command is just "a non-empty array of non-empty strings" — - * manifest.mjs never inspects the values). A real installed adapter would - * need SOME resolution step to turn a portable manifest's declared command + * The manifest contract has no path-resolution policy of its own for + * LIFECYCLE hooks (hook.command is just "a non-empty array of non-empty + * strings" — manifest.mjs never inspects the values, and lifecycle-registry.mjs + * spawns with no `cwd` anchoring). A real installed adapter would need SOME + * resolution step to turn a portable manifest's declared lifecycle command * into a locally-runnable one; that step doesn't exist in src/ today, so - * this is a minimal, test-owned stand-in: the literal token 'node' becomes - * process.execPath (never a bare PATH-searched 'node' — the same portability - * concern the hook-runner tests already document), and a relative *.mjs - * argument is resolved against the fixture's own directory (where the - * committed hook script actually lives), not the CWD. + * this remains a minimal, test-owned stand-in for lifecycle hooks ONLY: the + * literal token 'node' becomes process.execPath, and a relative *.mjs + * argument is resolved against the fixture's own directory. + * + * The EXECUTION hook (`execution.run.hook`) needs no such rewriting — Wave B + * security review (F-1) gave admission.mjs a real baseDir-derivation + + * cwd-anchoring path (registerAdmittedExecution -> buildAdmittedExecutionAdapter + * -> runAdapterHook's `cwd` option), so the fixture's literal, UNREWRITTEN + * `["node", "run-hook.mjs"]` resolves correctly through the real resolver: + * 'node' via PATH, 'run-hook.mjs' relative to the fixture's own directory + * (this manifest's `source` is a file path, so baseDir = FIXTURE_ROOT). A + * test-side rewrite here would prove a safer-than-production path, not the + * real one — see the negative-corpus/unanchored tests in adapter-execution.test.mjs + * for what happens when there is no baseDir to anchor to. */ +function resolveHookCommand(command, hookDir) { + return command.map((part) => { + if (part === 'node') return process.execPath; + if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part); + return part; + }); +} + function resolveManifestCommands(raw, hookDir) { if (!raw || typeof raw !== 'object' || !raw.lifecycle || typeof raw.lifecycle !== 'object') return raw; const lifecycle = {}; for (const [verb, entry] of Object.entries(raw.lifecycle)) { - if (!entry?.hook?.command) { lifecycle[verb] = entry; continue; } - const command = entry.hook.command.map((part) => { - if (part === 'node') return process.execPath; - if (part.endsWith('.mjs') && !path.isAbsolute(part)) return path.join(hookDir, part); - return part; - }); - lifecycle[verb] = { ...entry, hook: { ...entry.hook, command } }; + lifecycle[verb] = entry?.hook?.command + ? { ...entry, hook: { ...entry.hook, command: resolveHookCommand(entry.hook.command, hookDir) } } + : entry; } return { ...raw, lifecycle }; } @@ -151,6 +167,44 @@ export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {}) assert.equal(typeof detected?.observed?.pid, 'number'); }); + await run("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", async () => { + if (!validated) throw new Error('prerequisite: manifest was not validated'); + // No runHook injection here either: registerAdmittedExecution's default + // dynamically imports the real hook-runner.mjs, and executeRunPlan's + // default adapter lookup (executionAdapterFor) falls through to the + // admitted overlay — proof the whole `ak run` path (materialized plan -> + // execution seam -> derived adapter -> hook runner -> spawned Node + // process -> stdout JSON -> WorkerResult) actually runs end-to-end. + // baseDir mirrors exactly what bootstrapHostAdapters derives in + // production (F-1) — this call bypasses that bootstrap wiring (it calls + // registerAdmittedExecution directly), so it must reproduce the same + // derivation rather than a test-only shortcut. + resetAdmittedExecution(); + try { + // haveFn only: the fixture's detection.bin ('acme') is a fictitious + // binary that will never actually be on a test machine's PATH — that's + // orthogonal to what this check proves (the execution.run hook itself + // running end-to-end), so readiness is stubbed the same way a real + // installed adapter's `acme` binary would report present. runHook + // stays the real default: a genuine spawned subprocess. + registerAdmittedExecution(validated, { + haveFn: async () => true, + baseDir: path.dirname(fs.realpathSync(validManifestPath)), + }); + const plan = { workers: [{ id: 'w1', activity: 'implementation', role: 'coder', host: 'acme', prompt: 'do the thing' }] }; + const [result] = await executeRunPlan(plan, { clock: () => new Date().toISOString() }); + assert.equal(result.status, 'succeeded', result.failure?.reason ?? 'expected a succeeded WorkerResult'); + assert.equal(result.host, 'acme'); + assert.equal(result.exitCategory, 'success'); + assert.equal(result.provider, 'acme'); + // F-7: a payload-declared provider is 'inferred', never 'observed' — + // ak did not verify the hook's claim against anything. + assert.equal(result.providerProvenance, 'inferred'); + } finally { + resetAdmittedExecution(); + } + }); + for (const [description, file, cfgName, reason] of NEGATIVE_CORPUS) { await run(`negative corpus: ${description} is refused with reason '${reason}'`, async () => { const results = await admitAdapters({ @@ -216,6 +270,10 @@ test("the fixture's declared detect hook runs as a real subprocess and its JSON assertCheck("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back"); }); +test("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)", () => { + assertCheck("the fixture's declared execution.run hook drives a real one-worker plan end-to-end (P2, ADR-0031)"); +}); + for (const [description, , , reason] of NEGATIVE_CORPUS) { const name = `negative corpus: ${description} is refused with reason '${reason}'`; test(name, () => assertCheck(name)); @@ -227,8 +285,8 @@ test('edit-invalidation: mutating one byte of the manifest invalidates the prior test('runConformanceReport reports a clean pass with no failures', () => { assert.equal(report.failed, 0, JSON.stringify(report.checks.filter((c) => !c.ok), null, 2)); - assert.equal(report.total, 8); - assert.equal(report.passed, 8); + assert.equal(report.total, 9); + assert.equal(report.passed, 9); }); // ── GAP CLOSED (Wave 4 security remediation, P0-A): consent hashing used to diff --git a/tests/kit/adapter-execution.test.mjs b/tests/kit/adapter-execution.test.mjs new file mode 100644 index 0000000..92f804a --- /dev/null +++ b/tests/kit/adapter-execution.test.mjs @@ -0,0 +1,597 @@ +// P2 (ADR-0031) — the derived execution adapter for an admitted external +// host. Unit-level: every case here injects `runHook`/`haveFn`/`clock`, so +// none of it depends on a real subprocess or on B1's manifest/hook-runner +// landing timing. The real-subprocess, real-manifest black-box proof lives in +// adapter-conformance.test.mjs (the acme fixture's execution.run hook). +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 { + buildAdmittedExecutionAdapter, registerAdmittedExecution, resetAdmittedExecution, admittedExecutionAdapterFor, + resetAllAdmitted, +} from '../../src/lib/execution/admitted.mjs'; +import { executionAdapterFor } from '../../src/lib/execution/adapters.mjs'; +import { validateExecutionAdapter } from '../../src/lib/execution/schema.mjs'; +import { normalizeHandoff, HANDOFF_START, HANDOFF_END } from '../../src/lib/execution/handoff.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { applyAdmitted, effectiveHostRegistry } from '../../src/lib/adapters/admitted.mjs'; +import { bootstrapHostAdapters } from '../../src/lib/adapters/admission.mjs'; +import { isRoutableHost, validateRoute } from '../../src/lib/routing.mjs'; + +const clock = () => '2026-08-16T00:00:00.000Z'; + +function validHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: false, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function hermesManifest(overrides = {}) { + return validateAdapterManifest({ + name: 'hermes', + version: '1.0.0', + contract: 1, + host: validHost(), + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command: ['hermes-run'], timeoutMs: 30_000 } } }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented hooks for hermes', + }], + }, + ...overrides, + }); +} + +const worker = (overrides = {}) => ({ + id: 'coder', activity: 'implementation', role: 'coder', host: 'hermes', + configuredModel: 'hermes-large', prompt: 'implement the thing', ...overrides, +}); + +// F-9: pairs both overlay resets so they can never desync between tests. +beforeEach(() => resetAllAdmitted()); + +// ── shape ──────────────────────────────────────────────────────────────── + +test('buildAdmittedExecutionAdapter returns a shape that passes validateExecutionAdapter', () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest()); + assert.equal(adapter.id, 'hermes-adapter'); + assert.doesNotThrow(() => validateExecutionAdapter(adapter)); +}); + +test('buildAdmittedExecutionAdapter requires an execution block and a detection.bin', () => { + const routableHost = { + id: 'x', capabilities: { canRouteActivities: true }, + }; + assert.throws(() => buildAdmittedExecutionAdapter({ + host: routableHost, driving: { surfaces: ['cli-subprocess'] }, + }), TypeError); + assert.throws(() => buildAdmittedExecutionAdapter({ + host: routableHost, driving: { surfaces: ['cli-subprocess'] }, execution: { run: { hook: { command: ['x'] } } }, + }), TypeError, 'missing detection.bin'); +}); + +// ── readiness ──────────────────────────────────────────────────────────── + +test('readiness reflects haveFn', async () => { + const ready = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => true }); + assert.deepEqual(await ready.readiness({}), { ready: true }); + + const notReady = buildAdmittedExecutionAdapter(hermesManifest(), { haveFn: async () => false }); + assert.deepEqual(await notReady.readiness({}), { ready: false, exitCategory: 'cli_unavailable' }); +}); + +// ── prepare ────────────────────────────────────────────────────────────── + +test('prepare rejects a worker for a different host and a relative cwd', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + await assert.rejects(() => adapter.prepare({ worker: worker({ host: 'codex' }), cwd: '/abs' }), TypeError); + await assert.rejects(() => adapter.prepare({ worker: worker(), cwd: 'relative/path' }), TypeError); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + assert.equal(state.prompt, 'implement the thing'); + assert.equal(state.startedAt, clock()); +}); + +// ── launch: stdin, env, and the min-wins timeout margin ───────────────── + +test('launch invokes runHook with stdin=prompt, AK_WORKER_* env (incl. AK_WORKER_CWD, R-2), and a margin below the phase budget', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: 'done', stdoutText: 'done', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 3000 }); + + assert.equal(calls.length, 1); + const call = calls[0]; + assert.equal(call.hostId, 'hermes'); + assert.equal(call.verb, 'run'); + assert.equal(call.stdin, 'implement the thing'); + assert.deepEqual(call.env, { + AK_WORKER_ID: 'coder', AK_WORKER_ACTIVITY: 'implementation', AK_WORKER_ROLE: 'coder', AK_WORKER_MODEL: 'hermes-large', + AK_WORKER_CWD: '/abs', + }); + // Inner (hook-runner-facing) timeout must be strictly less than the phase + // budget, so hook-runner's own kill always fires first. + assert.ok(call.timeoutMs < 3000, 'inner timeout must undercut the phase budget'); + assert.ok(call.timeoutMs > 0); +}); + +test('launch sends an empty string AK_WORKER_MODEL when the worker has no configuredModel', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: worker({ configuredModel: null }), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].env.AK_WORKER_MODEL, ''); +}); + +// ── observe / interpret: the full mapping matrix ───────────────────────── + +async function runToResult(hookResult, { worker: w = worker() } = {}) { + const runHook = async () => hookResult; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); + const state = await adapter.prepare({ worker: w, cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + const observation = await adapter.observe(state); + return { adapter, state, observation, result: adapter.interpret(state, observation) }; +} + +test('exit 0 with a JSON payload maps summary/observedModel/provider/usage and providerProvenance=inferred (F-7)', async () => { + const stdout = JSON.stringify({ + summary: 'implemented the thing', observedModel: 'hermes-large-2', provider: 'hermes-vendor', usage: { tokens: 42 }, + }); + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.exitCategory, 'success'); + assert.equal(result.failure, null); + assert.equal(result.observedModel, 'hermes-large-2'); + assert.equal(result.provider, 'hermes-vendor'); + // F-7: a self-declared payload provider is 'inferred', NEVER 'observed' — + // ak did not verify the hook's claim against anything. + assert.equal(result.providerProvenance, 'inferred'); + assert.deepEqual(result.usage, { tokens: 42 }); + assert.equal(result.host, 'hermes'); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'implemented the thing', artifacts: [], decisions: [], risks: [] }); + assert.doesNotThrow(() => normalizeHandoff(handoff), 'summarize output must be normalizeHandoff-compatible'); +}); + +test('F-7: a payload-declared provider is bounded to 64 chars', async () => { + const longProvider = 'x'.repeat(200); + const stdout = JSON.stringify({ summary: 'ok', provider: longProvider }); + const { result } = await runToResult({ ok: true, stdout, stdoutText: stdout, stderrText: '', exitCode: 0, detail: null }); + assert.equal(result.provider.length, 64); + assert.equal(result.provider, longProvider.slice(0, 64)); +}); + +test('exit 0 with plain-text stdout becomes the summary when stderr is empty', async () => { + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout: 'plain text result', stdoutText: 'plain text result', stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.provider, null); + assert.equal(result.providerProvenance, 'unknown'); + assert.equal(result.observedModel, null); + assert.equal(result.usage, null); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'plain text result', artifacts: [], decisions: [], risks: [] }); +}); + +test('F-4: non-empty stderrText is never auto-promoted into a summary, even with plain-text stdout', async () => { + const { adapter, state, observation } = await runToResult({ + ok: true, stdout: 'looks like a normal result', stdoutText: 'looks like a normal result', + stderrText: 'a stray debug line', exitCode: 0, detail: null, + }); + assert.equal(adapter.summarize(state, observation), null, + 'stderr chatter must never become the cross-vendor dependency handoff'); +}); + +// R-1 (HIGH, blocker fix): the pre-R-1 shape parsed the JSON payload from +// hook-runner's MERGED `stdout` field — the instant a hook wrote anything to +// stderr (a Node/npm deprecation warning, nothing to do with the hook's own +// correctness), the merged text was no longer clean JSON, JSON.parse threw, +// and a perfectly valid summary/provider/usage was silently discarded. Worse +// downstream: any worker OTHERS depend on (`requireHandoff`) would then throw +// "required worker handoff was missing" and fail the whole pipeline over one +// stderr line. Fixed by parsing from `stdoutText` (hook-runner's UNMERGED +// stdout) instead — a valid JSON payload now parses regardless of stderr; +// only the PLAIN-TEXT promotion path stays gated on stderr being empty (F-4, +// unchanged, and still proven by the sibling test above). +test('R-1: a JSON payload alongside stderr chatter STILL yields its summary, provider, and usage', async () => { + const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor', usage: { tokens: 7 } }); + const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`; + const { result, adapter, state, observation } = await runToResult({ + ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null, + }); + assert.equal(result.status, 'succeeded'); + assert.equal(result.provider, 'hermes-vendor'); + assert.equal(result.providerProvenance, 'inferred'); + assert.deepEqual(result.usage, { tokens: 7 }); + + const handoff = adapter.summarize(state, observation); + assert.deepEqual(handoff, { outcome: 'built the thing', artifacts: [], decisions: [], risks: [] }); + assert.doesNotThrow(() => normalizeHandoff(handoff)); +}); + +test('exit 0 with empty stdout summarizes to null (no fabricated handoff)', async () => { + const { adapter, state, observation } = await runToResult({ + ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null, + }); + assert.equal(adapter.summarize(state, observation), null); +}); + +// R-1 (pipeline-level): the scenario above must not just parse cleanly in +// isolation — it must not block a DEPENDENT worker either. Pre-R-1, the +// producer's missing handoff (mustSummarize, since a descendant depends on +// it) turned executeWorkerAttempt's requireHandoff check into a protocol +// error, blocking the producer AND its descendant over one stderr line. +test('R-1 (pipeline): a JSON summary alongside stderr flows to a dependent worker without blocking it', async () => { + const { executeRunPlan } = await import('../../src/lib/execution/runner.mjs'); + const stdoutText = JSON.stringify({ summary: 'built the thing', provider: 'hermes-vendor' }); + const stdout = `${stdoutText}\n--- stderr ---\nnpm warn deprecated some-pkg@1.0.0`; + const hookResult = { + ok: true, stdout, stdoutText, stderrText: 'npm warn deprecated some-pkg@1.0.0', exitCode: 0, detail: null, + }; + const runHook = async () => hookResult; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock, haveFn: async () => true }); + const plan = { + workers: [ + { id: 'producer', activity: 'implementation', role: 'coder', host: 'hermes', prompt: 'produce' }, + { + id: 'consumer', activity: 'review', role: 'reviewer', host: 'hermes', prompt: 'consume', dependsOn: ['producer'], + }, + ], + }; + const results = await executeRunPlan(plan, { adapters: { hermes: adapter }, clock }); + const producer = results.find((r) => r.workerId === 'producer'); + const consumer = results.find((r) => r.workerId === 'consumer'); + assert.equal(producer.status, 'succeeded', producer.failure?.reason); + assert.equal(producer.provider, 'hermes-vendor'); + assert.equal(consumer.status, 'succeeded', consumer.failure?.reason); +}); + +test('non-zero exit maps to failed/worker_error with a bounded reason including the exit code', async () => { + const { result, adapter, state, observation } = await runToResult({ + ok: false, stdout: 'boom on line 1\n--- stderr ---\nstack trace tail', stderrText: 'stack trace tail', + exitCode: 1, detail: 'hermes:run adapter hook exited with code 1', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'worker_error'); + assert.match(result.failure.reason, /code 1/); + assert.ok(result.failure.reason.length <= 240); + assert.equal(adapter.summarize(state, observation), null, 'a failed worker never produces a handoff'); +}); + +test('F-3: a private handoff block embedded in the failing stdout tail is redacted from the worker_error reason', async () => { + const leaking = `some output ${HANDOFF_START}{"outcome":"secret","artifacts":[],"decisions":[],"risks":[]}${HANDOFF_END} more`; + const { result } = await runToResult({ + ok: false, stdout: leaking, stderrText: '', exitCode: 1, detail: 'hermes:run adapter hook exited with code 1', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'worker_error'); + assert.doesNotMatch(result.failure.reason, /secret/); + assert.doesNotMatch(result.failure.reason, /AK_HANDOFF_V1/); + assert.match(result.failure.reason, /private handoff withheld/); +}); + +// ── F-6: reserved hook exit codes (consent/auth boundary) ──────────────── + +test('F-6: exit code 77 maps to status blocked / exitCategory permission_required', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', stderrText: '', exitCode: 77, detail: 'hermes:run adapter hook exited with code 77', + }); + assert.equal(result.status, 'blocked'); + assert.equal(result.exitCategory, 'permission_required'); +}); + +test('F-6: exit code 78 maps to status failed / exitCategory auth_required', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', stderrText: '', exitCode: 78, detail: 'hermes:run adapter hook exited with code 78', + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'auth_required'); +}); + +test('a spawn failure (ENOENT-style) maps to failed/cli_unavailable', async () => { + const { result } = await runToResult({ + ok: false, stdout: '', exitCode: null, detail: "hermes:run adapter hook failed to start: ENOENT (spawn hermes-run ENOENT)", + }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'cli_unavailable'); + assert.match(result.failure.reason, /ENOENT/); +}); + +test("hook-runner's own timeout detail maps to timed_out/timeout", async () => { + const { result } = await runToResult({ + ok: false, stdout: '', exitCode: null, detail: 'hermes:run adapter hook timed out after 5000ms and was killed', + }); + assert.equal(result.status, 'timed_out'); + assert.equal(result.exitCategory, 'timeout'); +}); + +// ── interpret: runner-injected terminal events (outer deadline abort) ──── + +test('interpret handles a runner-injected {type:"timeout"} terminal event', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'timeout', reason: 'launch exceeded the worker deadline' }); + assert.equal(result.status, 'timed_out'); + assert.equal(result.exitCategory, 'timeout'); +}); + +test('interpret handles a runner-injected {type:"orphaned"} terminal event', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'orphaned' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'orphaned'); +}); + +test('interpret treats an unrecognized observation as a protocol error rather than fabricating success', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + const result = adapter.interpret(state, { type: 'idle' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'protocol_error'); +}); + +// ── cancel / cleanup: honest post-launch no-ops, honest pre-launch orphan ─ + +test('cancel after launch has resolved is an honest no-op (hook-runner already owns the kill)', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const launchedState = { hookResult: { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null } }; + assert.deepEqual(await adapter.cancel(launchedState), { type: 'cancelled' }); + assert.deepEqual(await adapter.cleanup(launchedState), { cleaned: true }); +}); + +test('F-2: cancel BEFORE launch resolves reports orphaned (non-escalating), never a plain cancelled', async () => { + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { clock }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + // state.hookResult was never set — launch() is (hypothetically) still + // pending when the runner's outer deadline fires and calls cancel(). + const cancelled = await adapter.cancel(state); + assert.deepEqual(cancelled, { type: 'cancelled', orphaned: true }); + + // The runner maps a cancel() reporting orphaned:true into + // interpret(state, {type:'orphaned'}) — prove OUR adapter's interpret + // honors that terminal shape (already covered generically for 'orphaned' + // above, but pinned here specifically as the F-2 consequence). + const result = adapter.interpret(state, { type: 'orphaned' }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'orphaned'); +}); + +// ── F-1: relative hook commands anchor to baseDir, or refuse outright ──── + +test('F-1: a relative hook command with no baseDir is refused at construction (execution-unanchored)', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test('F-1: a relative NON-flag argument with no baseDir is refused (argv0 itself may be a bare PATH binary)', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'scripts/run.mjs'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test('F-1/R-3: a bare flag (and a non-path flag value) is never mistaken for a relative path', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['hermes-run', '--config', 'x', '--verbose'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +// R-3 (residual of F-1): the pre-R-3 shape skipped every '-'-prefixed arg +// entirely before checking whether it looked like a path — so a single +// `--flag=value` token (still '-'-prefixed as a WHOLE token) slipped past +// unchecked even when its value half was a relative script. Now every arg is +// inspected, catching this with a null baseDir exactly like a bare relative +// argument would be. +test("R-3: a '--flag=value' token whose value is a relative path is refused with a null baseDir", () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'execution-unanchored'); + return true; + }); +}); + +test("R-3: the same '--flag=value' token is legal once a baseDir anchors the command", () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', '--import=./evil.mjs', 'hermes-run'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest, { baseDir: '/adapters/hermes' })); +}); + +test('F-1: a bare PATH-resolved interpreter/binary command stays legal with no baseDir', () => { + const manifest = hermesManifest(); // command: ['hermes-run'] — no separator, no script extension + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +test('F-1: an absolute command is always legal, baseDir or not', () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['/usr/bin/hermes-run', '/abs/arg.mjs'] } } } }); + assert.doesNotThrow(() => buildAdmittedExecutionAdapter(manifest)); +}); + +test('F-1: a relative command IS legal once a baseDir anchors it, and launch passes cwd=baseDir to runHook', async () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } }); + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stderrText: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(manifest, { runHook, clock, baseDir: '/adapters/hermes' }); + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].cwd, '/adapters/hermes'); +}); + +// R-2: the tempting fix for "the hook lost its cwd signal" would be spawning +// in the repo cwd unconditionally — but that reopens F-1 (a relative command +// would resolve against the operator's cwd again). Instead: baseDir anchors +// a relative command when one was declared; with NO baseDir, the +// construction-time check already proved the command has no relative +// component a cwd could redirect (bare PATH binaries only), so falling back +// to the repo cwd here is safe AND gives the hook a normal spawn location — +// never Node's own "inherit ak's own process.cwd()" default. +test('R-2: launch falls back to state.cwd (never omits cwd) when there is no baseDir', async () => { + const calls = []; + const runHook = async (options) => { calls.push(options); return { ok: true, stdout: '', stdoutText: '', stderrText: '', exitCode: 0, detail: null }; }; + const adapter = buildAdmittedExecutionAdapter(hermesManifest(), { runHook, clock }); // baseDir defaults to null + const state = await adapter.prepare({ worker: worker(), cwd: '/abs' }); + await adapter.launch(state, { timeoutMs: 5000 }); + assert.equal(calls[0].cwd, '/abs'); +}); + +// ── F-1 (bootstrap-level): baseDir derives from entry.source ───────────── + +test('F-1 (bootstrap): an npm-sourced execution adapter with a relative command is refused with a surfaced warning', async () => { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['run-hook.mjs'] } } } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'npm:hermes-adapter@1.0.0' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1, 'the host itself still admits — only execution registration fails'); + const warning = result.warnings.find((w) => w.reason === 'execution-unanchored'); + assert.ok(warning, `expected an 'execution-unanchored' warning; got ${JSON.stringify(result.warnings)}`); +}); + +test('F-1 (bootstrap): a file-sourced manifest derives baseDir from realpath(dirname(source)) and registers cleanly', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-execution-basedir-')); + try { + const manifest = hermesManifest({ execution: { run: { hook: { command: ['node', 'run-hook.mjs'] } } } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const manifestPath = path.join(tmpDir, 'manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: manifestPath }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, [], `expected no warnings; got ${JSON.stringify(result.warnings)}`); + assert.notEqual(admittedExecutionAdapterFor('hermes'), null, 'registration must have succeeded with a real baseDir'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ── F-5: driving.surfaces must include 'cli-subprocess' ────────────────── + +test("F-5: a manifest whose driving.surfaces omits 'cli-subprocess' gets no execution adapter (surface-unsupported)", () => { + const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } }); + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'surface-unsupported'); + return true; + }); +}); + +test('F-5 (bootstrap): the execution-candidate filter refuses a non-cli-subprocess manifest with its own reason', async () => { + const manifest = hermesManifest({ driving: { surfaces: ['mcp'] } }); + const { hashManifest } = await import('../../src/lib/adapters/admission.mjs'); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: '/does/not/matter/manifest.json' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => manifest, + consent: { recordedHashFor: () => hash, isTrusted: () => true }, + }); + assert.equal(result.admitted.length, 1); + const warning = result.warnings.find((w) => w.reason === 'surface-unsupported'); + assert.ok(warning, `expected a 'surface-unsupported' warning; got ${JSON.stringify(result.warnings)}`); + assert.equal(admittedExecutionAdapterFor('hermes'), null); +}); + +// ── F-8: canRouteActivities is re-asserted at the construction site ────── + +test('F-8: a raw canRouteActivities:false host object is refused at construction (defence-in-depth)', () => { + const manifest = { + host: { id: 'hermes', capabilities: { canRouteActivities: false } }, + detection: { bin: 'hermes' }, + driving: { surfaces: ['cli-subprocess'] }, + execution: { run: { hook: { command: ['hermes-run'] } } }, + }; + assert.throws(() => buildAdmittedExecutionAdapter(manifest), (error) => { + assert.equal(error.reason, 'not-routable'); + return true; + }); +}); + +// ── F-9: paired overlay resets ──────────────────────────────────────────── + +test('F-9: resetAllAdmitted() clears both the host overlay and the execution overlay together', () => { + const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) }); + applyAdmitted([{ entry: manifest.host }]); + registerAdmittedExecution(manifest); + assert.ok(effectiveHostRegistry().some((host) => host.id === 'acme')); + assert.notEqual(admittedExecutionAdapterFor('acme'), null); + + resetAllAdmitted(); + + assert.ok(!effectiveHostRegistry().some((host) => host.id === 'acme')); + assert.equal(admittedExecutionAdapterFor('acme'), null); +}); + +// ── registry: overlay discipline mirrors adapters/admitted.mjs ────────── + +test('registerAdmittedExecution/admittedExecutionAdapterFor/resetAdmittedExecution round-trip, and a second register replaces', () => { + assert.equal(admittedExecutionAdapterFor('hermes'), null); + const first = registerAdmittedExecution(hermesManifest()); + assert.equal(admittedExecutionAdapterFor('hermes'), first); + + const second = registerAdmittedExecution(hermesManifest({ version: '1.0.1' })); + assert.notEqual(second, first); + assert.equal(admittedExecutionAdapterFor('hermes'), second, 'a second register replaces, not accumulates'); + + resetAdmittedExecution(); + assert.equal(admittedExecutionAdapterFor('hermes'), null); +}); + +// ── seam: executionAdapterFor falls through to the admitted overlay ───── + +test('executionAdapterFor falls through to an admitted execution adapter for a non-built-in host', () => { + assert.equal(executionAdapterFor('hermes'), null); + const registered = registerAdmittedExecution(hermesManifest()); + assert.equal(executionAdapterFor('hermes'), registered); +}); + +// ── byte-zero pins (flag/overlay off vs on) ────────────────────────────── + +test('byte-zero: with nothing admitted, executionAdapterFor is null and validateRoute refuses the host', () => { + assert.equal(executionAdapterFor('acme'), null); + assert.equal(isRoutableHost('acme'), false); + assert.ok(validateRoute({ host: 'acme' }).length > 0); +}); + +test('with the host + execution overlay applied, executionAdapterFor resolves and validateRoute accepts', () => { + const manifest = hermesManifest({ name: 'acme', host: validHost({ id: 'acme' }) }); + applyAdmitted([{ entry: manifest.host }]); + registerAdmittedExecution(manifest); + + assert.equal(isRoutableHost('acme'), true); + assert.deepEqual(validateRoute({ host: 'acme' }), []); + assert.notEqual(executionAdapterFor('acme'), null); +}); diff --git a/tests/kit/adapter-grants.test.mjs b/tests/kit/adapter-grants.test.mjs new file mode 100644 index 0000000..824d8fe --- /dev/null +++ b/tests/kit/adapter-grants.test.mjs @@ -0,0 +1,449 @@ +// Unit tests for the hash-pinned capability-grant store (ADR-0031 §1, §2, +// §4). Mirrors adapter-conformance.test.mjs's consent-store test patterns: +// temp files, prototype-chain safety, corrupt/missing-file tolerance. +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 { + CONFORMANCE_TIERS, TIER_GRANTS, + recordTierResult, recordTierGate, recordTierFailure, grantCapability, revokeGrants, revokeCapability, + grantsFor, grantedCapabilitiesFor, gatedTiersFor, +} from '../../src/lib/adapters/grants.mjs'; + +function tempFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-')); + return path.join(dir, 'adapter-grants.json'); +} + +const HASH_A = 'a'.repeat(64); +const HASH_B = 'b'.repeat(64); + +test('exports the five conformance tiers in graduation order', () => { + assert.deepEqual(CONFORMANCE_TIERS, [ + 'admission', 'session-driving', 'activity-routing', 'primary-eligible', 'statusline', + ]); +}); + +test('TIER_GRANTS only maps primary-eligible and statusline; aqeProvider never appears', () => { + assert.deepEqual(TIER_GRANTS, { 'primary-eligible': 'canBePrimary', statusline: 'commandStatusline' }); + assert.ok(!Object.values(TIER_GRANTS).includes('aqeProvider')); +}); + +test('record -> grant happy path: passed tier at the same hash grants the capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run, escalated to' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_A); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers['primary-eligible'].evidence, 'led a run, escalated to'); + assert.equal(record.capabilities.canBePrimary, true); + assert.ok(typeof record.grantedAt === 'string' && Number.isFinite(Date.parse(record.grantedAt))); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); +}); + +test('grantCapability refused: no tier recorded at all', () => { + const file = tempFile(); + assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }), (error) => { + assert.match(error.message, /primary-eligible/); + return true; + }); +}); + +test('grantCapability refused: tier passed but at a DIFFERENT hash', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + assert.throws(() => grantCapability('acme', 'canBePrimary', { hash: HASH_B }, { file }), (error) => { + assert.match(error.message, /primary-eligible/); + assert.match(error.message, new RegExp(HASH_B)); + return true; + }); + // and the never-recorded hash grants nothing either + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('grantCapability refused: aqeProvider is never a grantable capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + assert.throws(() => grantCapability('acme', 'aqeProvider', { hash: HASH_A }, { file }), TypeError); +}); + +test('grantedCapabilitiesFor returns {} on hash mismatch', () => { + const file = tempFile(); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {}); +}); + +test('grantedCapabilitiesFor returns {} after a re-record at a new hash (edit-invalidation)', () => { + const file = tempFile(); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); + + // Manifest changed -> re-record at a new hash. + recordTierResult('acme', 'statusline', { hash: HASH_B, evidence: 'new footer render' }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_B, { file }), {}); +}); + +test('re-record at a new hash wipes prior tiers and capabilities entirely', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'session-driving', { hash: HASH_A }, { file }); + + let record = grantsFor('acme', { file }); + assert.ok(record.tiers['primary-eligible']); + assert.ok(record.tiers['session-driving']); + assert.equal(record.capabilities.canBePrimary, true); + + recordTierResult('acme', 'admission', { hash: HASH_B }, { file }); + record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_B); + assert.deepEqual(Object.keys(record.tiers), ['admission']); + assert.equal(record.capabilities, undefined); + assert.equal(record.grantedAt, undefined); +}); + +test('recordTierGate validates the gatedBy ref format', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + recordTierGate('acme', 'statusline', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.equal(record.tiers['primary-eligible'].gatedBy, 'agentic-qe#563'); + assert.equal(record.tiers.statusline.gatedBy, 'ruvnet/ruflo#2962'); + + for (const bad of ['agentic-qe#0', 'no-hash', 'a#b', 'agentic-qe#', '#123', 'agentic-qe#01']) { + assert.throws( + () => recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: bad }, { file }), + TypeError, + `expected recordTierGate to reject gatedBy: ${bad}`, + ); + } +}); + +test('gatedTiersFor lists gated entries and [] when none/missing', () => { + const file = tempFile(); + assert.deepEqual(gatedTiersFor('acme', { file }), []); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const gated = gatedTiersFor('acme', { file }); + assert.equal(gated.length, 1); + assert.equal(gated[0].tier, 'primary-eligible'); + assert.equal(gated[0].gatedBy, 'agentic-qe#563'); +}); + +test('revokeGrants: true when a record existed, false otherwise', () => { + const file = tempFile(); + assert.equal(revokeGrants('acme', { file }), false); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + assert.equal(revokeGrants('acme', { file }), true); + assert.equal(grantsFor('acme', { file }), null); + assert.equal(revokeGrants('acme', { file }), false); +}); + +test("revokeGrants on prototype-chain names ('constructor', '__proto__', 'toString') returns false on an empty store", () => { + const file = tempFile(); + for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + assert.equal(revokeGrants(protoName, { file }), false, + `revokeGrants('${protoName}') on a never-recorded store must be false, not a prototype-chain hit`); + } + // A real recorded adapter must still revoke correctly. + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + assert.equal(revokeGrants('acme', { file }), true); +}); + +test('corrupt or missing file tolerance: grantsFor null, grantedCapabilitiesFor {}, gatedTiersFor []', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-grants-corrupt-')); + const missing = path.join(dir, 'does-not-exist.json'); + assert.equal(grantsFor('acme', { file: missing }), null); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: missing }), {}); + assert.deepEqual(gatedTiersFor('acme', { file: missing }), []); + + const corrupt = path.join(dir, 'adapter-grants.json'); + fs.writeFileSync(corrupt, '{ not valid json', 'utf8'); + assert.equal(grantsFor('acme', { file: corrupt }), null); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file: corrupt }), {}); + assert.deepEqual(gatedTiersFor('acme', { file: corrupt }), []); +}); + +test('recordTierResult / recordTierGate throw TypeError on invalid name/tier/hash', () => { + const file = tempFile(); + assert.throws(() => recordTierResult('', 'admission', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'not-a-tier', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'admission', { hash: '' }, { file }), TypeError); + assert.throws(() => recordTierGate('acme', 'not-a-tier', { hash: HASH_A, gatedBy: 'x#1' }, { file }), TypeError); + assert.throws(() => recordTierGate('acme', 'admission', { hash: '', gatedBy: 'x#1' }, { file }), TypeError); +}); + +test('evidence is bounded to 2048 characters', () => { + const file = tempFile(); + const huge = 'x'.repeat(3000); + recordTierResult('acme', 'admission', { hash: HASH_A, evidence: huge }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers.admission.evidence.length, 2048); +}); + +// ── Finding 10: grantsFor/gatedTiersFor are reporting surfaces, not the +// capability reader — currentHash lets them ANNOTATE/void staleness for a +// status display without hiding it. grantedCapabilitiesFor stays the only +// hash-blind-proof reader. ─────────────────────────────────────────────── + +test('grantsFor: no currentHash -> no stale field at all (raw reporting)', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(Object.hasOwn(record, 'stale'), false); +}); + +test('grantsFor: currentHash supplied -> stale:false on match, stale:true on mismatch, record still returned either way', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A, evidence: 'e' }, { file }); + + const fresh = grantsFor('acme', { file, currentHash: HASH_A }); + assert.equal(fresh.stale, false); + assert.equal(fresh.hash, HASH_A); + assert.ok(fresh.tiers.admission, 'stale annotation must not hide the underlying evidence'); + + const stale = grantsFor('acme', { file, currentHash: HASH_B }); + assert.equal(stale.stale, true); + assert.equal(stale.hash, HASH_A); + assert.ok(stale.tiers.admission, 'a stale record must still be visible, just flagged, not hidden'); +}); + +test('gatedTiersFor: currentHash omitted returns every gated tier regardless of hash', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + assert.equal(gatedTiersFor('acme', { file }).length, 1); + assert.equal(gatedTiersFor('acme', { file, currentHash: HASH_A }).length, 1); +}); + +test('gatedTiersFor: currentHash mismatch voids gated-tier records, same as grants', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + assert.deepEqual(gatedTiersFor('acme', { file, currentHash: HASH_B }), []); +}); + +// ── Finding 11: a grant-bearing tier ('primary-eligible', 'statusline') +// must never be recorded 'passed' with empty evidence — a grant must always +// trace back to real conformance evidence (ADR-0031 §1). ────────────────── + +test('recordTierResult on a grant-bearing tier requires non-empty evidence', () => { + const file = tempFile(); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: '' }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: ' ' }, { file }), TypeError); + assert.throws(() => recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: '' }, { file }), TypeError); + // nothing was recorded by any of the rejected attempts + assert.equal(grantsFor('acme', { file }), null); +}); + +test('recordTierResult on an evidence-only tier (admission) still accepts empty/missing evidence', () => { + const file = tempFile(); + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers.admission.status, 'passed'); + assert.equal(record.tiers.admission.evidence, ''); +}); + +// ── F-1 (security review): grantedCapabilitiesFor re-derives the grant +// invariant at READ time, not just at write time. adapter-grants.json is a +// flat JSON file an operator (or a bad merge) can hand-edit directly — +// grantCapability's write-time gate does not protect against that. A +// capability must only ever be returned when it is a real TIER_GRANTS value +// AND its gating tier is recorded 'passed' at the exact same hash. ───────── + +test('F-1: grantedCapabilitiesFor rejects a forged capability that has no matching passed tier, even at the correct hash', () => { + const file = tempFile(); + // Simulate a hand-edited/forged/merged store: 'canBePrimary' sits in + // capabilities with NO 'primary-eligible' tier ever recorded passed. + recordTierResult('acme', 'admission', { hash: HASH_A }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities = { canBePrimary: true }; + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + assert.deepEqual( + grantedCapabilitiesFor('acme', HASH_A, { file }), + {}, + 'a forged capability with no backing passed tier must never be returned', + ); +}); + +test("F-1: grantedCapabilitiesFor rejects a forged 'aqeProvider' key even though it is present in the raw store", () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities.aqeProvider = true; // forged — never written by grantCapability + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + const granted = grantedCapabilitiesFor('acme', HASH_A, { file }); + assert.deepEqual(granted, { canBePrimary: true }, 'the real grant still returns; the forged aqeProvider key must not'); + assert.ok(!Object.hasOwn(granted, 'aqeProvider')); +}); + +test('F-1: grantedCapabilitiesFor rejects a capability whose gating tier was recorded but is not status "passed"', () => { + const file = tempFile(); + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'agentic-qe#563' }, { file }); + const store = JSON.parse(fs.readFileSync(file, 'utf8')); + store.acme.capabilities = { canBePrimary: true }; // forged alongside a merely-gated tier + fs.writeFileSync(file, JSON.stringify(store, null, 2), 'utf8'); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +// ── F-2 (security review): running `gate` AFTER a grant must downgrade the +// live capability along with the tier — evidence and grant must never +// disagree, or a status display would show a self-contradiction (a 'gated' +// tier backing a still-live capability) and the audit trail would be lost. + +test('F-2: recordTierGate on a grant-bearing tier that currently backs a live capability drops that capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); + + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'the stored record must drop the capability, not just the read side'); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('F-2: recordTierGate on a grant-bearing tier leaves an UNRELATED live capability untouched', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('F-2: recordTierGate on a tier with no live capability (never granted) is a no-op on capabilities', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + // No grantCapability call — the tier passed but was never granted. + recordTierGate('acme', 'primary-eligible', { hash: HASH_A, gatedBy: 'ruvnet/ruflo#2962' }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'gated'); + assert.equal(record.capabilities, undefined); +}); + +// ── N-1 (security-review follow-up): recordTierFailure — the un-earn path +// mirroring recordTierGate's downgrade, but for a genuine RE-FAIL at the +// same hash a capability was earned at. ──────────────────────────────────── + +test('N-1: recordTierFailure on a grant-bearing tier that currently backs a live capability drops that capability', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'the stored record must drop the capability, not just the read side'); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('N-1: recordTierFailure on a grant-bearing tier leaves an UNRELATED live capability untouched', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('N-1: recordTierFailure on a tier with no live capability (never granted) is a no-op on capabilities', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + recordTierFailure('acme', 'primary-eligible', { hash: HASH_A }, { file }); + const record = grantsFor('acme', { file }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.equal(record.capabilities, undefined); +}); + +test('N-1: recordTierFailure at a DIFFERENT hash still wipes prior tiers/capabilities via the existing freshRecordAt path', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + + recordTierFailure('acme', 'primary-eligible', { hash: HASH_B }, { file }); + + const record = grantsFor('acme', { file }); + assert.equal(record.hash, HASH_B); + assert.deepEqual(Object.keys(record.tiers), ['primary-eligible']); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.equal(record.capabilities, undefined); + // The old hash's grant is gone too — a hash change voids everything. + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), {}); +}); + +test('recordTierFailure throws TypeError on invalid name/tier/hash', () => { + const file = tempFile(); + assert.throws(() => recordTierFailure('', 'primary-eligible', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierFailure('acme', 'not-a-tier', { hash: HASH_A }, { file }), TypeError); + assert.throws(() => recordTierFailure('acme', 'primary-eligible', { hash: '' }, { file }), TypeError); +}); + +// ── F-9 (deferred nit, now taken): revokeCapability — per-capability revoke, +// narrower than revokeGrants' whole-record wipe. ─────────────────────────── + +test('revokeCapability removes only the named capability, leaving other tiers/capabilities intact', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + recordTierResult('acme', 'statusline', { hash: HASH_A, evidence: 'footer renders' }, { file }); + grantCapability('acme', 'commandStatusline', { hash: HASH_A }, { file }); + + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), true); + + const record = grantsFor('acme', { file }); + assert.ok(!Object.hasOwn(record.capabilities, 'canBePrimary')); + assert.equal(record.capabilities.commandStatusline, true); + // Tiers are untouched — still 'passed', not reverted. + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.equal(record.tiers.statusline.status, 'passed'); + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { commandStatusline: true }); +}); + +test('revokeCapability returns false when the capability was never granted, or the adapter has no record', () => { + const file = tempFile(); + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), false); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + // Tier passed but never granted -> capability never existed to revoke. + assert.equal(revokeCapability('acme', 'canBePrimary', { file }), false); +}); + +test('revokeCapability rejects a non-grantable capability, including a forged aqeProvider key', () => { + const file = tempFile(); + recordTierResult('acme', 'primary-eligible', { hash: HASH_A, evidence: 'led a run' }, { file }); + grantCapability('acme', 'canBePrimary', { hash: HASH_A }, { file }); + assert.throws(() => revokeCapability('acme', 'aqeProvider', { file }), TypeError); + assert.throws(() => revokeCapability('acme', 'transcripts', { file }), TypeError); + // Nothing was touched by the rejected attempts. + assert.deepEqual(grantedCapabilitiesFor('acme', HASH_A, { file }), { canBePrimary: true }); +}); + +test('revokeCapability on prototype-chain names returns false, never a prototype-chain hit', () => { + const file = tempFile(); + for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + assert.equal(revokeCapability(protoName, 'canBePrimary', { file }), false); + } +}); diff --git a/tests/kit/adapter-hook-runner.test.mjs b/tests/kit/adapter-hook-runner.test.mjs index b0f7955..b29143a 100644 --- a/tests/kit/adapter-hook-runner.test.mjs +++ b/tests/kit/adapter-hook-runner.test.mjs @@ -127,6 +127,151 @@ test('a non-zero exit is reported as ok:false with the exit code preserved', asy assert.equal(result.exitCode, 7); }); +// --- stdin (P2, ADR-0031: worker prompt delivery) --------------------------- + +test('stdin: a payload written to the child is delivered and echoed back', async () => { + const payload = 'the worker prompt, rendered task + handoffs'; + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'process.stdin.pipe(process.stdout)'] }, + hostId: 'claude', verb: 'run', stdin: payload, + }); + assert.equal(result.ok, true); + assert.equal(result.stdout, payload); +}); + +test('stdin: a child that exits without reading stdin (EPIPE) still yields a normal result', async () => { + // A large payload makes it likely the write is still in flight (or at + // least unread) when the child exits, so the pipe's write side sees EPIPE. + const bigPayload = 'x'.repeat(4 * 1024 * 1024); + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'process.exit(3)'] }, + hostId: 'claude', verb: 'run', stdin: bigPayload, + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, 3); +}); + +test('stdin: absent stdin keeps the existing ignore behavior (reading it yields EOF, not a hang)', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "let n=0; process.stdin.on('data', () => { n++; }); process.stdin.on('end', () => console.log('eof', n));"], + }, + hostId: 'claude', verb: 'discover', timeoutMs: 5000, + }); + assert.equal(result.ok, true); + assert.ok(result.stdout.includes('eof 0'), result.stdout); +}); + +// --- cwd (F-1: pin the child to the adapter's own directory, never the +// operator's process.cwd() — a relative hook.command like `node +// run-hook.mjs` must resolve against the adapter bundle, not wherever `ak +// run` happened to be invoked from) -------------------------------------- + +test('cwd: when provided, the child actually runs there — not in process.cwd()', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-hook-cwd-')); + try { + const target = fs.realpathSync(dir); // resolve /private symlink on macOS + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'console.log(process.cwd())'] }, + hostId: 'claude', verb: 'run', cwd: target, + }); + assert.equal(result.ok, true); + assert.equal(result.stdout.trim(), target); + assert.notEqual(result.stdout.trim(), process.cwd()); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('cwd: a relative path is refused synchronously, same class as the other arg-shape checks', async () => { + await assert.rejects( + () => runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('should not run')"] }, + hostId: 'claude', verb: 'run', cwd: 'relative/dir', + }), + TypeError, + ); +}); + +test('cwd: a non-string cwd is refused synchronously', async () => { + await assert.rejects( + () => runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('should not run')"] }, + hostId: 'claude', verb: 'run', cwd: 42, + }), + TypeError, + ); +}); + +test('cwd: omitted keeps the existing inherited-process.cwd() behavior', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'console.log(process.cwd())'] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + assert.equal(result.stdout.trim(), process.cwd()); +}); + +// --- stderrText (F-4: stderr must never be silently folded into a caller's +// parse of `stdout` — the admitted execution adapter reads the payload from +// stdout alone and diagnostics from stderrText, never a blended blob) ------ + +test('stderrText: carries only stderr, independent of the combined stdout field', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "process.stdout.write('PAYLOAD'); process.stderr.write('noisy diagnostic log line');"], + }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stderrText, 'noisy diagnostic log line'); + // the combined `stdout` field still folds both streams together, unchanged + // back-compat behavior for lifecycle callers/diagnostics that read it. + assert.ok(result.stdout.includes('PAYLOAD')); + assert.ok(result.stdout.includes('noisy diagnostic log line')); +}); + +test('stderrText: empty when the hook writes nothing to stderr', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('only stdout')"] }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stderrText, ''); +}); + +// --- stdoutText (R-1: mergeCapture's `stdout` folds stderr in after a +// separator, so a JSON.parse of `stdout` breaks the instant the hook writes +// ANYTHING to stderr — a warning, a deprecation notice, a logging library +// defaulting to stderr. stdoutText is the unmerged, stdout-only capture the +// admitted adapter must parse the payload from instead.) ------------------ + +test('stdoutText: a JSON payload on stdout plus a warning on stderr — stdoutText parses cleanly, stdout does not', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "process.stdout.write(JSON.stringify({summary:'ok'})); process.stderr.write('deprecation warning');"], + }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.deepEqual(JSON.parse(result.stdoutText), { summary: 'ok' }); + assert.equal(result.stderrText, 'deprecation warning'); + // the combined `stdout` field is unchanged back-compat behavior — it folds + // both streams, so parsing IT as JSON is exactly the break R-1 fixes. + assert.throws(() => JSON.parse(result.stdout)); + assert.ok(result.stdout.includes('deprecation warning')); +}); + +test('stdoutText: equals the plain stdout capture when the hook writes nothing to stderr', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('only stdout')"] }, + hostId: 'claude', verb: 'run', + }); + assert.equal(result.ok, true); + assert.equal(result.stdoutText.trim(), 'only stdout'); + assert.equal(result.stdout, result.stdoutText); +}); + // --- consent.mjs ----------------------------------------------------------- function sandboxFile() { diff --git a/tests/kit/adapter-lifecycle-conformance.test.mjs b/tests/kit/adapter-lifecycle-conformance.test.mjs index 3fe97a4..16adb9e 100644 --- a/tests/kit/adapter-lifecycle-conformance.test.mjs +++ b/tests/kit/adapter-lifecycle-conformance.test.mjs @@ -102,6 +102,58 @@ test('undo preserves a user value changed after apply', () => { }); }); +// ── F8 (security-review follow-up): fail-closed gate on apply ───────────── +// Mirrors the ADMITTED-host hook-failure shapes exactly (lifecycle-registry.mjs's +// hookFailureResult): detect/verify failure -> {observed:null, error}; plan +// failure -> {changed:false, operations:[], error}. opencode's own detect()/ +// plan() never emit a `.error` key on any path (see opencode.mjs), so the +// gate below only ever fires for an admitted host's genuine hook failure. + +test('apply is aborted when detect returns the admitted-host hook-failure shape ({observed:null, error})', async () => { + let applyCalls = 0; + const adapter = { + id: 'fake-hook-failure', + detect() { return { observed: null, error: 'detect hook exited 1' }; }, + plan() { throw new Error('plan must never run when detect failed'); }, + apply() { applyCalls++; return { changed: true }; }, + verify() { return { observed: null }; }, + undo() { return { changed: false }; }, + }; + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(applyCalls, 0, 'apply must never run after a detect hook failure'); + assert.equal(result.ok, false); + assert.equal(result.changed, false); + assert.deepEqual(result.errors, ['detect hook exited 1']); +}); + +test('apply is aborted when plan returns the admitted-host hook-failure shape ({changed:false, operations:[], error})', async () => { + let applyCalls = 0; + const adapter = { + id: 'fake-hook-failure', + detect() { return { observed: { enabled: false } }; }, + plan() { return { changed: false, operations: [], error: 'plan hook exited 1' }; }, + apply() { applyCalls++; return { changed: true }; }, + verify() { return { observed: { enabled: false } }; }, + undo() { return { changed: false }; }, + }; + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(applyCalls, 0, 'apply must never run after a plan hook failure'); + assert.equal(result.ok, false); + assert.equal(result.changed, false); + assert.deepEqual(result.errors, ['plan hook exited 1']); +}); + +test('the fail-closed gate does not fire for a normal facts/plan shape carrying no `.error` key (opencode-safety proof)', async () => { + // fakeLifecycleAdapter's detect/plan return {observed:{...}} / {changed, + // operations} — exactly the shape opencode's own detect()/plan() return + // (no `.error` key ever) — so apply must run through untouched. + const surface = fakeSurface({ enabled: false }); + const adapter = fakeLifecycleAdapter(surface); + const result = await runLifecycle({ adapter, action: 'apply' }); + assert.equal(result.changed, true, 'apply must still run when neither facts nor plan carry an `.error` key'); + assert.deepEqual(surface.snapshot(), { enabled: true }); +}); + test('undo removes an ak-created value while preserving sibling configuration', () => { assert.deepEqual(undoOwnedValues( { env: { AK_MANAGED: 'yes', USER_KEY: 'keep' } }, diff --git a/tests/kit/adapter-manifest.test.mjs b/tests/kit/adapter-manifest.test.mjs index 33ca128..70aa475 100644 --- a/tests/kit/adapter-manifest.test.mjs +++ b/tests/kit/adapter-manifest.test.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { validateAdapterManifest, DRIVING_SURFACES, MANIFEST_TRUST_KINDS, ManifestRejected, } from '../../src/lib/adapters/manifest.mjs'; +import { hashManifest } from '../../src/lib/adapters/admission.mjs'; function validHost(overrides = {}) { return { @@ -281,3 +282,70 @@ test('unknown-field: an extra or miscased capability key cannot ride along inert rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, CanBePrimary: true } }) }), 'unknown-field'); rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, ADMIN: true } }) }), 'unknown-field'); }); + +// ── execution block (P2, ADR-0031) ────────────────────────────────────────── +// validHost()'s default capabilities already declare canRouteActivities:true, +// so an execution block is legal by default; the coupling tests below flip +// that flag explicitly to exercise both directions. + +function withExecution(overrides = {}) { + return { + run: { hook: { command: ['acme', 'run'], timeoutMs: 5000 } }, + ...overrides, + }; +} + +test('execution block round-trips into the validated output', () => { + const manifest = validateAdapterManifest(validManifest({ execution: withExecution() })); + assert.deepEqual(manifest.execution.run.hook.command, ['acme', 'run']); + assert.equal(manifest.execution.run.hook.timeoutMs, 5000); + assert.ok(Object.isFrozen(manifest.execution)); +}); + +test('a manifest with no execution block omits the key entirely', () => { + const manifest = validateAdapterManifest(validManifest()); + assert.equal('execution' in manifest, false); +}); + +test('an execution block changes hashManifest\'s value vs. the same manifest without it', () => { + const withoutExecution = validateAdapterManifest(validManifest()); + const withExecutionBlock = validateAdapterManifest(validManifest({ execution: withExecution() })); + assert.notEqual(hashManifest(withoutExecution), hashManifest(withExecutionBlock)); +}); + +test('execution-not-routable: an execution block requires host.capabilities.canRouteActivities:true', () => { + rejects(validManifest({ + host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: false } }), + execution: withExecution(), + }), 'execution-not-routable'); +}); + +test('canRouteActivities:true without an execution block still validates (legal degrade-at-runtime)', () => { + const manifest = validateAdapterManifest(validManifest({ + host: validHost({ capabilities: { ...validHost().capabilities, canRouteActivities: true } }), + })); + assert.equal(manifest.host.capabilities.canRouteActivities, true); + assert.equal('execution' in manifest, false); +}); + +test('unknown-field: an extraneous execution key is refused', () => { + rejects(validManifest({ execution: { run: withExecution().run, escalate: true } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous execution.run key is refused', () => { + rejects(validManifest({ execution: { run: { hook: withExecution().run.hook, cwd: '/tmp' } } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous execution.run.hook key is refused', () => { + rejects(validManifest({ + execution: { run: { hook: { command: ['acme', 'run'], cwd: '/tmp' } } }, + }), 'unknown-field'); +}); + +test('invalid-execution: an empty command array is rejected', () => { + rejects(validManifest({ execution: withExecution({ run: { hook: { command: [] } } }) }), 'invalid-execution'); +}); + +test('invalid-execution: a non-positive timeoutMs is rejected', () => { + rejects(validManifest({ execution: withExecution({ run: { hook: { command: ['acme', 'run'], timeoutMs: 0 } } }) }), 'invalid-execution'); +}); diff --git a/tests/kit/adapter-sources.test.mjs b/tests/kit/adapter-sources.test.mjs new file mode 100644 index 0000000..70fd269 --- /dev/null +++ b/tests/kit/adapter-sources.test.mjs @@ -0,0 +1,572 @@ +// Remote manifest sources (ADR-0031 P6). All offline: fetchFn/execFileFn are +// always injected, no real network or npm/tar invocation happens. Verifies +// the three source forms (file / https / npm), their failure modes each +// mapped to a named SourceError.reason, and — via admitAdapters — that the +// resolve-before-hash ordering makes a mutated remote source show up as +// 'consent-stale' rather than being silently re-trusted. +// +// Also carries regression coverage for the security-review fix-first pass: +// (1) a hanging https body read must be bounded by timeoutMs, not hang +// forever; (2) npm tar extraction never touches disk (stdout-only); +// (6) file sources refuse symlinks/directories and enforce maxBytes; +// (7) the npm version/tag half is validated, not just character-classed; +// (14) external text is sanitized (control chars stripped, length bounded) +// before it enters a SourceError detail. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveManifestSource, SourceError } from '../../src/lib/adapters/sources.mjs'; +import { admitAdapters, hashManifest } from '../../src/lib/adapters/admission.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; + +function validHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +function validManifest(overrides = {}) { + return { + name: 'hermes', + version: '1.0.0', + contract: 1, + host: validHost(), + detection: { bin: 'hermes' }, + driving: { surfaces: ['acp'] }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented lifecycle hooks for hermes', + }], + }, + ...overrides, + }; +} + +function trustingConsent(trusted = {}) { + return { + recordedHashFor: (name) => trusted[name] ?? null, + isTrusted: (name, hash) => trusted[name] === hash, + }; +} + +/** Builds a fetch-shaped Response stub: ok/status/headers.get/body.getReader + * over the given bytes, delivered as a single chunk. */ +function jsonResponse(payload, { status = 200, declareLength = true } = {}) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + return bytesResponse(bytes, { status, declareLength }); +} + +function textResponse(text, { status = 200, declareLength = true } = {}) { + return bytesResponse(new TextEncoder().encode(text), { status, declareLength }); +} + +function bytesResponse(bytes, { status = 200, declareLength = true } = {}) { + let sent = false; + return { + ok: status >= 200 && status < 300, + status, + type: 'basic', + headers: { get: (name) => (declareLength && name.toLowerCase() === 'content-length' ? String(bytes.length) : null) }, + body: { + getReader: () => ({ + read: async () => { + if (sent) return { done: true, value: undefined }; + sent = true; + return { done: false, value: bytes }; + }, + cancel: async () => {}, + }), + }, + }; +} + +/** A reader that yields multiple chunks, exceeding maxBytes only once all + * are combined (so a Content-Length precheck can't catch it — it must be + * caught by the streamed cap instead). Tracks whether cancel() was called. */ +function chunkedOversizeResponse(chunks) { + let index = 0; + let cancelled = false; + return { + response: { + ok: true, + status: 200, + type: 'basic', + headers: { get: () => null }, // no Content-Length declared + body: { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true, value: undefined }; + const value = chunks[index++]; + return { done: false, value }; + }, + cancel: async () => { cancelled = true; }, + }), + }, + }, + wasCancelled: () => cancelled, + }; +} + +/** A response whose body reader hangs forever — never resolves, never + * rejects, and never observes any signal itself. Used to prove + * resolveHttpsSource bounds the BODY READ, not just the initial fetch + * (regression for finding 1). */ +function hangingBodyResponse() { + return { + ok: true, + status: 200, + type: 'basic', + headers: { get: () => null }, + body: { + getReader: () => ({ + read: () => new Promise(() => {}), + cancel: async () => {}, + }), + }, + }; +} + +const neverCalled = (label) => (...args) => { throw new Error(`${label} must not be called (args: ${JSON.stringify(args)})`); }; + +// ── file source ────────────────────────────────────────────────────────── + +test('file path source: passthrough read + JSON.parse, no network', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-')); + const file = path.join(dir, 'manifest.json'); + const payload = { name: 'hermes', contract: 1 }; + await fs.writeFile(file, JSON.stringify(payload), 'utf8'); + try { + const result = await resolveManifestSource(file, { fetchFn: neverCalled('fetchFn') }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'file'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file path source: missing file -> source-unreachable', async () => { + await assert.rejects( + () => resolveManifestSource('/nonexistent/path/does-not-exist.json'), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); +}); + +test('file source (finding 6): a symlink is refused, not followed, even to a valid manifest', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-symlink-')); + const real = path.join(dir, 'real.json'); + const link = path.join(dir, 'link.json'); + await fs.writeFile(real, JSON.stringify(validManifest()), 'utf8'); + await fs.symlink(real, link); + try { + await assert.rejects( + () => resolveManifestSource(link), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file source (finding 6): a directory is refused', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-dir-')); + try { + await assert.rejects( + () => resolveManifestSource(dir), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('file source (finding 6): an oversized file is refused before being fully read', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-file-big-')); + const file = path.join(dir, 'big.json'); + await fs.writeFile(file, JSON.stringify({ padding: 'x'.repeat(1000) }), 'utf8'); + try { + await assert.rejects( + () => resolveManifestSource(file, { maxBytes: 100 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +// ── https:// source ────────────────────────────────────────────────────── + +test('http:// is refused as source-insecure, before any fetch', async () => { + await assert.rejects( + () => resolveManifestSource('http://example.com/manifest.json', { fetchFn: neverCalled('fetchFn') }), + (error) => error instanceof SourceError && error.reason === 'source-insecure', + ); +}); + +test('a 3xx redirect response is refused, not followed', async () => { + const fetchFn = async () => ({ + type: 'opaqueredirect', status: 0, ok: false, headers: { get: () => null }, body: null, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' + && /redirects are not followed/.test(error.message), + ); +}); + +test('a plain 3xx status (non-opaque) is also refused', async () => { + const fetchFn = async () => ({ + type: 'basic', status: 302, ok: false, headers: { get: () => null }, body: null, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); +}); + +test('declared Content-Length above maxBytes is rejected before any body read', async () => { + let readerCreated = false; + const fetchFn = async () => ({ + ok: true, status: 200, type: 'basic', + headers: { get: (name) => (name.toLowerCase() === 'content-length' ? '999999' : null) }, + body: { getReader: () => { readerCreated = true; return { read: neverCalled('reader.read') }; } }, + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 100 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + assert.equal(readerCreated, false, 'the body must never be read once Content-Length alone exceeds the cap'); +}); + +test('an oversized streamed body (no declared Content-Length) is caught by the streamed cap and cancels the reader', async () => { + const chunk = new TextEncoder().encode('x'.repeat(8)); + const { response, wasCancelled } = chunkedOversizeResponse([chunk, chunk]); // 16 bytes total + const fetchFn = async () => response; + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, maxBytes: 10 }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + assert.equal(wasCancelled(), true, 'the reader must be cancelled the moment the cap is crossed'); +}); + +test('a timeout during the initial fetch aborts and reports source-unreachable', async () => { + const fetchFn = (url, opts) => new Promise((_, reject) => { + opts.signal.addEventListener('abort', () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 20 }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message), + ); +}); + +test('regression (finding 1): a hanging body read is bounded by timeoutMs, not left to hang forever', async () => { + const fetchFn = async () => hangingBodyResponse(); + const start = Date.now(); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn, timeoutMs: 300 }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable' && /timed out/.test(error.message), + ); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1500, `expected the body-read hang to be bounded by timeoutMs, took ${elapsed}ms`); +}); + +test('invalid JSON body -> source-invalid-json', async () => { + const fetchFn = async () => textResponse('{ this is not valid json'); + await assert.rejects( + () => resolveManifestSource('https://example.com/manifest.json', { fetchFn }), + (error) => error instanceof SourceError && error.reason === 'source-invalid-json', + ); +}); + +test('a valid https manifest resolves with origin "url"', async () => { + const payload = validManifest(); + const fetchFn = async () => jsonResponse(payload); + const result = await resolveManifestSource('https://example.com/manifest.json', { fetchFn }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'url'); +}); + +// ── npm: source ───────────────────────────────────────────────────── + +/** Injected into every npm-path test in place of the real resolveShim + * (src/lib/exec.mjs). On Windows, the real resolveShim rewrites npm/tar + * into PowerShell-shim invocations (a different command and a different + * argv shape), which would make these execFileFn stubs — written against + * the LOGICAL invocation (cmd === 'npm'/'tar', args starting with + * 'pack'/'-xzOf') — see something they don't recognize and fail, even + * though production behavior is correct. That Windows-shim rewriting is + * already covered by exec.mjs's own test suite; this module's tests only + * need to prove sources.mjs's own logic, platform-independently. */ +const passthroughResolveShim = (command, args) => ({ command, args, resolved: true }); + +/** Stub execFileFn that fabricates what real npm+tar would have produced: + * `npm pack` "writes" a .tgz into --pack-destination, and + * `tar -xzOf package/ak-adapter.json` "extracts" by returning the + * member content on stdout — never touching disk, matching the real + * implementation's stdout-only extraction (finding 2). No real npm/tar + * binary or network is invoked. + * - failTar: simulate tar exiting non-zero (member not in the archive). + * - emptyMember: simulate a symlink/non-regular member — tar succeeds + * (exit 0) but produces no data on stdout. */ +function fakeNpmExecFileFn(payload, { failTar = false, emptyMember = false } = {}) { + return async (_cmd, args) => { + if (args[0] === 'pack') { + const destIndex = args.indexOf('--pack-destination'); + const dest = args[destIndex + 1]; + fsSync.writeFileSync(path.join(dest, 'fake-pkg-1.0.0.tgz'), 'fake tarball bytes'); + return { stdout: '', stderr: '' }; + } + if (args[0] === '-xzOf') { + if (failTar) throw Object.assign(new Error('tar: package/ak-adapter.json not found in archive'), { code: 2 }); + if (emptyMember) return { stdout: '', stderr: '' }; + return { stdout: JSON.stringify(payload), stderr: '' }; + } + throw new Error(`unexpected command: ${args.join(' ')}`); + }; +} + +test('npm: happy path — resolves ak-adapter.json extracted (via stdout) from the package root, and cleans up its temp dir', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn(payload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + assert.equal(result.origin, 'npm'); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed after a successful resolve'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: scoped package spec with a version resolves correctly', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-scoped-')); + try { + const result = await resolveManifestSource('npm:@acme/hermes-adapter@2.1.0', { + execFileFn: fakeNpmExecFileFn(payload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: exact semver version is accepted', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-ver-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@1.2.3', { + execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: a dist-tag version ("latest") is accepted', async () => { + const payload = validManifest(); + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-tag-')); + try { + const result = await resolveManifestSource('npm:fake-pkg@latest', { + execFileFn: fakeNpmExecFileFn(payload), resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }); + assert.deepEqual(result.raw, payload); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 7): a git-shorthand disguised as a version is rejected before execFileFn is called', async () => { + await assert.rejects( + () => resolveManifestSource('npm:pkg@attacker/repo', { + execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /not a version or dist-tag/.test(error.message), + ); +}); + +test('npm (finding 7): a local-path-shaped version is rejected before execFileFn is called', async () => { + await assert.rejects( + () => resolveManifestSource('npm:pkg@../../x', { + execFileFn: neverCalled('execFileFn'), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + ); +}); + +test('npm: package-name injection attempts are rejected before execFileFn is ever called', async () => { + const attempts = ['npm:foo; rm -rf /', 'npm:foo$(x)', 'npm:foo bar', 'npm:foo`x`', 'npm:foo|bar']; + for (const source of attempts) { + await assert.rejects( + () => resolveManifestSource(source, { + execFileFn: neverCalled(`execFileFn for ${source}`), resolveShimFn: passthroughResolveShim, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid', + `expected ${source} to be rejected as source-invalid`, + ); + } +}); + +test('npm (finding 2): tar exiting non-zero (member not in archive) -> source-invalid, temp dir cleaned up', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-missing-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn({}, { failTar: true }), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /does not ship an ak-adapter\.json/.test(error.message), + ); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even on a failure path'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 2): a symlink member (empty stdout, tar exits 0) is refused explicitly and honestly', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-symlink-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn({}, { emptyMember: true }), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-invalid' + && /produced no content/.test(error.message), + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm: npm pack itself failing -> source-unreachable, temp dir cleaned up', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-packfail-')); + const execFileFn = async () => { throw new Error('npm ERR! 404 Not Found'); }; + try { + await assert.rejects( + () => resolveManifestSource('npm:does-not-exist@9.9.9', { + execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }), + (error) => error instanceof SourceError && error.reason === 'source-unreachable', + ); + const remaining = await fs.readdir(tmpBase); + assert.deepEqual(remaining, [], 'the mkdtemp working dir must be removed even when npm pack fails'); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('npm (finding 5): oversized extracted manifest (stdout) -> source-too-large', async () => { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-oversize-')); + const bigPayload = { name: 'hermes', padding: 'x'.repeat(1000) }; + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn: fakeNpmExecFileFn(bigPayload), + resolveShimFn: passthroughResolveShim, + tmpDir: tmpBase, + maxBytes: 100, + }), + (error) => error instanceof SourceError && error.reason === 'source-too-large', + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +test('regression (finding 14): control characters (C0 and C1, incl. U+009B CSI) are stripped and detail length is bounded before entering a SourceError', async () => { + const dirty = `line one\nline two\x1B[8A\x9B2Jmalicious cursor move${'z'.repeat(500)}`; + const execFileFn = async () => { throw new Error(dirty); }; + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'ak-src-npm-dirty-')); + try { + await assert.rejects( + () => resolveManifestSource('npm:fake-pkg@1.0.0', { + execFileFn, resolveShimFn: passthroughResolveShim, tmpDir: tmpBase, + }), + (error) => { + assert.ok(error instanceof SourceError); + assert.ok(!error.message.includes('\x1B'), 'ESC control byte must be stripped'); + assert.ok(!error.message.includes('\x9b'), 'C1 CSI (U+009B) must be stripped'); + assert.ok(!error.message.includes('\n'), 'newline must be stripped'); + assert.ok(error.message.length < 300, `detail must be length-bounded, got ${error.message.length}`); + return true; + }, + ); + } finally { + await fs.rm(tmpBase, { recursive: true, force: true }); + } +}); + +// ── integration: admitAdapters + resolve-before-hash ordering ────────────── + +test('admitAdapters: a mutated https-sourced manifest since consent was recorded is refused as consent-stale', async () => { + const first = validManifest(); + const second = validManifest({ version: '1.0.1' }); // valid, but different content -> different hash + + // Simulate: consent was recorded against an EARLIER resolve of `first`. + const firstValidated = validateAdapterManifest(first); + const consentedHash = hashManifest(firstValidated); + + // By the time admission actually runs, the remote now serves `second`. + const fetchFn = async () => jsonResponse(second); + const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw); + + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] }, + readManifest, + consent: trustingConsent({ hermes: consentedHash }), + }); + + assert.equal(results.length, 1); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'consent-stale'); +}); + +test('admitAdapters: an unchanged https-sourced manifest matching consent is admitted', async () => { + const manifest = validManifest(); + const validated = validateAdapterManifest(manifest); + const hash = hashManifest(validated); + + const fetchFn = async () => jsonResponse(manifest); + const readManifest = (source) => resolveManifestSource(source, { fetchFn }).then((r) => r.raw); + + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'https://example.com/hermes.json' }] }, + readManifest, + consent: trustingConsent({ hermes: hash }), + }); + + assert.equal(results[0].admitted, true); +}); diff --git a/tests/kit/admitted-grants.test.mjs b/tests/kit/admitted-grants.test.mjs new file mode 100644 index 0000000..a0123b0 --- /dev/null +++ b/tests/kit/admitted-grants.test.mjs @@ -0,0 +1,180 @@ +// The D2 keystone (ADR-0031 §1): a maintainer-granted capability +// (canBePrimary/commandStatusline) must become LIVE in effectiveHostRegistry() +// once applyAdmitted is handed a per-host grantsByName lookup — and must be +// impossible to smuggle anything else through that same seam. This file tests +// admitted.mjs's own overlay guarantee in isolation, with an injected +// grantsByName object (no fs, no grants.mjs) — the full bootstrap -> +// grants.mjs -> applyAdmitted wiring (including the manifest-hash-mismatch +// edit-invalidation case) is covered in adapter-admission.test.mjs instead. +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAdmitted, resetAdmitted, effectiveHostRegistry, effectivePrimaryHostIds, +} from '../../src/lib/adapters/admitted.mjs'; +import { HOST_REGISTRY, primaryHostIds } from '../../src/lib/adapters/registries.mjs'; +import { hostTierLabel } from '../../src/lib/hosts.mjs'; + +beforeEach(() => resetAdmitted()); + +/** A validated, admittable host entry — canBePrimary/commandStatusline start + * false (the manifest floor: external adapters may never self-declare + * either, see manifest.mjs's cap-can-be-primary/cap-command-statusline + * refusals), so every capability these tests observe as true came from the + * grant overlay, never the manifest. */ +function admittedHost(overrides = {}) { + return { + id: 'hermes', + label: 'Hermes', + install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, + configProjection: 'ruflo', + observability: [], + ...overrides, + }; +} + +// ── flag-off / nothing-admitted byte-identity ─────────────────────────────── + +test('nothing admitted: effectiveHostRegistry() is HOST_REGISTRY by identity, effectivePrimaryHostIds() == primaryHostIds()', () => { + assert.equal(effectiveHostRegistry(), HOST_REGISTRY); + assert.deepEqual(effectivePrimaryHostIds(), primaryHostIds()); +}); + +test('applyAdmitted with no grantsByName leaves capabilities at the manifest floor (byte-identical entry)', () => { + applyAdmitted([{ entry: admittedHost() }]); + const entry = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(entry.capabilities.canBePrimary, false); + assert.equal(entry.capabilities.commandStatusline, false); + assert.equal(effectivePrimaryHostIds().includes('hermes'), false); + assert.deepEqual([...effectivePrimaryHostIds()].sort(), [...primaryHostIds()].sort()); +}); + +test('applyAdmitted({grantsByName: {}}) — no entry for this host — is identical to no grantsByName at all', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: {} }); + const entry = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(entry.capabilities.canBePrimary, false); +}); + +// ── the keystone: a granted capability lights up ──────────────────────────── + +test('a granted canBePrimary makes effectiveHostRegistry() show it true, and effectivePrimaryHostIds() include the host', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } }); + const entry = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(entry.capabilities.canBePrimary, true); + assert.ok(effectivePrimaryHostIds().includes('hermes')); +}); + +test('a granted canBePrimary lights up hostTierLabel — "drives sessions · can lead"', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } }); + assert.equal(hostTierLabel('hermes'), 'drives sessions · can lead'); +}); + +test('without the grant, hostTierLabel reflects the ungranted (routing-only external) tier', () => { + applyAdmitted([{ entry: admittedHost() }]); + assert.equal(hostTierLabel('hermes'), 'routing only · external adapter · not AQE'); +}); + +test('a granted commandStatusline flips only that flag, leaving canBePrimary at the manifest floor', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { commandStatusline: true } } }); + const entry = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(entry.capabilities.commandStatusline, true); + assert.equal(entry.capabilities.canBePrimary, false); + assert.equal(effectivePrimaryHostIds().includes('hermes'), false); +}); + +test('a host absent from grantsByName stays at its manifest floor while a sibling host in the same call is granted', () => { + applyAdmitted([ + { entry: admittedHost({ id: 'hermes' }) }, + { entry: admittedHost({ id: 'iris' }) }, + ], { grantsByName: { hermes: { canBePrimary: true } } }); + const hosts = effectiveHostRegistry(); + assert.equal(hosts.find((h) => h.id === 'hermes').capabilities.canBePrimary, true); + assert.equal(hosts.find((h) => h.id === 'iris').capabilities.canBePrimary, false); +}); + +// ── defensive allow-list: the overlay can NEVER carry anything else ───────── + +test('a grantsByName entry cannot flip canRouteActivities, transcripts, or any non-grantable capability', () => { + applyAdmitted([{ entry: admittedHost({ + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: false, + commandStatusline: false, transcripts: false, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + }) }], { + grantsByName: { + hermes: { + canBePrimary: true, canRouteActivities: true, transcripts: true, + usage: true, nativeMcpConfig: true, nativeGuidance: true, canDriveSession: true, + }, + }, + }); + const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(capabilities.canBePrimary, true, 'the one grantable flag actually granted must flip'); + assert.equal(capabilities.canRouteActivities, false, 'not grantable — must stay at the manifest floor'); + assert.equal(capabilities.transcripts, false, 'not grantable — must stay at the manifest floor'); + assert.equal(capabilities.usage, false, 'not grantable — must stay at the manifest floor'); + assert.equal(capabilities.nativeMcpConfig, false, 'not grantable — must stay at the manifest floor'); + assert.equal(capabilities.nativeGuidance, false, 'not grantable — must stay at the manifest floor'); +}); + +test('a grantsByName entry can never introduce a key outside the eight schema-defined capabilities (e.g. aqeProvider)', () => { + applyAdmitted([{ entry: admittedHost() }], { + grantsByName: { hermes: { canBePrimary: true, aqeProvider: 'claude-code' } }, + }); + const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(capabilities.aqeProvider, undefined, 'aqeProvider must never reach a host capabilities object via a grant'); + assert.deepEqual(Object.keys(capabilities).sort(), [ + 'canBePrimary', 'canDriveSession', 'canRouteActivities', 'commandStatusline', + 'nativeGuidance', 'nativeMcpConfig', 'transcripts', 'usage', + ]); +}); + +test('a grant can never flip a capability back to false — it can only raise, never lower', () => { + applyAdmitted([{ entry: admittedHost({ + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: false, + nativeMcpConfig: false, nativeGuidance: false, + }, + }) }], { grantsByName: { hermes: { canBePrimary: false } } }); + const { capabilities } = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.equal(capabilities.canBePrimary, false); +}); + +// ── deep-freeze holds on the grant-augmented entry too ────────────────────── + +test('the grant-augmented entry (and its capabilities) is still deep-frozen', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } }); + const entry = effectiveHostRegistry().find((h) => h.id === 'hermes'); + assert.ok(Object.isFrozen(entry)); + assert.ok(Object.isFrozen(entry.capabilities)); + assert.throws(() => { 'use strict'; entry.capabilities.canBePrimary = false; }, TypeError); + assert.equal(entry.capabilities.canBePrimary, true, 'the mutation attempt must not have taken effect'); +}); + +// ── F-6 (LOW hardening): prototype-chain safety on the grantsByName lookup ── +// entry.id is a consented-but-adapter-supplied string; a host literally named +// 'constructor' must never resolve to Object.prototype.constructor via `[]` +// lookup on a plain object literal (grantsByName here is deliberately `{}`, +// NOT Object.create(null), to prove the READ side's own Object.hasOwn guard +// holds independently of admission.mjs building it null-prototype). + +test('a host id of "constructor" is never misread as granted via the prototype chain (plain-object grantsByName)', () => { + applyAdmitted([{ entry: admittedHost({ id: 'constructor' }) }], { grantsByName: {} }); + const entry = effectiveHostRegistry().find((h) => h.id === 'constructor'); + assert.equal(entry.capabilities.canBePrimary, false); + assert.equal(entry.capabilities.commandStatusline, false); +}); + +test('effectiveHostRegistry() still returns HOST_REGISTRY entries by identity alongside a granted admitted entry', () => { + applyAdmitted([{ entry: admittedHost() }], { grantsByName: { hermes: { canBePrimary: true } } }); + const combined = effectiveHostRegistry(); + assert.ok(HOST_REGISTRY.every((host) => combined.includes(host)), 'built-in entries must be unchanged references'); +}); diff --git a/tests/kit/conformance-tiers.test.mjs b/tests/kit/conformance-tiers.test.mjs new file mode 100644 index 0000000..b099777 --- /dev/null +++ b/tests/kit/conformance-tiers.test.mjs @@ -0,0 +1,609 @@ +// 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, grantCapability, grantedCapabilitiesFor, recordTierResult, +} 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'); +}); + +// ── statusline: honest gated, ungranted (untouched this wave) ────────────── + +test('statusline tier is gated (never passed) against acme with no grant recorded', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['statusline'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'gated', JSON.stringify(tier.checks)); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.detail, /ak-local/); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +// ── primary-eligible: honest activity-routing dependency ─────────────────── + +test('primary-eligible tier is skipped when activity-routing did not pass this run', async () => { + const grantsFile = tempGrantsFile(); + // No haveFn override: acme's detection.bin will never be on a test + // machine's real PATH, so activity-routing genuinely fails this run — + // primary-eligible must short-circuit on that. + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped', JSON.stringify(tier.checks)); + assert.notEqual(tier.status, 'passed'); + assert.match(tier.checks[0].detail, /activity-routing did not pass/); + assert.equal(grantsFor('acme', { file: grantsFile }), null); +}); + +test('primary-eligible tier is skipped on a missing activity-routing prerequisite even when a real grant was earned earlier through the sanctioned flow', async () => { + const grantsFile = tempGrantsFile(); + // Earn the grant honestly first, via the real sanctioned sequence (ADR-0031 + // §2): a run with activity-routing genuinely passing records a real + // 'passed' primary-eligible tier, which grantCapability (grants.mjs) + // requires before it will confer canBePrimary — never hand-seeded. + const earned = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + haveFn: async () => true, + }); + assert.equal(earned.tiers[0].status, 'passed', JSON.stringify(earned.tiers[0].checks)); + grantCapability('acme', 'canBePrimary', { hash: earned.hash }, { file: grantsFile }); + + // Re-run WITHOUT the haveFn override — activity-routing genuinely fails + // this run, so primary-eligible must short-circuit regardless of the (now + // real, honestly earned) grant already sitting in the store. + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'skipped', JSON.stringify(tier.checks)); + assert.match(tier.checks[0].detail, /activity-routing did not pass/); +}); + +// ── primary-eligible: the real exercise, evidence-first (F-1 fix) ────────── +// F-1 (security review, HIGH, fixed): the exercise runs UNCONDITIONALLY — +// never gated on a pre-existing canBePrimary grant. Gating it on the grant +// it exists to justify was a deadlock: grantCapability (grants.mjs) refuses +// unless a 'passed' primary-eligible tier is ALREADY recorded at this hash, +// so the only way it could ever pass before this fix was hand-seeding a fake +// tier result first — defeating the whole earned-evidence point of +// ADR-0031 §2 ("passing a tier records evidence; the maintainer's grant +// turns evidence into capability" — evidence comes FIRST). These tests prove +// the corrected, sanctioned sequence with NO hand-seeding anywhere. + +test('primary-eligible tier GENUINELY PASSES via the real exercise with NO pre-existing grant — evidence records first, per ADR-0031 §2', async () => { + const grantsFile = tempGrantsFile(); + assert.equal(grantsFor('acme', { file: grantsFile }), null, 'sanity: nothing recorded before this run'); + + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + haveFn: async () => true, + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'passed', JSON.stringify(tier.checks, null, 2)); + // Real evidence, not a fabricated string — worded to what was actually + // demonstrated (a direct run plus a genuine ADR-0019 escalation onto the + // same host: ladder + execution-contract plumbing), not an overclaim about + // agentic task quality (F-7). + assert.match(tier.evidence, /'acme' completed a direct \(non-escalated\) run/); + assert.match(tier.evidence, /genuine ADR-0019 escalation onto itself/); + assert.match(tier.evidence, /2 attempts:/); + assert.match(tier.evidence, /=failed -> acme=succeeded/); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'passed'); + assert.match(record.tiers['primary-eligible'].evidence, /genuine ADR-0019 escalation/); +}); + +test('the sanctioned §2 sequence end to end: a freshly-passed tier with NO prior grant lets grantCapability confer a LIVE canBePrimary', async () => { + const grantsFile = tempGrantsFile(); + assert.equal(grantsFor('acme', { file: grantsFile }), null, 'sanity: nothing recorded before this run'); + + // Step 1: the real exercise earns the evidence (no grant exists yet). + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + haveFn: async () => true, + }); + assert.equal(report.tiers[0].status, 'passed', JSON.stringify(report.tiers[0].checks)); + assert.equal( + grantedCapabilitiesFor('acme', report.hash, { file: grantsFile }).canBePrimary, + undefined, + 'passing the tier alone must never itself grant the capability', + ); + + // Step 2: the maintainer's explicit act (ADR-0031 §1) — only possible now + // because the tier is genuinely 'passed' at this hash, not because + // anything above hand-seeded it. + grantCapability('acme', 'canBePrimary', { hash: report.hash }, { file: grantsFile }); + assert.equal(grantedCapabilitiesFor('acme', report.hash, { file: grantsFile }).canBePrimary, true); +}); + +test('primary-eligible tier: a caller-supplied legacy exercisePrimaryEligible option has no effect — there is no injection seam, only the real exercise ever runs', async () => { + const grantsFile = tempGrantsFile(); + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, + readManifest: readManifestFromFile, + tiers: ['primary-eligible'], + grantsFile, + haveFn: async () => true, + // Not a real option on runTieredConformance anymore (silently ignored) — + // proves a caller cannot launder a fabricated pass through what used to + // be the injection point. + exercisePrimaryEligible: async () => ({ ok: true, detail: 'FAKE-LAUNDERED-PASS' }), + }); + const [tier] = report.tiers; + assert.equal(tier.status, 'passed'); + assert.notEqual(tier.evidence, 'FAKE-LAUNDERED-PASS'); + assert.match(tier.evidence, /genuine ADR-0019 escalation onto itself/); + const record = grantsFor('acme', { file: grantsFile }); + assert.notEqual(record.tiers['primary-eligible'].evidence, 'FAKE-LAUNDERED-PASS'); +}); + +// ── N-1 (security-review follow-up): the persist loop's un-earn path ─────── +// recordTierGate already voids a live capability when a grant-bearing tier +// is downgraded to 'gated' at the same hash (F-2 above). These tests prove +// the mirrored path for a genuine RE-FAIL: a grant-bearing tier that runs +// 'failed' at the SAME (unchanged) hash a capability was earned at must void +// both the stored tier and the capability — never leave a live capability +// standing on evidence just shown non-reproducible. + +/** A custom execution.run.hook (inline `node -e`, no fixture file edits) that + * succeeds for activity-routing's own worker (id 'conformance-w1' — never + * matches the 'primary-eligible' prefix check) but genuinely fails for + * primary-eligible's two workers (ids 'primary-eligible-direct'/ + * '-escalation'), gated behind an on-disk flag file the test flips between + * runs. This lets the SAME manifest content (same hash) genuinely PASS + * primary-eligible on one run and genuinely FAIL it on the next, while + * activity-routing — primary-eligible's own real prerequisite — keeps + * genuinely passing both times. No '/' or '\' anywhere in the inline script + * text: commandIsUnanchorable (execution/admitted.mjs) inspects every arg, + * and a slash would make this otherwise-absolute-argv0 command read as + * needing baseDir anchoring, which this mem:// source has none of. */ +function flakyPrimaryEligibleManifest(flagPath) { + const NODE = process.execPath; + const script = 'const fs=require("fs");let i="";process.stdin.on("data",c=>{i+=c});' + + 'process.stdin.on("end",()=>{const id=process.env.AK_WORKER_ID||"";const flag=process.argv[1];' + + 'if(id.indexOf("primary-eligible")===0&&fs.existsSync(flag)){process.stderr.write("deliberate re-run failure");process.exit(3);return}' + + 'process.stdout.write(JSON.stringify({summary:"ok "+id,observedModel:null,provider:"acme-flaky"}))});'; + const raw = rawAcmeManifest(); + // No baseDir exists for this mem:// source, so the fixture's own relative + // lifecycle.detect.hook.command would be refused as unanchorable (F1) — + // orthogonal to what this test isolates, so drop it, same pattern the + // other mem:// tests in this file already use. + delete raw.lifecycle; + raw.execution = { run: { hook: { command: [NODE, '-e', script, flagPath] } } }; + return raw; +} + +test('N-1: a grant-bearing tier that RE-FAILS at the SAME hash voids the stored tier and the live capability, leaving an unrelated grant untouched', async () => { + const grantsFile = tempGrantsFile(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-conformance-tiers-refail-')); + const flagPath = path.join(dir, 'fail-after-here'); + const manifestSource = 'mem://acme-flaky-primary-eligible'; + const readManifest = async () => flakyPrimaryEligibleManifest(flagPath); + + // Step 1: earn primary-eligible honestly (no flag file yet -> the flaky + // hook succeeds for every worker id), grant canBePrimary, and separately + // force-record + grant an UNRELATED statusline capability at the same hash + // — the control this test proves survives untouched. + const earned = await runTieredConformance({ + manifestSource, readManifest, tiers: ['primary-eligible'], grantsFile, haveFn: async () => true, + }); + assert.equal(earned.tiers[0].status, 'passed', JSON.stringify(earned.tiers[0].checks, null, 2)); + grantCapability('acme', 'canBePrimary', { hash: earned.hash }, { file: grantsFile }); + recordTierResult('acme', 'statusline', { hash: earned.hash, evidence: 'footer renders' }, { file: grantsFile }); + grantCapability('acme', 'commandStatusline', { hash: earned.hash }, { file: grantsFile }); + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { canBePrimary: true, commandStatusline: true }, + ); + + // Step 2: flip the flag -> the SAME manifest content (same hash, same + // command) now genuinely fails the primary-eligible exercise, while + // activity-routing (worker id 'conformance-w1') still genuinely passes. + fs.writeFileSync(flagPath, ''); + const refailed = await runTieredConformance({ + manifestSource, readManifest, tiers: ['primary-eligible'], grantsFile, haveFn: async () => true, + }); + assert.equal(refailed.hash, earned.hash, 'sanity: same manifest content, same hash'); + const [tier] = refailed.tiers; + assert.equal(tier.status, 'failed', JSON.stringify(tier.checks, null, 2)); + + const record = grantsFor('acme', { file: grantsFile }); + assert.equal(record.tiers['primary-eligible'].status, 'failed'); + assert.ok(!Object.hasOwn(record.capabilities ?? {}, 'canBePrimary'), 'canBePrimary must be voided, not left standing on disproven evidence'); + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { commandStatusline: true }, + 'an unrelated live grant earned at the same hash must survive a primary-eligible re-fail', + ); +}); + +test('N-1: a SKIPPED primary-eligible result (its own prerequisite did not run/pass this run) does NOT void an already-earned capability', async () => { + const grantsFile = tempGrantsFile(); + // Earn the grant honestly first, same sanctioned flow the F-1 tests above use. + const earned = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['primary-eligible'], grantsFile, + haveFn: async () => true, + }); + assert.equal(earned.tiers[0].status, 'passed', JSON.stringify(earned.tiers[0].checks)); + grantCapability('acme', 'canBePrimary', { hash: earned.hash }, { file: grantsFile }); + assert.deepEqual(grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), { canBePrimary: true }); + + // Re-run WITHOUT the haveFn override -> activity-routing genuinely fails + // this run (acme's detection.bin is never really on a test machine's + // PATH), so primary-eligible short-circuits to 'skipped' — its own + // prerequisite was never evaluated this run, which is ambiguous, not a + // disproof of anything already earned. + const report = await runTieredConformance({ + manifestSource: VALID_MANIFEST_PATH, readManifest: readManifestFromFile, tiers: ['primary-eligible'], grantsFile, + }); + assert.equal(report.tiers[0].status, 'skipped', JSON.stringify(report.tiers[0].checks)); + + assert.deepEqual( + grantedCapabilitiesFor('acme', earned.hash, { file: grantsFile }), + { canBePrimary: true }, + 'a skipped (never-evaluated) tier must never void an already-earned capability', + ); +}); + +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 three genuinely-passed tiers (admission, activity-routing, primary-eligible) plus an honestly gated statusline, 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'); + // primary-eligible now genuinely passes in the same run (F-1 fix): its + // real exercise is unconditional, not gated on a pre-existing grant — no + // grant was seeded anywhere in this test. + assert.equal(byTier['primary-eligible'], 'passed'); + 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'].status, 'passed'); + assert.ok(record.tiers['primary-eligible'].evidence.length > 0); + 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..cb70480 --- /dev/null +++ b/tests/kit/external-lifecycle.test.mjs @@ -0,0 +1,439 @@ +// 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 (D4, ADR-0031 P3 tracked follow-up) now +// emits a lean, generic subsystem row (subsystem === hostId) for any admitted +// lifecycle host that lifecycleExecutionEnabled() gates in for this run +// (cfg enablement AND the experimental flag — see status.mjs's +// admittedLifecycleFallbackRows). That closes the gap this file used to +// pin as a documented gap: an admitted host's row now enters sync's plan, +// so its already-wired lifecycle loop body runs for real, exactly like +// setup's and uninstall's below. +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 '