Skip to content

config keys per-provider host settings by provider ID with generic -provider-bin/-provider-config maps, and keeps the claude-named fields as deprecated aliases

internal/config carried the host settings for the one MVP provider as claude-named fields: -claude / -claude-config / LAB_CLAUDE_CONFIG, resolving the Claude Code binary and its global config file (the folder-trust seeding target). That surface was provider-shaped in the generic core. A second provider (Codex, #2) needs the same two host settings — where is its binary, where is its config file — so the status-quo pattern is to copy the pair under a new name (-codex, -codex-config), and a third under a third. Every provider grows config.go by two flags, two env bindings, and two struct fields, and bakes another provider's name permanently into the generic layer. That is precisely the accretion issue #2's acceptance rules out: the grep-clean core that names no concrete provider (the SPA grep-guard and httpapi importing no concrete provider, ADR-0026), now leaking back in through the config surface.

ADR-0026 made the provider seam provider-neutral by construction and promised a second provider is "one new implementation and zero refactor of the generic core." ADR-0033 carried that promise across the deploy boundary — agent CLIs keyed by provider ID, so a new provider ships as a pure Go add with no module refactor. This ADR carries the same promise into the host-config surface. It replaces the claude-named pair with a generic per-provider surface keyed by the same provider IDs the registry, the DB provider column, and the API already share, so provider N+1's binary and config path are two map entries under its ID, and config.go never grows a provider-named field again. The two claude-named forms survive as documented deprecated aliases that write the claude-code entries — the only provider-named remnant left in config.go, kept exactly for the reason ADR-0033 kept claudePackage: existing deployments migrate on a warning, not a hard break.

The pins, decided:

  • Two repeatable generic flags, -provider-bin id=path and -provider-config id=path, each stored as map[string]string keyed by registered provider ID. -provider-bin claude-code=/usr/bin/claude sets the Claude Code binary; -provider-config claude-code=/var/lib/lab/.claude.json sets its global config file. Both flags are repeatable — one occurrence per provider — and the id=path shape names the provider explicitly rather than positionally. The surface holds exactly these two settings; it deliberately does not grow per-provider knobs beyond bin + config path (see the last pin).
  • Env forms LAB_PROVIDER_BIN_<ID> and LAB_PROVIDER_CONFIG_<ID>, where <ID> is the provider ID uppercased with dashes mapped to underscores — claude-code → LAB_PROVIDER_BIN_CLAUDE_CODE / LAB_PROVIDER_CONFIG_CLAUDE_CODE. One env pair per provider, matching the flag pair.
  • config.Parse receives the registered provider IDs from cmd/lab — the one place that already constructs the adapters and therefore already names them. Config does not discover IDs itself (no registry import, no hardcoded list), so config.go needs zero changes for provider N+1: the new adapter's ID flows in from cmd/lab, and its map entries validate against it automatically. An unknown ID in a generic flag is a boot error that lists the registered IDs, so a typo (-provider-bin claud-code=…) fails loudly at parse and names what it should have been, rather than being silently dropped. The env forms are read only for registered IDs (parse reaches the environment solely through the injected getenv, key by key, so it cannot enumerate unknown variables) — a typoed env var name is inert, which is the pre-existing behavior of every LAB_* variable.
  • Adapters own their defaults; an absent map entry means the adapter fills it. The generic surface stores only what the operator set. claudecode defaults its binary to a PATH-resolved claude and its config path to $HOME/.claude.json (derived from its own LoginDir), exactly as before — the defaults never live in config.go. A provider with no entry and no default simply has none; the config layer holds no provider-shaped fallback.
  • -claude, -claude-config, and LAB_CLAUDE_CONFIG remain as deprecated aliases that write the map's claude-code entries. Per-entry precedence, highest wins: generic flag > generic env > alias flag > alias env — the generic form always beats the claude-named form for the same setting, and within each pair a flag beats its env. This alias shim is the only provider-named remnant in config.go; every other provider reaches the surface through the generic map alone.
  • The generic surface stays two settings wide, on purpose. Anything a provider needs beyond "where is my binary" and "where is my config file" stays adapter-owned — declared and applied behind the seam (the spawn-options pattern, ADR-0021), never promoted into a generic per-provider config knob. Widening this surface per provider would re-import the very provider-shaped accretion this ADR removes.

Out of scope, unchanged: main's degraded-boot rule keeps its spirit — with no HOME and no configured claude-code config path, the instance/AFK stack stays unmounted and lab serves the rest with a loud warning (the same rule, now reading the claude-code map entry instead of the old field). The Codex AgentProvider implementation (#2) is not in scope; this ADR only makes its future host settings a pure add. The consuming NixOS/fleet config's migration from --claude/--claude-config to the generic flags is downstream (the extraFlags example in nix/module.nix is updated to the generic form here).

Status

Accepted. Amends the config surface: the claude-named -claude / -claude-config / LAB_CLAUDE_CONFIG fields are superseded by the generic -provider-bin / -provider-config maps (env LAB_PROVIDER_BIN_<ID> / LAB_PROVIDER_CONFIG_<ID>) and demoted to deprecated aliases writing the claude-code entries. Carries ADR-0026's provider-neutral-by-construction promise into the host-config surface, mirroring ADR-0033's carry of the same promise into the deploy surface, and reuses the provider-ID vocabulary the registry, the DB provider column, and the API share (ADR-0026, ADR-0030, ADR-0033). Relates to #2 (Codex): its future host settings are two map entries under codex, no config.go change — the acceptance issue #2 sets for the grep-clean core. Resolves #78.

Considered options

  • Copy the claude-named pair per provider (-codex / -codex-config, -gemini / …) — the status-quo pattern. Rejected: config.go grows two flags, two env bindings, and two struct fields per provider, and every one of them bakes a concrete provider name permanently into the generic core — the exact accretion issue #2's acceptance and ADR-0026's grep-clean core rule out. The generic map holds provider N under its ID with no new config code at all.
  • Have config.go discover the provider IDs itself — import the registry, or hardcode the known list. Rejected: importing the provider registry into config risks an import cycle (adapters and their construction already sit above config), and hardcoding a list of IDs puts provider names back in the generic layer — the thing this ADR removes. The ID list flows in from cmd/lab, the one place that already constructs and therefore already names the adapters, so config stays name-free and provider N+1 needs no config edit.
  • Validate unknown IDs later — at registry construction, or never (ignore entries with no matching adapter). Rejected: a typoed -provider-bin claud-code=… would be silently dropped, and the adapter would mysteriously fall back to its default binary with no signal to the operator that their override never took. Validating at parse against the IDs cmd/lab passed in turns the typo into a boot error that names the offending ID and lists the registered ones — the failure is loud and points at the fix.
  • Remove the claude aliases outright and require the generic form. Rejected: a hard break for every existing deployment that passes --claude-config / LAB_CLAUDE_CONFIG, breaking at the next restart. A deprecated shim (write the claude-code map entries, generic form wins the precedence) gives a warning-and-migrate path at the cost of one provider-named block in config.go — the same trade ADR-0033 made keeping claudePackage as an alias rather than deleting it.
  • Widen the generic surface with more per-provider knobs than bin + config path. Rejected: bin and config path are the two host settings every agent CLI has by nature of being an external binary with an on-disk config; anything past them is provider-specific and belongs behind the seam (declared and applied by the adapter, the ADR-0021 spawn-options pattern), not in a generic config map. A wider generic surface would re-admit provider-shaped fields into the core under a generic-looking name.