Skip to content

Loader

loader

Loads agent and tool definitions from YAML and assembles them into a queryable roster.

Loads per-agent definition files from bundled package resources and optional user extensions, validates them through the strict schema (typo-rejecting Pydantic models), and projects each entry onto the runtime types dataclasses.

Directory layout::

resources/agents/claude.yaml      (bundled, shipped in wheel)
resources/agents/codex.yaml
...
~/.config/terok/agent/agents/      (user overrides / additions)
~/.config/terok/providers/         (user provider overrides / additions)

ROSTER_VERSION = 3 module-attribute

Schema version of the agent-roster YAML format.

Bundled agent YAMLs and user override files declare a top-level roster_version: 1 that matches this constant. A file with no roster_version is treated as version 1 (forward-compat for existing user overrides written before the marker existed). A file declaring a future version is still loaded but the loader logs a warning — the host and container may be on incompatible contracts. Bumped only on breaking changes to the roster schema, never per release.

AgentRoster(_agents=dict(), _providers=dict(), _auth_providers=dict(), _vault_routes=dict(), _sidecar_specs=dict(), _installs=dict(), _helps=dict(), _mounts=(), _agent_names=(), _all_names=(), _web_ingress=frozenset()) dataclass

Queryable view over the loaded set of agents and tools.

Returned by load_roster; grouped accessors expose agents, auth providers, vault routes, sidecar specs, install snippets, and help blurbs by name.

agents property

All headless agents (kind: agent only).

providers property

All vault-routed providers (LLM endpoints + tool APIs), keyed by clean name.

