Files
stack/docs/plans/2026-09-03_auth-provider-harness-registry.md
T

21 KiB

Harness declaration + centralized auth/provider registry

Status: DRAFT FOR OWNER REVIEW — no implementation is authorized by this file. Issue: #49. Date: 2026-09-03.

Purpose

Specify how Mosaic Stack supports multiple harnesses, providers, endpoints, and authentication accounts without modifying a user's default harness configuration or requiring provider registration on every agent seat.

Pi is the first implementation checkpoint. The registry and seat contracts must remain harness-neutral so future adapters (claude, codex, opencode) materialize their own native files from the same desired state. Canonical harness IDs are the executable names so CLI, manifests, diagnostics, and user expectation stay 1:1.

Non-negotiable decisions already made

  1. Default harness homes (~/.pi, and future equivalents) are read-only to Mosaic. The stack never writes there.
  2. Credentials live only under the configured data root, never in the repo, image, argv, stdout, logs, or generated non-secret manifests.
  3. Account registration is centralized. A seat never performs OAuth login and does not maintain an independent source credential.
  4. Seat files are generated artifacts, not user-authored configuration.
  5. Missing, stale, invalid, insecure, or ambiguous registry/materialized state refuses launch. No fallback to a different account.
  6. Pi is implemented first; harness-specific behavior stays behind adapters and materializers.

Separate concepts (do not conflate)

Concept Example Authority
Harness pi, later claude-code, codex, opencode agent.json + versioned harness manifest
Provider openai-codex, anthropic, zai, ollama-local central provider registry
Account openai-codex/homelab-openai central account registry
Endpoint local/remote Ollama base URL provider record
Settings profile reusable account/provider/model policy central settings registry
Seat selection active choice within a profile's allowed accounts audited runtime state
Materialization harness-native auth.json / models.json generated per seat

Accounts represent identities. Ollama instances represent endpoints. Harnesses consume generated configuration. Keeping these axes separate prevents account, provider, and model-policy conflicts.

Harness declaration: agent.json

A defined seat declares exactly one harness:

{
  "agentVersion": 2,
  "name": "researcher",
  "role": "researcher",
  "harness": "pi",
  "settingsProfile": "research-default",
  "capabilities": { "tools": ["read", "bash"] }
}

