Skip to content

The OneCLI credential gateway: repo secrets stop entering runs at all — a sidecar proxy injects them into outbound HTTPS against the grants on that repo's agent identity, three optional settings leave the integration entirely off until an operator turns it on, and a run refuses to spawn without a reachable gateway

A repo secret ends up inside the instance. repo_secrets (migration 0008) are per-repo named blobs encrypted under the master key, and at spawn their values are forwarded into the pane environment — from that moment the plaintext is the agent's, and lab's only remaining moves are after the fact. That is exactly what lab has built: internal/tracker/secretscan screens what an agent writes to a tracker, internal/chat/redact scrubs the transcript the operator reads, and migration 0010 added a sticky exposed_run_id / exposed_at pair whose one remediation is rotating the secret. Every one of those is worth having and not one of them can un-send a value an agent has already put in an HTTP request. The mismatch underneath is that an agent almost never needs the value — it needs the capability: push to this registry, call this API — and lab hands it the value because a value is what lab has.

OneCLI (github.com/onecli/onecli) is an open-source credential gateway that inverts that shape. It is a Rust transparent HTTP proxy plus a Next.js dashboard; secrets sit AES-256-GCM at rest and are decrypted only at request time. It runs as a sidecar beside lab under docker compose, on two ports — 10254 dashboard/API, 10255 gateway proxy. A client authenticates to the proxy with a per-agent token in the Proxy-Authorization header; the gateway matches the outbound request against that agent's grants and injects the real credential into the request it forwards. Lab itself speaks to the REST API at http://<host>:10254/v1 with Authorization: Bearer <key>.

This ADR records the decision for the whole series. Issue #23 is the foundation epic — the sidecar, lab's configuration surface, an internal client, and health visibility — and deliberately changes nothing about how a run gets its secrets today: wiring runs to the proxy was #24 — since landed, and recorded in the amendment under Status — the per-repo grant picker is #25, the dashboard exposure modes are #26, and retiring repo_secrets is #27. What is pinned below is pinned for all of them.

