# harness-adapter-config — Design

audience: AI coding agents first. Contract-level: seams + decisions, NOT code bodies.
slug: `harness-adapter-config` · date: 2026-06-30

## Purpose

Define the **adapter registry + per-adapter config + health** surface the harness core is missing. Surfaced as a
GAP while specing the Web UI (sub-project 3): the Adapter Setup page (enable/disable adapters, granular per-adapter
model selection, health) binds a contract that does not exist — `harness-control-api` covers *runs*, `PRESETS.md`
covers *seat→binding resolution*, but nothing owns the **catalog of engines** a binding may point to, whether each
is enabled, which of its models are usable, or whether it is reachable right now.

This spec fills exactly that gap and no more. It is a harness-core prerequisite: the Web UI's component 8 is
build-gated on it; presets/run-configs validate against it. It introduces no run-scoped state — adapter config is
**global** (per machine + per repo), never per run.

## Ground truth (read before designing — do not re-derive)

- **An "adapter" IS an engine wrapper.** `WRAPPER-CONTRACT.md`: every engine (claude / codex / cursor / north /
  opencode / openrouter / …) is a shell wrapper satisfying one fixed CLI shape; "a new engine = a new wrapper +
  **one registry line**." That registry line is what this spec defines — it does not exist yet (grep: the phrase
  appears in WRAPPER-CONTRACT, the artifact is absent).
- **Presets already bind `seat → {wrapper, model}`** (`PRESETS.md`, `spec/presets.schema.json`). A binding names a
  `wrapper` path + a `model` string directly, with NO catalog to validate against — so a preset can today name a
  wrapper that isn't installed or a model the engine doesn't accept, and only fail at dispatch. `PRESETS.md` states
  "**The future UI edits THIS**" of the *run-config*; this spec gives that UI (and the validator) the catalog to
  edit *against*.
- **Model pinning is load-bearing & footgun-prone.** WRAPPER-CONTRACT §`--model`: an unpinned engine silently runs
  whatever model the user last selected while the log claims the intended one (the shipped `ca.sh` regression
  2026-06-09..15). The curated per-adapter model allowlist + a validate-time cross-check closes this at config
  time, not dispatch time.
- **Secrets are sourced in-subprocess, NEVER inlined or leaked** (WRAPPER-CONTRACT §behavior-2: `na.sh` sources
  `NORTH.env` inside its own subprocess). Any adapter credential config MUST follow this — the registry stores a
  *pointer* to a per-machine env file, never the secret value, and that file is gitignored.
- **Repo layout** (`docs/specs/2026-06-27-mega-plan-harness-design.md`): shared config lives in `presets/` (repo-
  tracked `preset/v1` JSON) validated by `presets/_validate.mjs` — a **zero-dep, ajv-free hand-rolled** validator
  (repo policy: no npm deps). Runtime/per-machine state lives under `$HARNESS_HOME` (≈ `~/.harness/`: the
  control-api run registry `~/.harness/runs/*.json` + `sock/`). `bin/okf` is the precedent for a zero-dep CLI the
  Web gateway shells out to.
- **Adapter config is GLOBAL, not run-scoped** → it is NOT served over the per-run unix socket. The Web gateway
  shells directly to this spec's CLI, exactly as it shells to `bin/okf` (Web UI spec component 8 / data flow).

## Architecture

Three physical surfaces behind one CLI seam. The CLI is the only reader/writer the rest of the harness (validator,
Web gateway, future runner) talks to — none of them parse the files directly.

```
  repo-tracked CATALOG            per-machine STATE              live HEALTH (ephemeral)
  presets/adapters.json          $HARNESS_HOME/adapters.json     wrapper `--health` probe
  {id,kind,wrapper,models,        {<id>:{enabled,                 exit 0|3 + status JSON
   availableModelsCmd?,envFile?}}  config, modelAllow?}}          (never persisted)
        │                               │                              │
        └───────────────┬───────────────┴──────────────┬──────────────┘
                        ▼                               ▼
                 bin/harness-adapter   (zero-dep Node CLI — the SEAM)
                 merges catalog ⊕ state ⊕ (on demand) health → effective descriptor
                        │                               │
            presets/_validate.mjs               Web gateway  GET|POST /api/adapters
            (cross-check bindings)              (shells out, like bin/okf)
```