Rules:

  • harness is a scalar identifier, not an array. One process launch runs one harness.
  • It is not hard-coded to an enum in the agent schema. The value must resolve dynamically through the installed/available harness registry.
  • Canonical identifiers (owner-confirmed during #50 review) are executable names: pi, claude, codex, opencode. Provider openai-codex and harness codex remain distinct.
  • No CLI harness override in phase 1. Harness is part of reviewed seat identity; an override would bypass that declaration.
  • Migration: existing agentVersion: 1 seats without harness resolve to pi with a loud deprecation warning. A later reviewed migration makes version 2 and harness mandatory.
  • Plain M13 launches without an agent definition continue to use the system execution adapter; they have no seat enrollment or generated seat auth.

Harness manifests

Harness support is versioned with its adapter, for example:

adapters/pi/harness.json
adapters/claude-code/harness.json

A manifest declares its identifier, executable name, adapter, compatible version range, installer/detector metadata, execution mode, materializers, native config paths, and supported credential types. Adding harness support requires a reviewed adapter + manifest + suites. Detecting/installing a known harness is mutable machine state; changing the manifest/adapter contract is a reviewed repository change.

Harness detection, installation, and availability

Target CLI:

mosaic harness list [--available|--installed] [--json]
mosaic harness detect [<id>|--all]
mosaic harness install <id> [--version <exact-compatible-version>]
mosaic harness rm <id>
mosaic harness status [<id>] [--json]

Lifecycle and rules:

  1. detect checks canonical executable names (pi, claude, codex, opencode) on PATH and approved known locations, resolves real paths, obtains versions using manifest-declared noninteractive commands, validates compatibility, and records compatible findings as available. Unknown executables are never auto-registered.
  2. Detection records metadata only: executable path, version, source (external-detected), compatibility result, detection timestamp, and manifest identity. It never reads/copies the harness's home, settings, auth, sessions, extensions, or plugins.
  3. install <id> resolves the reviewed catalog/manifest, pins an exact compatible version, verifies package identity/checksum where supported, and installs/packages into a Mosaic-managed immutable runtime. It never performs an unversioned global install and never writes the default harness home.
  4. Built-in pi is an image-baked managed harness. Future installs should package a harness-specific container image/bundle beside the active release so reset.sh does not destroy installed runtimes; dataRoot stores registry state and receipts, not the package payload.
  5. Availability and launch-readiness are separate fields. A compatible detected host executable is available; under the current container boundary it is not automatically ready until imported/installed into a managed runtime, unless a separately reviewed adapter explicitly supports host execution.
  6. agent.json.harness launch requires a compatible, ready harness + adapter + materializer. Detected-but-not-ready, missing, incompatible, or ambiguous installations refuse loudly; no fallback to pi.
  7. Install/detect/remove operations write append-only receipts. Removal refuses while defined seats reference the harness unless those bindings are migrated first.

Registry state is derived under <dataRoot>/harnesses/; the reviewed catalog and adapter manifests remain in the installation/repository. This avoids a hard-coded enum while preventing arbitrary PATH executables from becoming trusted harnesses.

Central registry layout

Fixed below <dataRoot>; not configurable independently (config.json remains the sole system config):

<dataRoot>/auth/
├── providers/
│   ├── openai-codex.json
│   ├── anthropic.json
│   ├── zai.json
│   ├── ollama-local.json
│   └── ollama-remote.json
├── accounts/
│   ├── openai-codex/
│   │   ├── homelab-openai/
│   │   │   ├── account.json       # non-secret metadata, 0600
│   │   │   └── credential.json    # secret OAuth/API material, 0600
│   │   └── personal-openai/
│   │       ├── account.json
│   │       └── credential.json
│   └── zai/
│       └── main/
│           ├── account.json
│           └── credential.json
├── settings/
│   ├── base.json
│   └── research-default.json
└── state/
    ├── refresh.json                # non-secret status only
    └── activation-log.jsonl        # append-only, no secret material

Naming and security:

  • Provider/account/profile IDs match ^[a-z0-9][a-z0-9._-]{0,63}$.
  • Display names are metadata; path IDs are explicit or deterministically slugged and confirmed before creation.
  • Account path must be under its registered provider.
  • Registry dirs are owner-only; account and credential files are regular, non-symlink, 0600. Loose perms refuse use.
  • credential.json schema depends on type but is never returned by list/status.
  • Provider removal refuses while accounts/profiles/seats reference it.
  • Account removal refuses while profiles/selections reference it unless an explicit reviewed migration removes those references first.

Registry records

Provider

Native provider example:

{
  "providerVersion": 1,
  "id": "openai-codex",
  "kind": "native",
  "harnesses": { "pi": { "providerId": "openai-codex" } },
  "credentialTypes": ["oauth", "api_key"]
}

Custom endpoint example:

{
  "providerVersion": 1,
  "id": "ollama-local",
  "kind": "custom-endpoint",
  "harnesses": {
    "pi": {
      "api": "openai-completions",
      "baseUrl": "http://host.docker.internal:11434/v1",
      "apiKey": "ollama",
      "models": ["qwen2.5-coder:7b", "llama3.1:8b"]
    }
  },
  "credentialTypes": ["none"]
}

ollama-remote is a second provider record with its remote base URL, optional credential account, and independently scoped model catalog. Linux compose must provide host-gateway resolution for local Ollama; container localhost is not the host.

Account metadata

{
  "accountVersion": 1,
  "id": "homelab-openai",
  "name": "Homelab OpenAI",
  "provider": "openai-codex",
  "type": "oauth",
  "createdAt": "<UTC timestamp>"
}

credential.json holds the pi-compatible OAuth/API payload centrally. API keys are accepted only from an interactive hidden prompt, stdin, or a validated 0600 file — never an argv value.

Reusable settings profile

Profiles remove per-seat/per-provider registration:

{
  "settingsVersion": 1,
  "id": "research-default",
  "allowedAccounts": [
    "openai-codex/homelab-openai",
    "openai-codex/personal-openai",
    "zai/main"
  ],
  "defaultAccounts": {
    "openai-codex": "openai-codex/homelab-openai",
    "zai": "zai/main"
  },
  "providers": ["ollama-local", "ollama-remote"],
  "models": {
    "ollama-local": ["qwen2.5-coder:7b"],
    "ollama-remote": ["abliterated-model-id"]
  }
}

A seat references one profile. Editing one central profile updates every seat that uses it on the next ensure/launch. The seat is not re-registered with each provider.

A profile may authorize multiple accounts for the same provider. Pi can place only one entry per provider in one auth.json; therefore exactly one account per provider is active for a materialization. Defaults provide the initial selection.

Runtime seat selection

Owner rulings 2026-09-10 supersede the earlier seat-global single-selection model: selection is per-execution, not one mutable seat-global file. The file below remains the host-side record of the seat's current default choices, but it is an input to the next materialization, not a live switch that rewrites a running session's identity.

At-will changes are mutable state, not repo edits:

<dataRoot>/agents/<seat>/settings-selection.json

Example:

{
  "selectionVersion": 1,
  "profile": "research-default",
  "accounts": {
    "openai-codex": "openai-codex/personal-openai"
  },
  "updatedAt": "<UTC timestamp>"
}

Rules:

  • Selection can choose only accounts allowed by the seat's referenced profile.
  • Omitted provider selections use profile defaults.
  • Changing selection is append-only-audited and triggers regeneration for the next execution; concurrent workspace sessions of one seat materialize independently from their own execution records, so one file never rewrites another running session's identity.
  • Forked sessions pin the original account in immutable session metadata (owner ruling 2026-09-10, gate 6). Unpinned relaunches materialize the current profile default.
  • No account choice silently falls back to another account.
  • In-session identity swapping is deferred. Selection applies on relaunch/new session; changing identity inside a session risks ambiguous billing, provider state, and audit lineage.
  • Each execution record references the selected settings profile, account set, and generated-file manifest identity, per the #53 execution/context manifest records. Credentials never enter records or fingerprints (R17).

Mechanical per-seat materialization

Generated state:

<dataRoot>/agents/<seat>/generated/pi/
├── auth.json          # secret, 0600, generated
├── models.json        # generated custom providers/model scopes
└── manifest.json      # non-secret inputs/hashes/timestamps; no credential hash

Resolution on agent.sh <seat>:

  1. Strictly validate agent definition and resolve its harness manifest.
  2. Resolve role, settings profile, runtime selection, providers, accounts, and model scopes.
  3. Validate every referenced record, regular-file constraint, ownership, and 0600 credential perms.
  4. Refresh required centralized OAuth records if policy says stale/near expiry; refusal leaves prior generated files untouched.
  5. Generate beside existing files, validate harness-native output, chmod 0600, then atomically rename into place.
  6. Compare/write non-secret manifest state and append an activation receipt.
  7. Mount generated files into the harness's native paths read-only and launch.

No seat authenticates, edits auth.json, or owns an independent OAuth refresh token. Generated files are disposable derivatives of the registry.

mosaic auth ensure [<seat>] runs the same materializer explicitly. A periodic host service refreshes central OAuth records and rematerializes affected seats; launch-time ensure is the final fail-closed gate. OAuth refresh mechanics must reuse pi's implementation where possible rather than reimplement provider protocols; the exact noninteractive refresh trigger is an implementation investigation and acceptance gate.

CLI contract (target mosaic surface)

mosaic auth list [--provider <id>] [--json]
mosaic auth status [<account-ref>|--seat <seat>] [--json]
mosaic auth new
mosaic auth new --name "Homelab OpenAI" --provider openai-codex --type oauth
mosaic auth rm <provider/account>
mosaic auth login <provider/account>
mosaic auth logout <provider/account>
mosaic auth refresh [<provider/account>|--all]
mosaic auth ensure [<seat>|--all]

mosaic auth provider list [--json]
mosaic auth provider status <id> [--json]
mosaic auth provider create
mosaic auth provider create --id ollama-local --kind custom-endpoint ...
mosaic auth provider rm <id>

mosaic harness list|detect|install|rm|status ...

mosaic agent settings use <seat> <profile>
mosaic agent auth use <seat> <provider/account>
mosaic agent auth status <seat>

Behavior:

  • new without sufficient flags launches an interactive wizard.
  • OAuth new/login launches the host-side provider flow once and stores the resulting centralized profile. Agents never run it.
  • API secret input is hidden prompt/stdin/validated file only.
  • rm reports references and refuses when in use.
  • Every mutating command writes an append-only secret-free receipt.
  • list/status --json never include credential/token/key fields.
  • CLI flags name accounts/providers; no secret material is accepted on argv.

Until M20 provides mosaic, scripts/auth.sh may prototype the backend, but it must use the same schemas and must not become a competing implementation.

Harness-neutral materializers

Core resolution produces a secret-bearing internal representation in memory: selected providers/accounts/models for one seat. A harness materializer maps it to native files:

  • Pi: auth.json + models.json.
  • Claude Code/Codex/OpenCode: future adapter-defined files/env, without changing account/provider/profile schemas.

The harness manifest declares support. Enrollment or launch refuses if a chosen account/provider cannot materialize for the seat's harness. No best-effort provider dropping.

Policy relationships (future phase, not initial implementation)

Initial authorization is settings-profile enrollment. Later, the existing least-privilege doctrine extends naturally:

role auth ceiling ∩ profile enrollment ∩ mission grant ∩ task grant
= effective providers/accounts/models

A task may narrow a seat's providers/models, never select an account outside its profile. This is separate from registry and materialization correctness.

Migration from M19 prototype

No named accounts currently exist, so migration is state-free:

  1. Keep ~/.pi/agent/auth.json as the default harness file, read-only to Mosaic.
  2. Replace the prototype <dataRoot>/auth/<account>.json convention with the registry tree before users create accounts.
  3. Keep scripts/auth.sh status/accounts behavior but drive it from registry schemas.
  4. Replace agent.sh --auth <account> direct-file selection with profile + runtime selection + materialization. A temporary compatibility path may map --auth provider/account to a one-launch selection, but must be explicit and audited.
  5. Add harness: "pi" + settingsProfile to researcher through an agentVersion migration after review.

Required suites / acceptance

  • Registry schemas: unknown keys, path/name mismatch, symlinks, traversal, wrong perms, duplicate IDs, missing provider, unsupported credential type.
  • Secret non-disclosure: fixture keys/tokens never appear in stdout, stderr, JSON status, manifests, receipts, or git diff.
  • Referential integrity: provider/account/profile removal refuses while used.
  • Multiple account types for one provider register successfully.
  • Profile authorizes multiple same-provider accounts; exactly one active choice materializes for pi.
  • Changing runtime choice regenerates atomically; failed generation preserves previous files and records refusal.
  • OAuth central refresh rematerializes affected seats; agents never authenticate.
  • harness: pi resolves; unregistered/unsupported harness refuses.
  • Harness detection records recognized compatible executables without reading harness homes; incompatible/ambiguous detections refuse availability.
  • Harness install is exact-version/verified, Mosaic-managed, and never global; detected-but-not-ready harnesses cannot launch seats.
  • Pi materialization generates valid auth.json/models.json with scoped providers and models; custom local/remote Ollama entries remain distinct.
  • Launch refuses missing/stale/invalid materialization; no account fallback.
  • Existing generic TUI and default-harness use remain untouched.
  • Full existing suites + verify green.

Review gates (must resolve before implementation)

  1. RESOLVED (owner, #50): canonical harness IDs are executable names: pi, claude, codex, opencode; validation is registry/manifest-driven, not a schema enum. mosaic harness detect/install/list/rm/status owns the lifecycle. Detected host executables are available but not launch-ready under the container boundary until imported/installed into a managed runtime (unless host execution receives a separate reviewed adapter).
  2. RESOLVED (owner, 2026-09-10): one-time reviewed cutover to agentVersion: 2; settingsProfile is mandatory for defined seats. v1 seats resolve to pi with a loud deprecation warning until migrated.
  3. RESOLVED (owner, 2026-09-10): keep the split — account.json non-secret metadata + credential.json secret, both 0600.
  4. RESOLVED (owner, 2026-09-10): filesystem 0600 now. OpenBao is the intended eventual secret backend; the credential-type field and materializer interface are the design seam. No OpenBao work is authorized yet.
  5. RESOLVED (owner, 2026-09-10): named launch profiles are deferred. Per-execution selection recorded in execution records/context manifests (per the Gate 7 reconciliation and #53 model) covers the need; a named-profile layer would be a separate future decision if a use case emerges.
  6. RESOLVED (owner, 2026-09-10): forked sessions pin the original account in immutable session metadata; unpinned relaunches materialize the current profile default; selection applies between executions, never mid-session.
  7. PARTIALLY RESOLVED (owner, 2026-09-10): the pin moves to pi 0.85.1 (exact). A bounded read-only investigation of the 0.85.1 package will determine the supported noninteractive OAuth refresh path; owner approves the mechanism from those findings before implementation. Refresh stays host-side, fail-closed, no token exposure or duplication.
  8. RESOLVED (owner, 2026-09-10): local Ollama routes via host-gateway (http://host.docker.internal:11434/v1). Remote Ollama supports http and https; plain http requires an explicit per-provider allowInsecureTransport: true flag (loud, opt-in, audited). Ollama is not currently installed on the workstation; suites use fixtures/mocks, and the owner may install Ollama with a small model for real-endpoint testing later.
  9. RESOLVED (owner, 2026-09-10): M20 packages/mosaic begins with the auth/provider/harness domain. scripts/auth.sh becomes a thin compatibility shim over it, removed at the version-2 cutover. Harness runtime bundles package beside the active release image so reset.sh does not destroy them; registry state and receipts remain under dataRoot.
  10. RESOLVED (owner, 2026-09-10): registry stays inside the data root; reset.sh keeps wiping it but prints a loud warning about OAuth re-login cost. Revisit a separate protected registry root only if re-login pain proves real (would be a canon change).

Remaining open item before implementation: gate 7's 0.85.1 refresh investigation findings and the owner's approval of the refresh mechanism it proposes.