The endpoint axis: where requests go and how the real credential is attached. Loaded from resources/providers/*.yaml. The routes.json the sandbox vault reads is generated from these.

auth_providers property

All auth providers (agents + tools with auth: section).

vault_routes property

All vault routes, keyed by provider name.

sidecar_specs property

All sidecar tool specs, keyed by tool name.

agent_names property

Names of kind: agent entries (for CLI completion).

all_names property

Return the names of agents, tools, and selectable LLM providers.

installs property

All install specs, keyed by roster name (entries without one are absent).

helps property

All help blurbs, keyed by roster name (entries without one are absent).

web_ingress property

Names of entries that publish a host HTTP port (web_ingress: true).

Consumers (e.g. terok's task launcher) use this to decide whether to allocate a published port and drop a per-task auth token into the container-visible config dir.

mounts property

All shared directory mounts (auth dirs + explicit mounts: sections).

Deduplicated by host_dir — if auth and mounts define the same directory, only one entry is returned.

resolve_selection(selection)

Resolve a user-supplied selection into the full set of roster names to install.

Accepts the literal string "all" (every roster entry that has an InstallSpec) or a tuple of selection tokens. Each token is either a roster name (include) or a name prefixed with - (exclude). The pseudo-name "all" is also valid as an include token, meaning "seed from every installable entry"; this combines naturally with excludes, e.g. ("all", "-vibe") installs everything except vibe. When no include tokens are present (only excludes), the seed is the full roster.

Includes are expanded transitively via depends_on before excludes are applied, so an exclude that names a dependency of a kept agent will silently drop that dependency — likely producing a broken image, but matching the user's literal request.

Returns the names sorted alphabetically — the canonical order used for the OCI label, the tag suffix, and the in-container manifest.

This method raises ValueError if an include or exclude specifies a provider-only name or an unknown name. It raises TypeError if selection is a string other than "all". This check prevents iteration through the characters of a bare name such as "claude". An exclusion has no effect if the agent is not in the resolved include set.

Source code in src/terok_executor/roster/loader.py
def resolve_selection(self, selection: str | tuple[str, ...]) -> tuple[str, ...]:
    """Resolve a user-supplied selection into the full set of roster names to install.

    Accepts the literal string ``"all"`` (every roster entry that has an
    [`InstallSpec`][terok_executor.roster.types.InstallSpec]) or a tuple of
    selection tokens.  Each token is either a roster name (include) or a
    name prefixed with ``-`` (exclude).  The pseudo-name ``"all"`` is also
    valid as an include token, meaning "seed from every installable
    entry"; this combines naturally with excludes, e.g. ``("all",
    "-vibe")`` installs everything except vibe.  When no include tokens
    are present (only excludes), the seed is the full roster.

    Includes are expanded transitively via ``depends_on`` *before*
    excludes are applied, so an exclude that names a dependency of a
    kept agent will silently drop that dependency — likely producing a
    broken image, but matching the user's literal request.

    Returns the names sorted alphabetically — the canonical order used
    for the OCI label, the tag suffix, and the in-container manifest.

    This method raises ``ValueError`` if an include or exclude specifies a
    provider-only name or an unknown name. It raises ``TypeError`` if
    *selection* is a string other than ``"all"``. This check prevents
    iteration through the characters of a bare name such as ``"claude"``.
    An exclusion has no effect if the agent is not in the resolved include
    set.
    """
    if isinstance(selection, str):
        if selection != "all":
            raise TypeError(
                f"Selection must be the literal string 'all' or a tuple of "
                f"tokens, got {selection!r}"
            )
        return tuple(sorted(self._installs))

    includes = {t for t in selection if not t.startswith("-")}
    excludes = {t[1:] for t in selection if t.startswith("-")}

    referenced = (includes | excludes) - {"all"}
    runtime_endpoints = {
        name
        for name, provider in self._providers.items()
        if provider.serves and name not in self._installs
    }
    endpoint_only = referenced & runtime_endpoints
    if endpoint_only:
        raise ValueError(
            f"These names are providers, not installable agents: "
            f"{sorted(endpoint_only)!r}. Add 'opencode' or 'pi' to image.agents. "
            "Then select a provider with --provider <name>."
        )
    unknown = referenced - set(self._installs)
    if unknown:
        avail = ", ".join(sorted(self._installs))
        raise ValueError(f"Unknown roster entries: {sorted(unknown)!r}. Available: {avail}")

    seed = set(self._installs) if "all" in includes or not includes else includes

    resolved: set[str] = set()
    stack = list(seed)
    while stack:
        name = stack.pop()
        if name in resolved:
            continue
        resolved.add(name)
        spec = self._installs.get(name)
        if spec is None:
            continue
        for dep in spec.depends_on:
            if dep not in self._installs:
                raise ValueError(
                    f"Agent {name!r} declares depends_on {dep!r}, "
                    f"which has no install: section in the roster"
                )
            if dep not in resolved:
                stack.append(dep)
    return tuple(sorted(resolved - excludes))

get_agent(name, *, default_agent=None)

Resolve an agent name to an Agent.

Falls back to default_agent, then "claude". Raises SystemExit if the resolved name is unknown.

Source code in src/terok_executor/roster/loader.py
def get_agent(self, name: str | None, *, default_agent: str | None = None) -> Agent:
    """Resolve an agent name to an ``Agent``.

    Falls back to *default_agent*, then ``"claude"``.
    Raises ``SystemExit`` if the resolved name is unknown.
    """
    from terok_executor.provider.providers import resolve_agent

    return resolve_agent(self._agents, name, default_agent=default_agent)

get_auth_provider(name)

Look up an auth provider by name.

Raises SystemExit if the name is unknown.

Source code in src/terok_executor/roster/loader.py
def get_auth_provider(self, name: str) -> AuthProvider:
    """Look up an auth provider by name.

    Raises ``SystemExit`` if the name is unknown.
    """
    info = self._auth_providers.get(name)
    if info is None:
        available = ", ".join(sorted(self._auth_providers))
        raise SystemExit(f"Unknown auth provider: {name!r}. Available: {available}")
    return info

get_sidecar_spec(name)

Look up a sidecar spec by tool name.

Raises SystemExit if the name has no sidecar configuration.

Source code in src/terok_executor/roster/loader.py
def get_sidecar_spec(self, name: str) -> SidecarSpec:
    """Look up a sidecar spec by tool name.

    Raises ``SystemExit`` if the name has no sidecar configuration.
    """
    spec = self._sidecar_specs.get(name)
    if spec is None:
        available = ", ".join(sorted(self._sidecar_specs)) or "(none)"
        raise SystemExit(f"No sidecar config for {name!r}. Available: {available}")
    return spec

generate_routes_json()

Generate the routes.json content for the sandbox vault server.

Emits one entry per provider — keyed by its clean name — so the vault can route to any authenticated provider, not just the one some agent binds by default. This is what lets a harness (opencode, pi) reach a provider no agent owns, and what keeps a provider routable after its shim agent is collapsed away. Empty/absent optional fields are stripped.

Source code in src/terok_executor/roster/loader.py
def generate_routes_json(self) -> str:
    """Generate the ``routes.json`` content for the sandbox vault server.

    Emits one entry per **provider** — keyed by its clean name — so the
    vault can route to any authenticated provider, not just the one some
    agent binds by default.  This is what lets a harness (opencode, pi)
    reach a provider no agent owns, and what keeps a provider routable
    after its shim agent is collapsed away.  Empty/absent optional fields
    are stripped.
    """
    from pydantic import TypeAdapter

    routes = {
        name: _provider_route_entry(provider) for name, provider in self._providers.items()
    }
    return (
        TypeAdapter(dict[str, VaultRouteEntry])
        .dump_json(routes, indent=2, exclude_none=True)
        .decode()
    )

deny_to_vault_hosts(*, exposed_credential_providers=frozenset())

Hosts to deny directly at the egress firewall (shield security_deny, t20).

Every provider the vault relays contributes its relayed_hosts (upstream + path overrides + OAuth-refresh endpoint) — the agent must reach those only through the loopback vault. Two classes are skipped:

  • shared_domain providers (gitlab.com, sonarcloud.io): the API rides an apex that also serves git push / docs, so a host-level deny would kill legitimate traffic; credential containment carries the weight instead.
  • the providers bound by exposed_credential_providers — roster-entry (agent/tool) names in terok's experimental expose_oauth_token mode, where the agent holds its real credential in-container (vault bypassed) and so must reach the endpoint directly. Each entry is mapped to the provider it binds (provider_binding.default).

A pure function of the roster and exposed_credential_providers, mirroring generate_routes_json: the vault routes every provider, so every relayed host is denied.

Source code in src/terok_executor/roster/loader.py
def deny_to_vault_hosts(
    self, *, exposed_credential_providers: frozenset[str] = frozenset()
) -> frozenset[str]:
    """Hosts to deny directly at the egress firewall (shield ``security_deny``, t20).

    Every provider the vault relays contributes its
    [`relayed_hosts`][terok_executor.roster.types.Provider.relayed_hosts]
    (upstream + path overrides + OAuth-refresh endpoint) — the agent must
    reach those *only* through the loopback vault.  Two classes are skipped:

    - **``shared_domain`` providers** (``gitlab.com``, ``sonarcloud.io``):
      the API rides an apex that also serves ``git push`` / docs, so a
      host-level deny would kill legitimate traffic; credential containment
      carries the weight instead.
    - the providers bound by **``exposed_credential_providers``** — roster-entry
      (agent/tool) names in terok's experimental ``expose_oauth_token`` mode,
      where the agent holds its *real* credential in-container (vault bypassed)
      and so must reach the endpoint directly.  Each entry is mapped to the
      provider it binds (``provider_binding.default``).

    A pure function of the roster and *exposed_credential_providers*, mirroring
    [`generate_routes_json`][terok_executor.roster.loader.AgentRoster.generate_routes_json]:
    the vault routes every provider, so every relayed host is denied.
    """
    exposed = self._exposed_provider_names(exposed_credential_providers)
    hosts: set[str] = set()
    for name, provider in self._providers.items():
        if provider.shared_domain or name in exposed:
            continue
        hosts |= provider.relayed_hosts()
    return frozenset(hosts)

compose_egress(*, exposed_credential_providers=frozenset())

Project the roster into the shield's egress tiers.

Bundles the deny_to_vault_hosts set (t20) with the union of every provider's egress_allow (t30) into one EgressProjection; both tuples are sorted and de-duplicated for a deterministic bundle.

exposed_credential_providers — roster-entry (agent/tool) names whose real credential is exposed in-container; the providers they bind are left reachable (see deny_to_vault_hosts).

Source code in src/terok_executor/roster/loader.py
def compose_egress(
    self, *, exposed_credential_providers: frozenset[str] = frozenset()
) -> EgressProjection:
    """Project the roster into the shield's egress tiers.

    Bundles the [`deny_to_vault_hosts`][terok_executor.roster.loader.AgentRoster.deny_to_vault_hosts]
    set (t20) with the union of every provider's
    [`egress_allow`][terok_executor.roster.types.Provider.egress_allow]
    (t30) into one [`EgressProjection`][terok_executor.roster.types.EgressProjection];
    both tuples are sorted and de-duplicated for a deterministic bundle.

    *exposed_credential_providers* — roster-entry (agent/tool) names whose real
    credential is exposed in-container; the providers they bind are left
    reachable (see
    [`deny_to_vault_hosts`][terok_executor.roster.loader.AgentRoster.deny_to_vault_hosts]).
    """
    deny = self.deny_to_vault_hosts(exposed_credential_providers=exposed_credential_providers)
    provider_allow = {host for p in self._providers.values() for host in p.egress_allow}
    return EgressProjection(
        deny_to_vault=tuple(sorted(deny)),
        provider_allow=tuple(sorted(provider_allow)),
    )

collect_all_auto_approve_env()

Merge auto_approve.env from all agents into one dict.

Source code in src/terok_executor/roster/loader.py
def collect_all_auto_approve_env(self) -> dict[str, str]:
    """Merge ``auto_approve.env`` from all agents into one dict."""
    merged: dict[str, str] = {}
    for p in self._agents.values():
        for key, value in p.auto_approve_env.items():
            if key in merged and merged[key] != value:
                raise ValueError(
                    f"Conflicting auto_approve_env for {key!r}: "
                    f"{merged[key]!r} vs {value!r} (agent {p.name!r})"
                )
            merged[key] = value
    return merged

collect_opencode_provider_env()

Collect the TEROK_OC_{NAME}_* env vars for all OpenCode-driven providers.

Source code in src/terok_executor/roster/loader.py
def collect_opencode_provider_env(self) -> dict[str, str]:
    """Collect the ``TEROK_OC_{NAME}_*`` env vars for all OpenCode-driven providers."""
    env: dict[str, str] = {}
    for p in self._providers.values():
        if p.opencode_config is not None:
            env.update(p.opencode_config.to_env(p.name))
    return env

load() staticmethod

Load a fresh roster from the current configuration.

This method reads the configuration on each call. It does not replace the process-wide snapshot from AgentRoster.shared.

Source code in src/terok_executor/roster/loader.py
@staticmethod
def load() -> AgentRoster:
    """Load a fresh roster from the current configuration.

    This method reads the configuration on each call. It does not replace
    the process-wide snapshot from
    [`AgentRoster.shared`][terok_executor.roster.loader.AgentRoster.shared].
    """
    return load_roster()

shared() staticmethod

Return the process-wide cached roster.

Loaded on first access; every subsequent call returns the same instance. Use this from anywhere that just needs the global view. Call AgentRoster.load when the caller needs the current configuration.

Source code in src/terok_executor/roster/loader.py
@staticmethod
def shared() -> AgentRoster:
    """Return the process-wide cached roster.

    Loaded on first access; every subsequent call returns the same
    instance.  Use this from anywhere that just needs the global
    view. Call
    [`AgentRoster.load`][terok_executor.roster.loader.AgentRoster.load]
    when the caller needs the current configuration.
    """
    return _shared_roster()

parse_selection(raw) staticmethod

Normalise a user-supplied agent selection string.

Accepts a comma-list of selection tokens or the literal "all". Each token is either an agent name ("claude") or a name prefixed with - to exclude it from the selection ("-vibe"). The pseudo-name "all" is also valid as a token, so "all,-vibe" means "everything except vibe". When the input contains only excludes ("-vibe"), the selection seeds from every installable entry — same effect as "all,-vibe".

Whitespace is stripped, empty / whitespace-only entries dropped, and case folded. Empty or all-whitespace input collapses to "all" — the same shape AgentRoster.resolve_selection expects. Unknown names are not checked here; resolve_selection does that.

Source code in src/terok_executor/roster/loader.py
@staticmethod
def parse_selection(raw: str) -> str | tuple[str, ...]:
    """Normalise a user-supplied agent selection string.

    Accepts a comma-list of selection tokens or the literal ``"all"``.
    Each token is either an agent name (``"claude"``) or a name
    prefixed with ``-`` to exclude it from the selection
    (``"-vibe"``).  The pseudo-name ``"all"`` is also valid as a
    token, so ``"all,-vibe"`` means "everything except vibe".  When
    the input contains only excludes (``"-vibe"``), the selection
    seeds from every installable entry — same effect as
    ``"all,-vibe"``.

    Whitespace is stripped, empty / whitespace-only entries dropped,
    and case folded.  Empty or all-whitespace input collapses to
    ``"all"`` — the same shape
    [`AgentRoster.resolve_selection`][terok_executor.roster.loader.AgentRoster.resolve_selection]
    expects.  Unknown names are not checked here;
    ``resolve_selection`` does that.
    """
    folded = raw.strip().lower()
    if folded == "all" or not folded:
        return "all"
    tokens = tuple(n.strip() for n in folded.split(",") if n.strip())
    return tokens or "all"

validate_selection(raw)

Reject raw with SystemExit(2) if it names roster entries we don't have.

CLI-flavoured: prints a Invalid agent selection: … line on stderr and exits. Domain callers that just want the parsed tuple should use parse_selection + resolve_selection and handle ValueError themselves.

Source code in src/terok_executor/roster/loader.py
def validate_selection(self, raw: str) -> None:
    """Reject *raw* with ``SystemExit(2)`` if it names roster entries we don't have.

    CLI-flavoured: prints a ``Invalid agent selection: …`` line on
    stderr and exits.  Domain callers that just want the parsed
    tuple should use
    [`parse_selection`][terok_executor.roster.loader.AgentRoster.parse_selection]
    + [`resolve_selection`][terok_executor.roster.loader.AgentRoster.resolve_selection]
    and handle ``ValueError`` themselves.
    """
    try:
        self.resolve_selection(self.parse_selection(raw))
    except ValueError as exc:
        print(f"Invalid agent selection: {exc}", file=sys.stderr)
        raise SystemExit(2) from exc

prompt_selection()

Print the installed roster and read one line of executor grammar.

Empty input → "all". Non-interactive stdin (closed pipe) exits with a hint to pass the selection positionally instead.

Source code in src/terok_executor/roster/loader.py
def prompt_selection(self) -> str:
    """Print the installed roster and read one line of executor grammar.

    Empty input → ``"all"``.  Non-interactive stdin (closed pipe)
    exits with a hint to pass the selection positionally instead.
    """
    agents = self.agents
    print("\nAvailable agents:")
    for name in sorted(self.agent_names):
        agent = agents.get(name)
        label = agent.label if agent is not None else name
        print(f"  · {name}  — {label}")
    try:
        raw = input("\nType a comma list, or '-name' to exclude [all]: ").strip()
    except EOFError as exc:
        raise SystemExit(
            "No interactive stdin available.  Pass the selection positionally "
            "instead, e.g. `terok agents set all`."
        ) from exc
    return raw or "all"

ensure_vault_routes(cfg=None)

Generate routes.json from this roster and write it to disk.

The routes file is written to the path configured in SandboxConfig (typically ~/.local/share/terok/vault/routes.json).

When cfg is None, falls back to standalone defaults.

Returns the path to the written file.

Source code in src/terok_executor/roster/loader.py
def ensure_vault_routes(self, cfg: SandboxConfig | None = None) -> Path:
    """Generate ``routes.json`` from this roster and write it to disk.

    The routes file is written to the path configured in
    [`SandboxConfig`][terok_sandbox.SandboxConfig] (typically
    ``~/.local/share/terok/vault/routes.json``).

    When *cfg* is ``None``, falls back to standalone defaults.

    Returns the path to the written file.
    """
    if cfg is None:
        cfg = SandboxConfig()
    path = cfg.routes_path

    path.parent.mkdir(parents=True, exist_ok=True)
    content = self.generate_routes_json() + "\n"
    fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
    tmp = Path(tmp_name)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
            f.flush()
            os.fsync(f.fileno())
        tmp.replace(path)
    except BaseException:
        tmp.unlink(missing_ok=True)
        raise
    return path

doctor_checks(*, token_broker_port=None)

Return agent-level health checks for in-container diagnostics.

Delegates to terok_executor.doctor for the actual check factories; this method is the canonical entry point so consumers can discover the checks through the roster.

Parameters:

Name Type Description Default
token_broker_port int | None

Host-side vault broker TCP port. None selects socket mode; any integer selects TCP mode. Base URL checks use the port (or the in-container loopback port) to derive the expected host.

None
Source code in src/terok_executor/roster/loader.py
def doctor_checks(self, *, token_broker_port: int | None = None) -> list[DoctorCheck]:
    """Return agent-level health checks for in-container diagnostics.

    Delegates to
    [`terok_executor.doctor`][terok_executor.doctor] for the actual
    check factories; this method is the canonical entry point so
    consumers can discover the checks through the roster.

    Args:
        token_broker_port: Host-side vault broker TCP port.  ``None``
            selects socket mode; any integer selects TCP mode.  Base
            URL checks use the port (or the in-container loopback
            port) to derive the expected host.
    """
    from terok_executor.doctor import _build_agent_doctor_checks

    return _build_agent_doctor_checks(self, token_broker_port=token_broker_port)

providers_config_dir()

Return the current directory for user provider files.

The function uses the shared XDG-aware Terok configuration root. The default directory is ~/.config/terok/providers/. Terok also reads the legacy ~/.config/terok/agent/providers/ directory. Do not use the legacy directory for new files.

Source code in src/terok_executor/roster/loader.py
def providers_config_dir() -> Path:
    """Return the current directory for user provider files.

    The function uses the shared XDG-aware Terok configuration root. The
    default directory is ``~/.config/terok/providers/``. Terok also reads the
    legacy ``~/.config/terok/agent/providers/`` directory. Do not use the
    legacy directory for new files.
    """
    return namespace_config_dir() / _USER_PROVIDERS_DIR_NAME

load_roster()

Load the agent roster from bundled YAML + user overrides.

Bundled agents in resources/agents/*.yaml are loaded first, then user files in ~/.config/terok/agent/agents/*.yaml are deep-merged on top (allowing field-level overrides or entirely new agents). Each merged entry is then validated through RawAgentYaml — typos in section keys, wrong types, or unknown fields fail loud instead of silently defaulting. Providers use the bundled, legacy-user, and current-user layers. See providers_config_dir.

Source code in src/terok_executor/roster/loader.py
def load_roster() -> AgentRoster:
    """Load the agent roster from bundled YAML + user overrides.

    Bundled agents in ``resources/agents/*.yaml`` are loaded first, then
    user files in ``~/.config/terok/agent/agents/*.yaml`` are deep-merged
    on top (allowing field-level overrides or entirely new agents).  Each
    merged entry is then validated through [`RawAgentYaml`][terok_executor.roster.schema.RawAgentYaml]
    — typos in section keys, wrong types, or unknown fields fail loud
    instead of silently defaulting. Providers use the bundled,
    legacy-user, and current-user layers. See
    [`providers_config_dir`][terok_executor.roster.loader.providers_config_dir].
    """
    raw = _load_bundled_agents()

    # Deep-merge user overrides on top of bundled definitions
    for name, user_data in _load_user_agents().items():
        if name in raw:
            raw[name] = deep_merge(raw[name], user_data)
        else:
            raw[name] = user_data

    providers = _load_providers(reserved_names=frozenset(raw))

    agents: dict[str, Agent] = {}
    auth_providers: dict[str, AuthProvider] = {}
    vault_routes: dict[str, VaultRoute] = {}
    sidecar_specs: dict[str, SidecarSpec] = {}
    installs: dict[str, InstallSpec] = {}
    helps: dict[str, HelpSpec] = {}
    agent_names: list[str] = []
    all_names: list[str] = []
    web_ingress_names: set[str] = set()

    # Collect mounts from all entries — deduplicate by host_dir
    seen_mounts: dict[str, MountDef] = {}

    for name, data in sorted(raw.items()):
        try:
            spec = RawAgentYaml.model_validate(data)
        except ValidationError as exc:
            raise ValueError(f"Agent {name!r}: invalid roster YAML\n{exc}") from exc

        label = spec.resolve_label(name)
        is_agent_kind = spec.kind not in ("tool", "frontend", "infra")

        if spec.kind not in ("frontend", "infra"):
            all_names.append(name)
        if is_agent_kind:
            agent_names.append(name)
            agents[name] = spec.to_agent(name)

        credential_file = spec.provider.credential_file if spec.provider else ""
        credential_file_writable = bool(spec.provider and spec.provider.credential_file_writable)

        # Agents capture credentials only through an explicit ``auth:`` block;
        # the harness-driven providers' API-key capture is synthesized from
        # their OpenCode config in the provider loop below.
        auth_prov: AuthProvider | None = (
            spec.auth.to_dataclass(name=name, label=label) if spec.auth is not None else None
        )

        if auth_prov is not None:
            # Stamp the provider the captured credential is keyed under (claude →
            # anthropic), so the auth layer can resolve it without importing the
            # roster.  Falls back to the entry's own name when unbound.
            if spec.provider is not None and spec.provider.default:
                auth_prov = replace(auth_prov, credential_provider=spec.provider.default)
            auth_providers[name] = auth_prov
            if auth_prov.host_dir_name not in seen_mounts:
                seen_mounts[auth_prov.host_dir_name] = MountDef(
                    host_dir=auth_prov.host_dir_name,
                    container_path=auth_prov.container_mount,
                    label=f"{auth_prov.label} config",
                    credential_file=credential_file,
                    provider=name,
                    writable=credential_file_writable,
                )

        for m in spec.mounts:
            if m.host_dir not in seen_mounts:
                seen_mounts[m.host_dir] = MountDef(
                    host_dir=m.host_dir,
                    container_path=m.container_path,
                    label=m.label or name,
                )

        if spec.provider is not None and spec.provider.default:
            pname = spec.provider.default
            prov = providers.get(pname)
            if prov is None:
                raise ValueError(
                    f"Agent {name!r} binds provider {pname!r}, which has no "
                    f"resources/providers/{pname}.yaml"
                )
            if pname in vault_routes:
                raise ValueError(
                    f"Provider {pname!r} is bound by more than one agent (second: {name!r}); "
                    f"a provider maps to exactly one vault route"
                )
            vault_routes[pname] = _vault_route_from_binding(pname, prov, spec.provider)

        if spec.sidecar is not None:
            sidecar_specs[name] = spec.sidecar.to_dataclass(default_name=name)

        if spec.install is not None:
            installs[name] = spec.install.to_dataclass()

        if spec.help is not None:
            helps[name] = spec.help.to_dataclass()

        if spec.web_ingress:
            web_ingress_names.add(name)

    # LLM endpoints without an agent binding still need credential delivery.
    # New provider-neutral entries opt in through ``serves`` + API-key auth;
    # legacy curated providers remain eligible through their ``opencode`` block.
    # Install/help are independent decorations for the curated one-word aliases.
    for pname, provider in providers.items():
        oc = provider.opencode_config
        unbound_api_endpoint = (
            pname not in vault_routes
            and bool(provider.serves)
            and provider.api_key_auth is not None
        )
        delivers_to_harness = oc is not None or unbound_api_endpoint
        if delivers_to_harness:
            if pname not in vault_routes:
                vault_routes[pname] = _harness_provider_route(provider)
            if pname not in auth_providers:
                auth_prov = replace(_harness_provider_auth(provider), credential_provider=pname)
                auth_providers[pname] = auth_prov
                if auth_prov.host_dir_name not in seen_mounts:
                    seen_mounts[auth_prov.host_dir_name] = MountDef(
                        host_dir=auth_prov.host_dir_name,
                        container_path=auth_prov.container_mount,
                        label=f"{auth_prov.label} config",
                        credential_file=_PROVIDER_CREDENTIAL_FILE,
                        provider=pname,
                    )
            if pname not in all_names:
                all_names.append(pname)
        if provider.install_spec is not None and pname not in installs:
            installs[pname] = provider.install_spec
        if provider.help_spec is not None and pname not in helps:
            helps[pname] = provider.help_spec

    return AgentRoster(
        _agents=agents,
        _providers=providers,
        _auth_providers=auth_providers,
        _vault_routes=vault_routes,
        _sidecar_specs=sidecar_specs,
        _installs=installs,
        _helps=helps,
        _mounts=tuple(seen_mounts.values()),
        _agent_names=tuple(agent_names),
        _all_names=tuple(all_names),
        _web_ingress=frozenset(web_ingress_names),
    )