**Why split catalog (repo) from state (machine):** the *definition* of an engine (its wrapper, kind, model
allowlist) is shared, versioned config a preset author commits; *enabled* + credentials + machine-local overrides
are per-machine and MUST NOT be committed (secrets, and "which engines this box can run" differ per machine). The
CLI merges them into one effective descriptor so callers never see the split. Health is computed on demand, never
written to either file (a stored health value is stale the instant it's written).

## Build sequencing (this spec is partly Plan-A-gated — make the chain visible)

Three of five components touch files that **Plan A** (the harness core, `docs/plans/2026-06-27-mega-plan-harness.jsonl`)
creates or owns — they cannot build until Plan A lands:

| Component | Plan-A-gated? | Why |
|-----------|---------------|-----|
| 3. `bin/harness-adapter` CLI + 2. `$HARNESS_HOME/adapters.json` state | **No — independent** | new files, touch nothing Plan A owns; can land anytime |
| 1. catalog `presets/adapters.json` + `spec/adapters.schema.json` | **Yes** | `presets/` dir + its validator are Plan A artifacts |
| 5. `presets/_validate.mjs` cross-check | **Yes** | the validator does not exist until Plan A builds it (confirmed: only `spec/` is tracked today) |
| 4. wrapper `--health` mode | **Yes** | edits `na.sh`/`ca.sh`/`codex.sh` — the SAME wrapper files control-api is gated behind to avoid concurrent editors |

**Consequence:** the Web UI spec's "component 8 build-gated on adapter-config landing" is **transitively gated on
Plan A** for the catalog/validator/health parts. The CLI + state file can be built and unit-tested standalone first
(against a fixture catalog), so the Web UI Adapter page can bind a working CLI early; the validator cross-check and
`--health` probe attach once Plan A's `presets/` + wrappers exist. Record this gating in memory alongside
control-api's.

## Components & seams

### 1. Adapter catalog — `presets/adapters.json` + `spec/adapters.schema.json` (repo-tracked, versioned)

The shared definition of every adapter. New artifact, validated like presets (ajv-free). Pin the shape
(`adapters/v1`):

```jsonc
{ "version": "adapters/v1", "adapters": [
  { "id": "codex",                       // kebab; the registry key, stable
    "kind": "cli",                       // open enum: cli | proxy | api — informational grouping
    "wrapper": "wrappers/codex.sh",      // path per WRAPPER-CONTRACT (rel ~/.claude or repo, or absolute)
    "models": ["gpt-5.5-high","gpt-5.5-low"],  // CURATED allowlist — what a preset binding MAY pin for this adapter
    "listModels": true,                  // OPTIONAL bool: this wrapper implements the `--list-models` mode (component 4) for the full picker list
    "envFile": "wrappers/CODEX.env" } ]  // OPTIONAL pointer to the per-machine secret/env file (gitignored); value NEVER inlined
] }
```

- `models` = the operator-curated set presets may bind (the brief's "OpenRouter has hundreds, we need 1–2" — this
  is the 1–2). Distinct from `availableModels` (the full list, see component 3).
- `wrapper` MUST satisfy `WRAPPER-CONTRACT.md`. The schema validates shape only; existence/health is a CLI concern.
- **`wrapper` path is UNIQUE across catalog entries** (validated — see component 5). Bindings carry only
  `{wrapper, model}` (no adapter id), so the validator's binding→adapter match keys on the wrapper path; a path
  shared by two adapters would make that match ambiguous. Two *seats* binding the same wrapper (PRESETS.md's `codex`
  preset) is fine — that is one adapter bound twice, not two adapters.
- The full picker list comes ONLY from the declared `<wrapper> --list-models` mode (component 4), gated by the
  catalog `listModels` bool — **never an inline command string**. An arbitrary-exec field in committed, PR-able
  config is a data-file-that's-secretly-executable footgun (a JSON-diff reviewer is in data-mindset, not
  code-safety-mindset); routing all executed code through the vetted wrapper file removes that carve-out. The
  contract gains exactly two optional modes — `--health` and `--list-models`.