The decisions, pinned:

  • Full gateway model, not a secret-fetch API. An instance holds no real secret at all. Its outbound HTTPS routes through the OneCLI proxy, which matches each request against the run's grants by host and path and injects the credential on the way out. That property — and only that property — is what makes the exfiltration path close rather than narrow: a compromised agent, a leaked transcript, a prompt-injected tool call cannot send a key the instance never held. Every weaker shape ends with the plaintext in the run's address space, where prevention is no longer available and detection is all that is left.

  • Scope: repo secrets only. What the credential gateway replaces is lab's repo_secrets system and nothing else. Git credentials are untouched: the vault, the master key, and ADR-0006's load-bearing two-slot split — credential_id for git auth materialized per operation, forge_credential_id server-side only — all stand exactly as decided. Provider (Claude/Codex) auth is untouched too; ADR-0055's refresher, fan-out, and adopt-back keep owning it end to end. Routing LLM traffic through the gateway is explicitly out of scope even though OneCLI supports it. And the removal or migration of repo_secrets is its own later epic (#27) — this ADR decides the destination, it does not drop a table.

  • Mapping: one OneCLI project for the whole lab; one OneCLI agent per lab repo. The project is the pool the secrets and app connections live in; the per-repo agent identity is the thing that draws from it. Per-repo secret assignment is therefore exactly "the grants on that repo's agent" — there is no parallel lab-side assignment model, because the grant set is the assignment and OneCLI is where it lives, so there is nothing for lab to keep in step and nothing to reconcile after an operator edits the pool from the dashboard. Lab maps repo → agent lazily and idempotently: ensure-agent creates by name at first need and tolerates the 409 a concurrent or repeat call produces, so the mapping costs no migration, no stored id, and no startup pass (superseded in part by the issue #35 amendment below — the match key became the agent's identifier, derived from the repo's store ID, rather than its name, and the mapping did grow a bounded, best-effort startup ensure pass; "no migration" and "no stored id" survive that amendment unchanged).

  • Fail closed. When the gateway is unreachable, runs refuse to spawn. Enforcement landed in the runs epic (#24), where it became one more spawn precondition beside the container preflight (ADR-0052) and the dev-image pull (ADR-0053); the decision belongs here because it is the property the rest of the design is built on. Open is the tempting default and it is wrong twice over. A run that starts without credential injection does not fail cleanly — it fails in credential-shaped ways, minutes in, as 401s and 403s from services the agent is certain it was given access to, and it then spends its budget clock debugging a permissions problem that does not exist. And an open failure mode means secrets sometimes travel and sometimes do not, which is not a security model at all: nobody can answer "what could this run reach" without also knowing whether the sidecar happened to be up at the time.

  • Configuration is three optional settings, and the integration is entirely off when they are unset. --onecli-url is the REST API base lab itself dials — typically loopback, e.g. http://127.0.0.1:10254. --onecli-api-key-file is the API key, a 0600 file carrying ADR-0006's master-key-file contract: read at startup, refused if the permissions are looser, never in argv or logs, and pointable at a sops-nix / LoadCredential path — with the one half of that contract that cannot apply left out, since lab never auto-generates a key another system mints. Those two must be set together, because half a REST config is dead config: a key file with nothing to send it to, and a URL with nothing to authenticate with, are both startup errors rather than a silently half-live integration. --onecli-gateway-url is the proxy URL #24 injects into runs as HTTPS_PROXY, and it is a separate setting rather than a port swap on the first one for a reason that is load-bearing: the address lab uses is loopback, but lab's container runner deliberately pins host.containers.internal and host.docker.internal to 127.0.0.1 inside the container (ADR-0052, amended by issue #216, precisely to close host reachability), so inside a run's netns loopback is the container's own loopback and nothing answers there. A containerized run must be handed a host address that is genuinely routable from where it runs. The same fact carries a deployment constraint that is not a preference and must be documented as such: where container runs are used, the gateway port cannot be bound strictly to loopback. The gateway URL is therefore independently settable, outside the pairing rule — the reachability probe and #24's run wiring both consume it without needing the REST API at all.

  • Dashboard exposure: off / subdomain / port, configurable — subpath proxying was ruled out. OneCLI's Next.js app has no basePath, so serving it under https://lab.example.com/onecli/ would need a fork of upstream. The fork was sized honestly — roughly six files — and rejected anyway, because the size is not the objection: it is that the fork never ends. It is a permanent rebase against a UI that keeps moving and that lab has no stake in owning, paid on every upstream release, to save the operator a DNS record or a firewall rule. So the dashboard is reached not at all, on its own subdomain, or on its port directly, and which of the three is configuration. The modes themselves are built in #26; this ADR records the decision and why the fourth option is not on the menu.

  • The client binding is deliberately partial. internal/onecli binds exactly the operations epics #24–#26 need: health, ensure-agent, secret and connection listing over the project pool, and grant list/attach/detach for an agent — the agent's stable access token rides along on that same agent listing, so no separate proxy-token endpoint is bound at all (see the 2026-08-14 amendment under Status). Nothing else, and nothing speculative. A full binding of OneCLI's API would be dead code lab must nevertheless keep correct against an upstream it does not control — every endpoint bound is one more thing that can break lab's build, or quietly break lab's behavior, on a release lab had no reason to care about. Tests run against a stubbed HTTP API, never a live sidecar.

  • Health is reported, not enforced, in this epic. Lab surfaces gateway reachability so an operator can confirm the sidecar is up before anything depends on it, and it distinguishes "not configured" from "configured and unreachable" — an unconfigured lab is not an unhealthy lab, and a health surface that says otherwise is one operators learn to ignore. Reporting is all this epic does with the answer: nothing refuses, retries, or pauses on it. The refusal is #24's, at the spawn, where it names the run it is refusing and the repo whose secrets are missing.

Status

Accepted. Settled via issue #23 (2026-08-13), the foundation epic of the OneCLI series — #24 (run wiring and fail-closed enforcement), #25 (the per-repo grant picker), #26 (dashboard exposure modes) and #35 (the agent identity's naming and lifecycle) have all since landed, each recorded in an amendment below except #25, which built exactly what was pinned above and had nothing to overturn; still ahead is #27 (repo_secrets retirement). Its SSH counterpart is ADR-0068, the non-HTTP sibling that fronts outbound SSH from runs through the Warpgate bastion on this same house pattern.

Amended by issue #24 (2026-08-13): the run-wiring epic, the first consumer of everything above and the one that had to price it. Nothing pinned above is overturned. What changes is that the three-setting pin becomes four, the fail-closed pin acquires a site, and the four questions #23 left genuinely open — how a run trusts a proxy that terminates TLS, what it must still reach directly, where the refusal lands, and what its context file then says — are settled:

  • The interception CA is located by a fourth optional setting, --onecli-ca-file / LAB_ONECLI_CA_FILE / services.lab.onecli.caFile: a host path to the gateway's interception CA in PEM. The alternative was fetching the certificate from the sidecar over the REST API so the operator configures one thing less, and it loses on two counts, the first decisive. The certificate must exist as a host FILE either way — the variables a run is handed (SSL_CERT_FILE and its three dialects) name a path, and a container bind mount needs a source path — so a fetched value would have to be written to a file before anything could consume it: the fetch does not remove the file, it only adds a network dependency to producing it, and a startup-time failure mode to a certificate that changes about as often as the sidecar's install. The second count is this ADR's own: fetching binds new OneCLI API surface, which "the client binding is deliberately partial" pins closed — one more endpoint to keep correct against an upstream lab does not control, bought to save one line in a config the operator is already editing. Like --onecli-gateway-url it sits outside the --onecli-url / --onecli-api-key-file pairing rule; unlike it, it is not fully independent — a gateway URL with no CA file refuses at the SPAWN, not at Parse, because Parse is pure shape validation that touches no filesystem and the launch path is the only layer that knows a run is about to be pointed at a proxy it could not verify.

  • Lab composes a per-run TRUST BUNDLE — the host's system CA roots followed by the gateway CA — and points all four CA variables at that, never at the interception CA alone. SSL_CERT_FILE (OpenSSL, curl, Go's own x509 loader), NODE_EXTRA_CA_CERTS (the provider CLIs are Node programs), REQUESTS_CA_BUNDLE (python-requests, which every scripting agent reaches for) and GIT_SSL_CAINFO (git's https transport, which must keep working through a TLS-terminating proxy) all carry one path, into the run's own runtime dir. The bare interception CA was the obvious cheaper thing and it is precisely wrong: a run does not stop making direct HTTPS calls when it gains a proxy, and a run whose sole trust root is the gateway's CA can verify nothing it reaches directly — the model connection, git push, and labctl all break, which is exactly the traffic NO_PROXY exists to protect, while proxied calls keep working. That is the worst available failure: half the HTTPS in a run fine, half failing with x509 errors, and no single symptom pointing at the cause. The system half is read from the first usable entry of a candidate list that MIRRORS crypto/x509's own certFiles, in its order, so the bundle is composed from the same file the host's clients would have used rather than a better-curated one. And there is no fallback: no system bundle on the host — or a zero-byte one, which is a broken trust store rather than an empty one — refuses the spawn naming every candidate it tried, rather than building the half-trusting run. Fail-closed, one layer down.

  • A container run trusts the bundle through the mount inventory that already exists. The bundle is written into <state>/instances/<runID>/runtime/, the per-run runtime dir ADR-0052 already binds rw at its host-identical path beside the materialized git credential files — so the absolute path in the four CA variables resolves to the same file inside the container and outside it, unchanged. No new podman flag, no new mount, no path translation, and nothing for RewriteHomeEnv to rewrite (that rewrite is anchored on the instance HOME, a sibling directory). ADR-0052's inventory is untouched; this is the second thing to consume the per-run runtime dir for the reason it was made per-run in the first place. The bundle is mode 0644 deliberately — a CA bundle is public material, and under --userns=keep-id a 0600 file owned by the lab user is a file the agent may not be able to read.

  • HTTPS_PROXY is a credential, and is handled as one. The proxy URL carries the repo's agent-identity token as userinfo, so on a container run the HTTPS_PROXY/https_proxy pair is seeded into the pane environment by tmux -e and forwarded inward by NAME only (--env K), exactly like LAB_TOKEN (#204's mechanism, extended by ADR-0052 across the container boundary) — never as --env K=V in the podman argv, where the value would be visible in ps, in podman's logs, and in every argv a test prints. One classification, one place: the launch path asks the same question the container renderer answers for LAB_TOKEN. The rest of the bundle goes the other way on purpose — NO_PROXY/no_proxy are hostnames and the four CA variables are a path, public by construction and deliberately VISIBLE in the argv, so an operator reading a run's command line can see what that run trusts and what it exempts. Both case spellings are emitted for the same non-negotiable reason: curl reads lowercase only, some Node stacks read uppercase only, and a gateway a tool fails to notice is worse than no gateway at all. HTTP_PROXY is deliberately absent — this ADR fronts outbound HTTPS, and adding the plaintext pair here would decide a separate question silently. The token is folded into the USERNAME slot of the proxy URL's userinfo, which is lab's documented assumption about how OneCLI reads Proxy-Authorization: Basic …, made without a live sidecar to check it against; one function is the single point of correction if a real build wants it elsewhere, and every caller downstream holds an opaque URL string precisely so that knowledge never spreads.

  • NO_PROXY is the lab host, the repo's forge host, and the resolving provider's declared direct API hosts — bare hostnames, deduped, comma-joined, ports stripped. The rule it encodes is that a gateway outage must break exactly one thing, secret-backed API calls, and nothing else: lab so labctl and the agent API keep working (a run that cannot reach lab dies silently while the operator watches an idle pane), the forge so git push/git fetch and the tracker keep working (git auth is ADR-0006's vault credential materialized per operation, not a gateway grant — forge traffic has nothing to gain from the proxy and everything to lose), and the model endpoint so the agent keeps streaming, since this ADR rules LLM traffic out of the gateway's scope and an unreachable sidecar must not be able to take the model connection down with it. The LLM host is provider-declared (provider.SeedMeta.DirectAPIHosts) rather than a core constant, and that is not tidiness: core naming one provider's API host is exactly what ADR-0033's neutrality guard fails the build on, and a core constant would be wrong as well — once a second provider is registered, every run would exempt the first provider's host and silently proxy its own model traffic. Only the adapter knows which endpoint its CLI dials. It is the RESOLVING provider's list, never the registry union (a run streams to its own provider and no other), and an empty declaration is legal and contributes nothing — which is the honest current state of the codex adapter, whose model host has never been confirmed against a live CLI to ADR-0008's pin-it-in-internal/compat bar, so a gateway-wired codex run's LLM traffic goes through the proxy today and a gateway outage can stall it. A guessed hostname would have been strictly worse than none: it would exempt an address the run never dials while the real one stays proxied, and read as verified to the next person. A blank list omits the NO_PROXY pair entirely rather than emitting it empty — several clients read a PRESENT NO_PROXY as authoritative and stop consulting anything else, so NO_PROXY= can mean "exempt nothing, and stop looking".

  • The proxy token is minted at most once per agent identity per lab process (superseded by the 2026-08-14 amendment below — the mint-once machinery described in this bullet was deleted once the stable-token read was verified), under a lock held across the mint. AgentToken is a POST that REGENERATES, so minting per spawn would invalidate the token every already-running run of that repo still holds: starting a second run of a repo would silently break the first one's gateway calls, and "my credentials stopped working the moment a colleague started another run" is about as far from its cause as a symptom gets. The residual is accepted, not unnoticed: a lab restart empties the map, the next spawn mints fresh, and a run that outlived the restart keeps a token the gateway no longer honours — its proxied calls then fail per request. That is the same end state this ADR already accepts for a gateway outage, reached by a different road, and the run's own operator sees it as the gateway rejecting that run's calls rather than as silence. The token is not persisted to close it: it is a credential, and lab's vault is git-credential/master-key territory (ADR-0006) that this epic deliberately does not extend. The identity itself is keyed on the repo's STORE ID, never its name — grants attach to the agent, so the name a run authenticates under has to survive a repo rename, and keying on the name would silently mint a second, grant-less agent the first time an operator renamed a repo.

  • Every refusal lands PRE-CLAIM. The gateway precheck runs before the start guard and long before AddWorktree — which for an AFK run is the claim — so a sidecar outage refuses a spawn without parking an issue, exactly like ADR-0052's container preflight and ADR-0063's import materialization beside it; the trust-bundle write sits later only because it needs the runtime dir the credential materializer creates, and still lands before the worktree. No worktree, branch, or run row exists at either point, so a refusal leaves nothing behind but the per-run directory it wipes on its way out. The set: the gateway URL set with no CA file; the gateway unreachable; the agent identity or its proxy token unobtainable; a gateway URL that is not a usable http(s) address; a CA file that is missing, unreadable, or carries no PEM certificate block; no system CA bundle on the host. All of them are 400s carrying the operator's own text verbatim, because what is wrong is always the deployment and rendering a host problem as a lab fault helps nobody. Grant LISTING is the one step that does not fail closed, and the asymmetry is the point: the grant list is documentation — it renders the context file's inventory of what the repo may reach — and a run holding a valid token has exactly the access its grants describe whether or not lab could render the paragraph. Documentation must never be why a run refuses to start. An empty list is not a failure either; a freshly created agent identity has none until #25's picker attaches some.

  • With OneCLI unconfigured, a spawn is byte-identical to before. The gate is one predicate, not a nil-check per call site: no precheck, no trust bundle, no proxy env, no gateway ref to the seeder. And a lab configured with the REST pair but no --onecli-gateway-url is unconfigured for this purpose, not a refusal — the gateway URL is independently settable (#23's health surface is exactly such a deployment), such a lab has nothing to point HTTPS_PROXY at, and the fail-closed pin is about a gateway that was configured and is unreachable, never about a gateway nobody asked for.

  • The generated context file's Secrets section forks, and only for a wired run. A gateway-wired run's section teaches the inversion — the environment holds no value and there is nothing to read, print or exfiltrate; call a granted service's API exactly as its docs say and send a placeholder where a credential is required; here are the services granted to this repo — with labctl secret exec and the whole "handle the value carefully" drill gone, because there is no value to handle. An unwired run still renders the legacy section verbatim: repo_secrets stays live in parallel until #27, and an agent on an unwired lab still needs to know how to use it. The two are mutually exclusive by construction rather than by convention — two ## Secrets headings would be two contradictory norms in one file, and which one an agent obeyed would come down to which it read last. The seeder is handed granted service NAMES and nothing else: no proxy URL, no token, no value, so nothing reachable from the render is credential-shaped in the first place. Names are sorted, so the file does not reshuffle between two runs of the same repo.

  • One caveat is documented rather than solved, in that section and in docs/ops.md: a tool that ignores proxy environment variables bypasses the gateway and gets 401/403 from the real API. It is a tool problem wearing an access problem's clothes, and it is explicitly not a leak — the environment never held a real key, so the bypassing tool has nothing to send. Closing it properly means egress filtering, which is out of scope here for the same reason it was out of #216's.

Amended (2026-08-14): the wire contract is now VERIFIED, and the mint-once pin above is superseded by something strictly better. The first live spawn against a real OneCLI build (1.45.0) falsified two of wire.go's good-faith readings — the create body requires an identifier slug alongside the name, and the token endpoint lab guessed (POST /agents/{id}/token) does not exist — which is precisely the failure mode the "single point of correction" consequence below was designed to absorb, and it worked as designed: the corrections are one file plus its tests. Every path and shape in wire.go is now read off the 1.45.0 source rather than assumed (grants rows key their ids as secretId/connectionId, a connection's display name is its nullable label, a connection attach PUT requires an {"access":"full"} body, errors arrive in a nested envelope). The consequential discovery: the agent listing carries each agent's stable accessToken, so lab now READS the run credential from the listing EnsureAgent already fetches and never touches the regenerate endpoint at all. That deletes the mint-once machinery (the per-process cache, its cross-spawn lock, and the deliberately-accepted restart residual — a run that outlives a lab restart now keeps a token the gateway still honours, because nothing ever regenerates), keeps the invalidate-your-concurrent-neighbour hazard structurally impossible rather than cache-suppressed, and binds one less write endpoint. The identifier is derived from the agent name deterministically (lowercase, non-slug bytes to -, capped at 50), which is what keeps EnsureAgent's 409-race resolution sound: same repo, same slug, so a conflict can only ever mean "already exists".

Amended by issue #26 (2026-08-14): the dashboard-exposure pin is built, and the shipped modes are exactly the three it named. Nothing above is overturned — subpath proxying stays rejected on the same grounds, and there is still no fourth mode. What the epic settled is how each of the three is actually made, and one property of the login round trip that is easy to get wrong and expensive to get wrong late:

  • port is a lab-owned SECOND LISTENER, not a route on lab's own. cmd/lab opens a second http.Server on --onecli-dashboard-addr and serves a whole-origin httputil.ReverseProxy onto the existing --onecli-url — no new OneCLI-side address, because the dashboard and the REST API share one port and "the client binding is deliberately partial" leaves nothing for a second setting to pair with. Whole-origin rather than path-prefixed is what makes it work at all: the app's absolute asset and route URLs land back on the origin they came from, which is precisely what the rejected fork existed to fake. In front of it sits one gate, admitting any identity resolveIdentity produces — a PAT or a trusted-proxy header beside a session cookie, because the question is "is this principal authenticated to lab" and narrowing it to cookies would refuse a client that already passed the same checks admitting it to the rest of /api/v1. Lab's own session cookie is stripped from the forwarded request: it is a lab credential, the dashboard has no use for it, and forwarding it would put a bearer token for lab's entire API into a separate process's logs and traces. No cookie configuration is needed in this mode, and the reason the obvious worry is not one is worth writing down: RFC 6265 cookies are host-scoped, not port-scoped, so lab's existing session rides to the second port unchanged — and another port on the same host is still same-site, so SameSite=Strict survives and is in fact this listener's whole CSRF defense (lab's X-Lab-Csrf convention is deliberately absent: the dashboard is a foreign application that would never send it, and requiring it would reject every mutation that matters).

  • subdomain is forward-auth plus a parent-domain session cookie, and both halves are load-bearing. GET /api/v1/auth/check answers 204/401 with an empty body — the shape nginx's auth_request and caddy's forward_auth both read, and an endpoint that returns nothing cannot be made into a data leak by a proxy that is designed to call it on an anonymous visitor's behalf. --session-cookie-domain is the other half: lab's cookie is host-only by default and would otherwise never reach onecli.<domain> at all. Widening Domain is a genuine trade — every host under the domain then receives lab's session cookie — so it is never derived from --base-url or --onecli-dashboard-url, only ever set on purpose, and docs/ops.md warns in exactly those terms. What it is not is a reason to relax SameSite: sibling subdomains share a registrable domain and are therefore same-site, so Strict rides along untouched.

  • The exposure is REPORTED, at GET /api/v1/onecli/dashboard — the resolved mode plus the browser-facing URL, url omitted rather than empty when nothing is exposed. That endpoint is what keeps #25's grant picker topology-free: the SPA renders or hides its link-out from one answer and never has to learn the difference between "lab proxies it on a second port" and "your nginx fronts onecli.example.com". It is deliberately separate from /api/v1/onecli/health rather than one more field on it — exposure is static process configuration resolved once at startup, health spends a probe budget on two live network dials, and folding them together would make every screen that renders the link pay for those probes while letting a dead sidecar take the link down with it.

  • The bounce-back carries a KEYWORD and a path, never a URL. An unauthenticated browser navigation on the port-mode listener is redirected to <base-url>/login?next=onecli-dashboard&path=<escaped path+query>, and the SPA composes the destination from that fixed keyword plus the origin it reads back from the exposure endpoint — so the origin always comes from lab's own configuration and never from the query string a stranger sent. The obvious ?next=<absolute URL> is exactly the design this avoids: it reduces every login page that implements it to one carefully-maintained allowlist standing between a phishing link and a credential prompt that redirects wherever the link said, and that allowlist is one refactor away from being wrong. Here there is no place in the parameter where an attacker's origin could be written — nothing for a validator to get wrong, and no validator to forget to run. A second bounce destination gets a second keyword, never a URL. Anything that is not a navigation — a form POST, an XHR, an asset fetch from a page whose session expired — gets a plain 401 instead, because a 302 to a login page is an illegible failure for every client that is not a browser.

  • off remains the default, and the SSH tunnel remains the access path there — ssh -N -L 10254:127.0.0.1:10254, needing no lab configuration whatsoever. The other two modes ADD an authenticated front door and remove nothing, which also fixes the reading of the epic's own acceptance criterion: this proxy cannot take away a route the operator left open, so "direct unauthenticated access is impossible" stays a property of binding 10254 to loopback rather than of anything in lab's code, and docs/ops.md owns that instruction.

Amended by issue #35 (2026-08-14): the repo → agent mapping is re-hung on OneCLI's own unique key and given a lifecycle, and one clause of the mapping pin above is OVERTURNED — the mapping now costs a startup pass. What that clause got right survives it intact: there is still no stored id and no migration, because the handle lab matches on is DERIVED from the repo's store ID at every call site rather than remembered anywhere, and the pass this adds writes nothing down either — it is a convergence loop, not a reconciler with a table behind it. Every shape below was read off the same OneCLI 1.45.0 source as the wire verification above, not assumed:

  • The identifier is the MATCH KEY and the name is display; lab had the two backwards. Upstream constrains an agent's identifier @@unique([projectId, identifier]) and lets no endpoint move it once the agent exists, while name carries no uniqueness at all and is freely mutable — and lab was matching on the name while putting the repo's store ID into it, the one arrangement that gets both fields wrong at once. So the identifier is now the slug onecli.AgentIdentifier derives from the repo's store ID (repo_<32 hex> → repo-<32 hex>), the name is the repo's human name, and every lab call site that addresses a repo's agent — repo create, the startup pass, the spawn's launch path, the grant picker's two resolvers, repo delete — resolves by EXACT identifier: never by name, never by prefix or pattern. Matching on a name is not a looser version of the right match, it is a different and unsound one, and lab's own repos.name UNIQUE constraint buys nothing here because the uniqueness that would have to hold is OneCLI's: the project is shared surface, upstream constrains agent names not at all, and an agent lab never created — NanoClaw's, an operator's own — may perfectly legitimately carry a name equal to a lab repo's, at which point that repo authenticates to the gateway as somebody else's identity and draws grants that were never meant for it. The name also moves under a rename, which is the failure the store-ID-as-name scheme was defending against in the first place; putting the store ID somewhere immutable defends against it without giving up the readable label. The derivation lives in exactly one exported function for the same reason — a second copy of the rule is one drift away from a repo owning two agents, only one of which carries its grants. The migration is nil: the derivation is byte-identical to the identifier the create path has been sending since the wire-verification amendment above, so every agent created before this change already carries the identifier lab now matches on. The first ensure adopts it and renames it in place; there is nothing to backfill, no dual-read window, and no cleanup pass. What the inversion buys the operator, beyond correctness, is the reason a display name is worth carrying at all: a dashboard whose agent list reads as repo names instead of a column of repo_4b1f0c7a….

  • Ensure heals the name in place, which means lab OWNS that string. EnsureAgent(ctx, identifier, displayName) lists, matches on the identifier, and PATCHes the name whenever it differs from the one lab says the repo has — so both renames that exist are covered by one move at whichever touchpoint comes first: a repo renamed in lab, and a pre-#35 agent still named after a store ID. No stored state, and no call site that exists only to rename. The alternative shape — a rename hook hung on the repo-update path — was rejected on the failure it invites rather than on cost: it must be called from every path that can change a repo's name, and the path that forgets leaves a permanently stale row that nothing will ever correct, because nothing else looks. The price is real and is not hidden: a rename typed into the OneCLI dashboard does not survive. The next ensure stomps it back, and an operator who wants a different label renames the repo in lab. Either lab is the authority on that string or nobody is, and a field two systems both claim is a field that flaps. The heal must never fail its caller, though: the name is cosmetic while the launch path is fail-closed on ensure, so a PATCH that errors returns the agent that was found, under the stale name it still has, and the next touchpoint tries again.

  • Repo creation ensures the agent eagerly, best-effort, and the lazy ensures stay as backstops. reposvc.Add ensures the identity as part of creating the repo — warning and continuing when the sidecar is down, and doing nothing at all when OneCLI is unconfigured. Best-effort is the right strength precisely because the SPAWN is already the fail-closed enforcement point: refusing to create a repo because a credential sidecar is unreachable spreads a credential failure into a screen that has nothing to do with credentials, where the operator can neither add the repo nor fix the sidecar, and it buys no safety a run's own refusal does not already provide. What eager creation buys is a workflow that was structurally impossible before: an operator can open OneCLI's dashboard and provision a new repo's secrets before that repo has ever spawned, because the agent to attach them to exists from the moment the repo does. Until #35 the identity first appeared at the first spawn or the first grant attach, so the dashboard had nothing to attach to and lab's own picker was the only way to bring one into being. And the lazy ensures do not go away now that Add does the work — they cannot be removed at all: every repo created before OneCLI was configured has no agent, and a lab that ensured only at repo-create would never give them one.

  • Startup runs a one-way ensure over every repo — the pass this ADR said the mapping would not need. reposvc.StartupHeal gains a bounded, best-effort loop that creates missing agents and heals stale names, and it is one-way on purpose: it never deletes. The OneCLI project is shared surface — NanoClaw's agents, agents an operator made by hand, whatever else a deployment points at the same project — so a reconciler that removes what it does not recognize would destroy other tools' identities and their grants for the sole offence of not appearing in lab's repo table. That is a blast radius produced by a bookkeeping routine, on exactly the material this ADR exists to protect. It never fails boot either: the sidecar comes up in parallel with lab in the same compose stack, so "the gateway is not answering yet at startup" is the EXPECTED case rather than an edge case, and a lab that refuses to serve because a credential sidecar is still starting is a worse outage than every stale name it was trying to fix. The whole sweep is bounded by one timeout rather than per call — a sidecar that accepts and then hangs would otherwise cost the client's own per-request timeout once per repo with boot waiting behind all of it — and it stops at the first failure with a single warning naming the repo it stopped on and the count it never reached, because every error reachable there is sidecar-level and therefore the same error waiting for every remaining repo: pressing on would render one outage as a wall of identical lines and bury whatever else the boot logged. What the pass buys is convergence for everything the touchpoints miss — repos renamed while the sidecar was down, repos that predate the configuration, agents renamed in the dashboard — which is why it is worth overturning a pin for; what it deliberately does not buy is a second source of truth, which is why the rest of that pin stands.

  • Deleting a repo deletes its agent, and the cascade stops exactly where it should. reposvc.Delete resolves by exact identifier and DELETEs — best-effort again, warning and orphaning when the call fails or OneCLI is unconfigured, because an orphaned agent is the status quo this issue found rather than a regression it introduces, and taking a repo delete down over a sidecar outage would strand the repo half-deleted with no better answer available anywhere. Upstream cascades the agent's grants away with it while leaving the project's secret and connection POOL untouched, and that shape is worth stating precisely: deleting a repo revokes that repo's access to shared credentials and destroys none of them, because other repos and other tools are still using the same secrets. The one answer worth reading rather than retrying is upstream's 400 "Cannot delete the default agent" — agents lab creates are never default, so that error can only mean lab was pointed at an agent it did not create, which is a deployment fact to REPORT and never a case to work around.

  • Exactly two endpoints are newly bound, and "the client binding is deliberately partial" holds unchanged. PATCH /agents/{id} takes {"name"} and nothing else — the identifier is immutable upstream, so the only write lab makes to an existing agent is structurally incapable of moving that agent's match key — and DELETE /agents/{id} answers 204 with the cascade above. Two, not a lifecycle surface: each is called by a hook that actually exists, neither is bound against a future need, and the regenerate-token endpoint sitting beside them in the same upstream source stays deliberately unbound for the reason the wire-verification amendment gave. The fallback is pinned here because the obvious repair is catastrophic: if a live build rejects either shape, lab degrades to what it did before — stale display names, orphaned agents — one cosmetic, one janitorial, both survivable indefinitely. It must NEVER delete and recreate an agent in order to rename it. That destroys the grant set an operator attached (the cascade cuts both ways) and mints a new access token, 401ing every in-flight run of that repo: a wrong label traded for a fleet-wide credential outage, on a code path whose entire subject is a display string.

Everything else this ADR decided stands.

Supersedes ADR-0006's rejection of an external secret manager — in part, and only for repo secrets. That rejection ("External secret manager (vault server, sops at runtime). Rejected: lab is self-contained (D2); sops integration happens at the master key file boundary, which NixOS provisions.") stands unchanged for git credentials and the master key: the vault is not going anywhere, credentials stay AES-256-GCM in lab's own database under a key file the operator provisions, per-op materialization and the two-slot split are untouched, and a lab with these flags unset is as self-contained as it was. What is overturned is that rejection's reach over repo secrets — and the reason is not that self-containedness stopped mattering. It is that the credential gateway is not the thing ADR-0006 rejected. A secret manager hands lab a value to hand to a run; the gateway model means the value never enters the run. That is a property no amount of vault-server integration would have bought, and it is the only thing that makes a runtime dependency worth taking on.

Realizes ADR-0055's deferred credential proxy — the same architectural move, applied to a different credential class. ADR-0055 left a server-side credential proxy explicitly open rather than rejected ("Deferred, not rejected"; "A server-side credential proxy remains a live option, and nothing this design does forecloses it"), and this is that move made concrete. The citation needs its qualification stated, not buried: 0055's deferred proxy concerned provider credentials at the InjectCredentials seam, and this epic leaves provider auth untouched — so that seam stays exactly where it is, still snapshot-plus-fan-out, still the single call site a future proxy would replace. Nothing here closes it. What this ADR does is prove the pattern on the credential class where the payoff is largest and the blast radius smallest, which is a precedent for 0055's seam and not a resolution of it.

Extends ADR-0052 without changing it. The runner's --add-host host.containers.internal:127.0.0.1 / host.docker.internal:127.0.0.1 pins (amended by issue #216) are the whole reason --onecli-gateway-url exists as its own setting; the runner's argv, mount inventory, and refusal semantics are untouched, and the gateway is reached over ordinary egress, which #216 deliberately left open.

Leaves untouched: everything in ADR-0006 outside that one rejected option; ADR-0055's refresh loop, fan-out, adopt-back, and wipe-site adopt-checks; the run token and its per-repo tracker scope (ADR-0009); and lab's own repo_secrets system, which keeps working exactly as it does today until #27 retires it.

Considered options

  • Subpath-proxy the OneCLI dashboard behind lab at https://lab.example.com/onecli/. Rejected: OneCLI's Next.js app has no basePath, so its asset and route URLs are absolute and a reverse proxy alone cannot rehome them — it takes a fork. The fork was sized at roughly six files, and it was rejected on permanence rather than size: upstream keeps moving, so the six files become a rebase obligation on every release, for a UI lab neither owns nor wants to own. Subdomain and port deliver the same dashboard for a DNS record or a firewall rule.

  • Keep repo_secrets and merely encrypt harder — per-run keys, shorter lifetimes, tighter key handling. Rejected: every variant still ends with the plaintext inside the instance, so the exfiltration path this closes stays open. The proof is already in the codebase: the transcript redactor, the tracker secret scan, and the sticky exposed_run_id/exposed_at flag exist because the run holds the value, and none of them is reachable by better cryptography at rest. Encryption protects the secret from everyone except the one process the threat model is about.

  • A secret-fetch API instead of a proxy — lab or the agent asks OneCLI for a value and then uses it. Rejected for the same reason one layer up: the agent ends up holding the plaintext, and the instant it does, every control downstream is detection again. It is the gateway design with the property that justifies it removed, in exchange for a slightly simpler client.

  • One OneCLI agent for the whole lab. Rejected: it collapses per-repo isolation into a single blast radius — every run authenticating as one identity entitled to the union of every repo's grants — which is exactly what ADR-0006's two-slot credential split exists to prevent, re-created one layer out. Per-repo agents cost one lazy create-by-name and buy a boundary that means something.

  • One OneCLI project per lab repo. Rejected: the project is the pool the grants draw from, so a project per repo multiplies the places a secret must be created, rotated, and audited while buying no isolation the per-repo agent does not already give. Isolation lives at the grant boundary; the project is inventory.

  • Fail open — spawn anyway when the gateway is unreachable. Rejected: a run without injection burns its budget clock chasing 401s that look like a misconfigured service rather than a missing sidecar, and an intermittent injection guarantee makes "what could this run reach" unanswerable in principle. A refusal is legible, recoverable, and honest about what happened.

  • Route LLM traffic through the gateway too. Rejected as out of scope: OneCLI supports it, and doing it would put the provider CLIs' authentication — which ADR-0055 owns, pokes, fans out, and adopts back, against tokens that rotate unattended — behind a second moving part, inside an epic whose job is to stop repo secrets entering runs. Provider auth is the one credential class in lab with a live rotation protocol, which makes it the last thing to hand to a new dependency, not the first.

Consequences

  • Three new optional flags and env vars, and their NixOS module options — four since #24. --onecli-url / LAB_ONECLI_URL, --onecli-api-key-file / LAB_ONECLI_API_KEY_FILE, --onecli-gateway-url / LAB_ONECLI_GATEWAY_URL in internal/config, with services.lab.onecli.{url,apiKeyFile,gatewayUrl} in nix/module.nix (ADR-0002's module-as-deployment-layer, apiKeyFile following masterKeyFile's sops-nix / LoadCredential patterns). The first two are enforced as a pair at Parse; the third is deliberately outside that pairing and must stay there — the gateway probe and #24's run wiring consume it without the REST API. #24 added --onecli-ca-file / LAB_ONECLI_CA_FILE / services.lab.onecli.caFile on the same footing, outside the pairing rule and validated as a path only, with its "gateway URL set, CA file unset" refusal landing at the spawn rather than at Parse (see the amendment under Status).

  • A new internal/onecli package whose wire structs are the single point of correction. Upstream's JSON is undocumented in places, so the package is split deliberately: the client's exported surface expresses lab's operations, and wire.go holds every struct and path segment that mirrors OneCLI's actual responses. When upstream's shape differs from what lab assumed, exactly one file changes and every caller is downstream of it. #35 grew that binding by exactly two endpoints — PATCH /agents/{id} to rename an agent and DELETE /agents/{id} to remove one — each called by a repo lifecycle hook that exists, each read off the same 1.45.0 source as the rest of the file, and neither loosening the "nothing speculative" pin (see the amendment under Status).

  • A new authenticated GET /api/v1/onecli/health that always answers 200 and reports off (nothing configured), ok, degraded (one of the configured endpoints answers and the other does not), or unreachable. The status is the payload, never the HTTP code — an unconfigured lab is not an unhealthy lab, and the SPA and any monitoring probe read one response shape either way.

  • docs/ops.md gains a sidecar-deployment section — the docker compose unit, per-port configurable bind interfaces, and the constraint stated plainly: where container runs are used the gateway port cannot be bound strictly to loopback, because the runner pins host.containers.internal to 127.0.0.1 inside the container. That sits directly against the same file's standing guidance to bind co-located host services to 127.0.0.1 (§ Container runner, ADR-0052 as amended by #216), so the new section has to name the gateway as the deliberate exception rather than leave two rules in the same document contradicting each other.

  • CONTEXT.md gains the secret vocabulary it never had — credential gateway, grant, and agent identity, with avoid-lists — and two of the three collide with words already in use, so the entries have to disambiguate rather than just define. "Grant" is already ADR-0063's word for a read-only import declaration, and "agent" everywhere else in this repo means the coding agent, not a credential-scoping identity in OneCLI. Both collisions land in Flagged ambiguities beside the existing "session"/"instance" and "token" entries.

  • Lab acquires a runtime dependency it does not ship. An operator who configures the flags now has a second process to run, back up, and upgrade — its own database, its own encryption key, its own release cadence, and its own failure modes to recognize on a bad night. Lab's self-contained posture survives only for the deployment that leaves the flags unset, which is why the integration is off by default and arrives as an opt-in rather than a migration. This is the price of the property in the first pin, and it is the largest genuine loss on this list.

  • Lab writes to the shared OneCLI project on repo lifecycle events, and owns each agent's display name — since #35. Creating a repo ensures its agent identity, deleting a repo deletes it, and startup converges the set; every one of those writes is best-effort and none of them ever deletes an agent lab did not create, because the project is surface other tools and hand-work share. The operator-visible cost is the second genuine loss on this list and much the smaller of the two: an agent renamed in OneCLI's dashboard is stomped back at lab's next ensure, so the label is changed by renaming the repo in lab. What it buys is a dashboard that lists repos by name, and secrets that can be provisioned for a repo before its first run ever spawns.