- Schema authored as `spec/adapters.schema.json` (draft 2020-12, `additionalProperties:false`), mirroring
  `presets.schema.json` conventions.

### 2. Per-machine state — `$HARNESS_HOME/adapters.json` (gitignored, machine-local)

`{ "version":"adapters-state/v1", "state": { "<id>": { "enabled": bool, "config": {…}, "modelAllow"?: [..] } } }`.
Holds only what is machine-specific: the **enabled** flag (default: absent ⇒ disabled — fail-closed, an
unconfigured adapter is NOT silently usable), non-secret per-adapter `config` (e.g. base-url, region), and an
OPTIONAL `modelAllow` narrowing of the catalog `models` on this machine. Secrets are NEVER here — they live in the
`envFile` the catalog points to (sourced in-subprocess by the wrapper). Absent file ⇒ all adapters disabled.

### 3. CLI — `bin/harness-adapter` (zero-dep Node; the one read/write seam)

The only component that touches the files. Verbs (exit `0` ok / `2` usage / `3` unavailable, matching the harness
exit-code floor):

```
harness-adapter list [--json]                 → merged effective descriptors (catalog ⊕ state; health "unknown" unless --probe)
harness-adapter list --probe [--json]         → as above + live health per adapter (runs each wrapper --health)
harness-adapter show <id> [--json]            → one effective descriptor incl. availableModels (cached) + health
harness-adapter enable <id> | disable <id>    → flip state.enabled; write adapters state file atomically
harness-adapter set-models <id> <m...>        → set state.modelAllow (subset of catalog models; reject non-subset → exit 2)
harness-adapter config <id> <k=v...>          → set non-secret config keys into state.config
harness-adapter set-secret <id> <KEY> [-]     → write a credential to the adapter's envFile (0600, gitignored); value read from stdin (`-`) or prompt, NEVER argv (avoids shell history/ps leak); never echoed back
harness-adapter health [<id>] [--json]        → probe one/all; exit 0 all-healthy, 3 any-down
harness-adapter available-models <id> [--json]→ run `<wrapper> --list-models` (if catalog listModels), cache to $HARNESS_HOME/adapters.cache.json (per-id, TTL 24h; --refresh forces), return full list
```

**Effective descriptor** (the merged shape `list --json` emits — IS the contract the Web UI consumes, kept
identical to the Web UI spec component 8): `{id, kind, enabled, models, availableModels?, config, health}` where
`models` = `state.modelAllow ?? catalog.models`, `health` ∈ `healthy|down|unknown`. Fail-closed: unknown adapter id
→ exit 2; malformed catalog/state → exit 2 with detail (never a silent empty list); write verbs are atomic
(temp-file + rename) so a crash never leaves a half-written state file.

### 4. Wrapper `--health` + `--list-models` modes — minimal `WRAPPER-CONTRACT.md` addition (OPTIONAL per wrapper)

Adds exactly TWO optional, backward-compatible modes to the contract — so every piece of executed code routes
through a vetted wrapper file, never an inline string in committed config.

**`<wrapper> --list-models`** (gated by catalog `listModels`): prints the engine's full available-model list (one
id per line, or a `{"models":[…]}` JSON line), dispatches nothing. Absent ⇒ the picker falls back to the curated
`models` only. This is the sole source of the full model list (replaces any inline command).

**`<wrapper> --health`** runs the engine's precondition check ONLY (the same
check that otherwise produces exit `3` before a real dispatch — auth present, proxy reachable, CLI on PATH), runs
NOTHING else, dispatches no work, and:
- exit `0` + optional stdout `{"ok":true,"detail":"…","latencyMs":N}` ⇒ healthy
- exit `3` + `{"ok":false,"detail":"…"}` ⇒ down (the reason surfaces in the UI)
- a wrapper that does NOT implement `--health` ⇒ the CLI reports `health:"unknown"` (never fabricates healthy).

This reuses the precondition logic wrappers already have for their pre-dispatch exit-3; it does not invent a new
probe. The contract addition is small and backward-compatible (absent ⇒ unknown). Authoring it is part of this
sub-project so the contract stays single-sourced.

### 5. Validator cross-check — extend `presets/_validate.mjs` (closes the silent-wrong-model footgun)

`_validate.mjs` already asserts presets/run-configs against their schemas. Extend it (still zero-dep) to load the
catalog and assert, for every preset binding and run-config override: (a) `binding.wrapper` resolves to a catalog
adapter (by wrapper path), and (b) `binding.model` ∈ that adapter's catalog `models`. A binding naming an unknown
wrapper or an unlisted model → validation error at author/CI time, NOT at dispatch. This is the payoff for having a
catalog at all: the WRAPPER-CONTRACT model-pin footgun becomes a static error.

## Data flow

Operator (Web UI or shell) → `bin/harness-adapter enable/disable/set-models/config` → atomic write to
`$HARNESS_HOME/adapters.json` (catalog untouched — definitions are committed, not UI-edited). Web UI Adapter page →
`GET /api/adapters` → gateway shells `harness-adapter list --probe --json` → effective descriptors + health →
render; toggle/model-select → `POST /api/adapters/:id` → gateway shells the matching verb. Preset author edits
`presets/*.json` → `presets/_validate.mjs` cross-checks against the catalog → CI/commit gate. Runtime dispatch is
UNCHANGED — the runner still resolves seat→binding per `PRESETS.md`; this spec only adds a *catalog to validate
against* and a *health/enable surface*, not a new resolution path.

**What `enabled` enforces (be precise — it is author-time + UI, NOT a runtime kill):** `enabled`, the `models`
allowlist, and `health` are enforced by the **validator** (`_validate.mjs` rejects a preset binding to a
disabled/unknown adapter or an unlisted model) and surfaced by the **UI** (a disabled adapter isn't offered). The
**runner does NOT consult the catalog or state at dispatch** — wiring `enabled` into the resolution ladder would
reopen Plan A's `PRESETS.md` resolution path, out of scope here. So a hand-edited run-config that bypasses the
validator *could* still dispatch a "disabled" adapter; the deterministic floor + wrapper exit-3 remain the runtime
safety net. "Fail-closed" here means **absent state ⇒ treated disabled by validator/UI**, not a dispatch-time
block. Runtime enforcement is a deliberate future option (an optional resolution-ladder rung), not v1.

## Error handling

Fail-closed throughout (repo invariant). Absent state file ⇒ all adapters disabled (never default-enabled). Unknown
id / non-subset model / secret-looking config key ⇒ exit `2` with detail. Malformed catalog or state ⇒ exit `2`,
never a silent empty/partial list. Health probe of a down adapter ⇒ `health:"down"` + the wrapper's reason (the UI
shows *why*, not just a red dot); a wrapper lacking `--health` ⇒ `unknown`, never assumed healthy. All writes
atomic (temp+rename). Secrets are **write-only**: `set-secret` writes them to the adapter's `envFile` (created
`0600`, gitignored, never the repo), reading the value from stdin/prompt (never argv), and **no verb ever reads,
logs, or returns a secret value** — validity is observable solely through the health probe's pass/fail. A secret
written via the UI lands in the exact `envFile` the wrapper sources in-subprocess (NORTH.env discipline).

## Testing strategy

- **CLI verbs (`test/*.sh`, both branches):** enable→list shows enabled; disable→absent⇒disabled; `set-models` with
  a non-subset model → exit 2; `config` with `api_key=…` → exit 2 (secret rejected); atomic write survives a killed
  process (no half file).
- **Merge precedence:** catalog ⊕ state fixture → effective descriptor has `modelAllow` narrowing applied,
  `enabled` from state, secrets absent from all output.
- **Schema:** `spec/adapters.schema.json` accepts the worked catalog, rejects a missing `wrapper`/`id` and unknown
  top-level keys (ajv-free validator parity with presets).
- **Wrapper modes:** `--health` stub exit 0 ⇒ healthy, exit 3 ⇒ down + detail, no `--health` ⇒ unknown;
  `--list-models` stub ⇒ `available-models` returns it + caches; `listModels:false` ⇒ picker shows curated only.
- **Validator cross-check:** a preset binding naming an unknown wrapper → `_validate.mjs` fails; a model not in the
  adapter's `models` → fails; a clean preset passes.
- **Gateway contract (in the Web UI plan, named here):** `harness-adapter list --json` output validates against the
  Web UI's `{id,kind,enabled,models,availableModels?,config,health}` descriptor.

## Architecture Decisions

- **Adapter = registered wrapper; the registry is the missing "one registry line."** Not a new engine abstraction —
  it gives the existing wrapper/preset model the catalog WRAPPER-CONTRACT already references but never defined. Deep:
  presets, validator, UI, and future runner all consume it; none parse the files directly. Reversible — additive,
  changes no existing resolution path.
- **Split repo-tracked catalog from per-machine state.** Definitions are shared/versioned (a preset author commits
  them); enabled-flags + credentials + local overrides are per-machine and must never be committed. Rejected a
  single file: it would either force secrets/enable-state into the repo or block committing engine definitions.
  The CLI hides the split behind one effective descriptor.
- **Health is computed on demand, NEVER persisted.** A stored health value is stale the moment it's written. The
  probe reuses the wrapper's existing pre-dispatch exit-3 precondition logic via a minimal optional `--health`
  mode — no new probe logic, backward-compatible (absent ⇒ `unknown`, never assumed healthy).
- **Secrets are UI-writable but write-only, to a 0600 gitignored env file (resolved decision).** In the established
  localhost / no-auth / single-user model, refusing UI credential writes buys ~no security (a local POSTer can read
  the env file directly) while contradicting the brief's "configure each." So `set-secret` writes to the
  per-machine `envFile` (`0600`, gitignored, never repo), value via stdin/prompt not argv, never echoed back — the
  industry-standard local-dev pattern. The anti-pattern avoided is writing secrets into a *repo-tracked* file or
  returning them over the API, not UI credential entry itself. `config` keys (non-secret) go to state; secrets go
  only to the env file.
- **CLI is zero-dep Node shelled-to like `bin/okf`, NOT served over the per-run socket.** Adapter config is global,
  not run-scoped; the per-run unix socket is the wrong transport. One CLI = single source of truth, usable from the
  UI, the validator, and the shell alike. Matches repo zero-dep policy.
- **Validator cross-check is the payoff, not scope creep.** Having a catalog is only worth it if it catches the
  WRAPPER-CONTRACT model-pin footgun statically; extending the existing `_validate.mjs` (zero new dep) turns a
  dispatch-time silent-wrong-model into an author-time error.
- **Curated `models` (allowlist) vs `availableModels` (full live list) are distinct fields.** The brief's OpenRouter
  case (hundreds available, 1–2 used) is exactly this split: `models` is what presets may bind; `availableModels`
  feeds the picker, fetched via the declared `<wrapper> --list-models` mode and cached. Conflating them would either
  flood presets with hundreds of bindable models or hide the real options from the picker.
- **No arbitrary-exec field in committed config.** The full model list routes through a declared wrapper mode
  (`--list-models`), never an inline command string in `presets/adapters.json` — a committed JSON file that shells
  out is the data-that's-secretly-code footgun a JSON-diff review misses. Two optional wrapper modes total
  (`--health`, `--list-models`); both vetted as code in their wrapper file.
