feat(model-auth): model-provider logins for every agent, not just openclaw - #378
Conversation
`rsync -a` implies `-D`, so codex's live `~/.codex/ipc/ipc.sock` made every agent-config stage try to recreate the socket. On macOS that bind() fails with EINVAL once the stage path passes sockaddr_un's 104-byte limit, so the rsync exited 23 and took the whole hetzner bake with it. Claude-Session: https://claude.ai/code/session_015SayJ3DT4xs8exBKeaG6vQ
The VNC path resolves its own target — the host's forwarded port is nothing inside the box — so it never picked up the sign-in fragment the host's browser gets, and openclaw's Control UI opened on its token prompt instead of the dashboard. `withServiceSignIn` applies the agent's declared url fields to a target, and every surface that opens the screen (CLI, dashboard, hub) now passes the provider that reads them. Claude-Session: https://claude.ai/code/session_015SayJ3DT4xs8exBKeaG6vQ
…rovider A service agent can now declare `modelAuth`: which other agents' host-held logins it may borrow, and the service task that ingests them. The host lands the lender's file at the lender's own credential path, 0600, over the carry step every provider already runs before the first supervisor task; the row's own task turns it into the agent's store. AgentBox never learns the format. OpenClaw borrows codex: `agentbox openclaw --model-auth codex`, the `openclaw.modelAuth` config key, or a TTY prompt defaulting to no. In the box `openclaw-model-auth` installs the official @openclaw/codex plugin and runs `openclaw migrate apply codex --item auth:openai` once, then leaves the profile to OpenClaw, which refreshes it independently. Measured on openclaw 2026.9.3: the auth store is SQLite and the retired JSON files are never read; a bare ~/.codex/auth.json shows a bootstrapped profile that a turn cannot use; OpenAI does not invalidate a prior refresh token on rotation, so two boxes seeded from one host file and the host itself all keep working. Also: docker's carry step now runs before the ctl daemon (it raced a task that reads a carried file on first boot), the credential fan-out reaches borrowing boxes and re-runs their ingest, a clone borrows what its source did, and the hub Box payload carries `borrowedCredentials`. Claude-Session: https://claude.ai/code/session_01WAFtRj2zMLmKKCFAyXLXvY
…ument it OpenClaw's own status view cannot say "already imported": a bare seeded ~/.codex/auth.json shows a bootstrapped openai:default profile, source store, status ok, that a turn cannot use — so the first live run skipped the import and the gateway came up with no usable auth. The task now hashes the seeded file and imports when the hash is new, which also stops a later boot from replacing the box's own refreshed chain (the import applies on every run). Docs: the OpenClaw page's "Model provider" section, the CLI reference, the openclaw.modelAuth config row, the borrowed-credentials paragraph in docs/agents.md, and docs/service-agent-model-auth-plan.md with what the PoC measured on openclaw 2026.9.3. Claude-Session: https://claude.ai/code/session_01WAFtRj2zMLmKKCFAyXLXvY
… push Per-gateway approval state, plus doctor's .migrated archives. The host's copy from an older openclaw rode the cloud static push into a 2026.9.3 E2B box and every turn failed with "Legacy exec approvals exist ... run doctor". Claude-Session: https://claude.ai/code/session_01WAFtRj2zMLmKKCFAyXLXvY
The `carry:` and `--model-auth` gates lived in apps/cli and called clack directly, so only the CLI could ask them. A box created from the tray or the hub web UI skipped both silently. Add `PromptRequest` in @agentbox/core — a generic envelope (kind/title/choices/ fallback) plus an optional typed `detail` a client MAY render richly (a carry file table, a credential card) and otherwise renders as `summary`. Ids are content-addressed over the question, so a preflight answer cannot be replayed onto a question that changed. Both gates move to @agentbox/sandbox-core and take a `PromptAsker` instead of a prompt library, so the hub can run the identical decision. The CLI keeps its behaviour through a clack asker: `-y` still does not auto-approve carry, and a non-TTY still refuses a `required` prompt with the gate's own hint. `CarryItem` moves to @agentbox/core (ctl re-exports it) because ctl -> relay -> sandbox-core would cycle; parsing stays with the caller via `loadCarrySpec`. The relay's `PromptAskEvent` gains the same `PromptKind` union and an optional `richDetail`, so host-action approvals can share one renderer later. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
A box created from the tray or the web UI now runs the same host-boundary gates a CLI create does, instead of silently skipping them. `POST /api/v1/projects/:id/create-preflight` returns the questions a create would ask; the client renders them and sends the answers back as `opts.promptAnswers`. Both halves call one `runCreateGates`, the preflight with a collecting asker and the create with a map-backed one, so the questions shown are by construction the questions asked. The gates run before the job is queued — the same front-loading the CLI's `-i` path does — so the queue worker is unchanged. A client that skips the preflight gets each prompt's fallback; a `required` one (carry, which moves host secrets) fails the create loudly rather than silently skipping. A stale answer id means the question changed since the preflight, and is refused rather than applied. A control box has no local checkout, so it can read neither the caller's `agentbox.yaml` nor their host logins: it reports both gates in `unavailable` rather than returning an empty list that looks like "nothing to ask". Also parses `opts.borrowCredentials`, which hub-backend has always read but validation dropped — the key was dead on the wire. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Create now runs a preflight first: if the hub has questions, the form is replaced by them one at a time, and answering the last one submits the create with the collected answers. `PromptView` has two halves on purpose. The generic one (title, body, choice buttons) renders any prompt, including a topic this build has never heard of, so the hub can add a gate without shipping new UI. The typed one draws the variants worth drawing: a `carry:` file table (host -> box, size, flags, with a symlink-out-of-$HOME row highlighted) and a credential card. An unrecognised detail falls back to its `summary`. Gates the hub could not run are shown above the form rather than dropped, so a control box that cannot read this machine's files says so before the user commits to a box. Verified against a live hub through the API: preflight returns the real 15-entry carry table for this repo, a create without answers is refused, and one with the answer copies 13/15 entries into the box at 0600. The web page itself could not be exercised — every hub page 500s in the standalone build on a pre-existing untraced-execa error that also reproduces on the pre-branch staged bundle. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Documents the preflight contract in the API reference (both halves of `PromptRequest`, why an unknown `topic` still renders, what `required` and a stale id mean), and adds the tray/web story to the `carry:` and OpenClaw model-provider pages — including the one case that genuinely cannot ask, a control box with no checkout of your project. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The preflight learns its questions by running the real gates with a collecting asker, which answers each prompt with its own fallback — and carry's fallback is `cancel`. So the carry gate aborted the run and every later gate's question was missing from the list: an OpenClaw box created from the tray or the web UI came up with no model provider, silently, which is the exact bug class this work exists to remove. Collection is now an explicit dry run that decides nothing. Caught by asking a live hub for an `openclaw` preflight and getting one prompt back instead of two. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The model-auth prompt used `AgentBorrowSpec.label` — a description, not a button label — as its choice text. At 44 characters the two buttons overflowed the tray card, running off the left edge and drawing the hint underneath them. It is now a plain yes/no; the card below already names which login it is. Buttons also wrap to a new row instead of overflowing: a choice label is free text from the hub, so no width can be assumed to fit. The hint moved to its own row, having been sized from whatever width was left over. Rest is copy. The prompts are read by whoever is deciding, not by whoever wrote the `carry:` block, so: no `carry:` block or daemon jargon in the questions, "from"/"to"/"notes" instead of src/dest/flags, "folder" instead of dir, "shortcut to somewhere else" instead of symlink-outside-home, and no octal file modes in the table. A hub that cannot reach your files now says that rather than mentioning local checkouts. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The card put the whole question next to the icon, top-aligned, so a one-line
title sat high and read as misaligned. `PromptRequest` gains an optional short
`heading` ("Copy credentials", "Copy files") with the question as the line under
it, and the header block is measured and centred against the icon. A prompt
without a `heading` still renders — the question becomes the header — so an
older hub against a newer client degrades rather than breaks.
Drops the "one quick question" line; it now appears only as "N questions to go"
when there is actually a queue behind the current one.
Capitalises the Codex borrow label at its source in the agent registry, which
`--model-auth --help` renders too (hence the golden CLI-surface fixture).
Also drops octal modes and uids from the file table in both clients: not
something the person deciding whether to copy a file needs to read.
Verified end to end: a tray-created OpenClaw box came up authenticated with the
host's Codex login.
Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Four findings, all real. **Clone was broken (high).** `create` now always runs the gates and `carry:` is `required`, but `clone` calls it with no answers — so every clone of a project declaring a carry block was refused. `opts.carryYes` is the API equivalent of `--carry-yes`, and clone sets it: the grant is inherited, not invented, since the source box was approved for this same project's block. Same rule `resyncCarryFiles` already applies when it re-copies within an existing grant. **An explicit "none" could lose.** `borrowCredentials` fell back to the client's list whenever the gate resolved to empty, so answering No lost to a supplied array. Both it and `carryYes` are now threaded INTO the gate as flag equivalents rather than merged with its answer afterwards, so precedence lives in one place. **Questions came after the bake.** The web modal baked first and asked in the create step, putting a required `carry:` question minutes into a build, under a card still titled "Building base image". The ask moved ahead of the bake decision. Threading the answers through `proceed` rather than reading them back from state is load-bearing: the no-bake path runs in the same tick as the `setAnswers` that produced them. **The CLI table lost mode and owner.** Simplifying the file table dropped them everywhere, but the terminal gate is the one surface where an entry landing 0644 or root-owned is worth seeing. Restored there; the GUI tables stay clean. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
`modelAuth` described one thing: another agent's login file, consumed by a service agent, imported by an entry in that agent's own service DAG. That shape could not express a provider with no agent behind it (xAI exists only as an env key), could not reach a TUI agent, and could only ever pick one. Replace `borrows`/`ingestTask` with `sources`/`ingest`: - A source is `agent` (another agent's login FILE) or `env` (a provider API key the host holds in its environment). `modelAuthSourceId` gives each a stable id, agent ids bare and env keys `env:`-prefixed, so every existing `--model-auth codex` invocation and stored config value keeps working. - An ingest is a `serviceTask` (openclaw's, ordered in its own DAG) or a `command` the host runs at the launch seam, which is where a TUI agent's import has to go: ctl's wire cannot express a task without a service. The gate becomes a multi-select over both kinds. A single `agent` source keeps the exact yes/no it has today, so openclaw's surface is unchanged. Env sources default ON, because those keys already reach every box; the grant makes that visible and revocable rather than removing it. A login FILE stays opt-in. `AgentSettingSpec` gains `enum-list` so `<agent>.modelAuth` can hold several, still as one scalar string — config, the JSON schema and the AGENTBOX_AGENT_SETTING_* export are all string-shaped by contract. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
`modelAuthHelp` moves to the shared CLI gate and renders from `sources`, since every agent that declares one gets the flag now rather than only a service agent. `--model-auth` becomes `<source...>`, repeatable and comma-splitting; the golden CLI-surface fixture is regenerated for that (a deliberate act — the diff is the two flag lines). `runModelAuthIngest` replaces the open-coded `agentbox-ctl run-task` call, and dispatches over the ingest union so a TUI agent's `command` runs the same way a service task does. Using it in the credential fan-out fixes a real bug: the old `.find(task => task !== undefined)` ran only the FIRST consuming agent's ingest, so a box running two would leave the rest holding a dead profile after a refresh. `enum-list` reaches config's two coercion sites and `--agent-setting`. The JSON schema cannot express a comma-separated subset as an `enum`, so `openclaw.modelAuth` becomes a pattern, and the schema-drift test grows an `enum-list` branch that checks the pattern member by member instead of comparing regex sources. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Both agents are multi-provider and both already store a ChatGPT-subscription
profile in their own provider-keyed auth.json, under the same OpenAI OAuth
client as the Codex CLI. So the import is a field mapping, not a plugin
install — which is why this is a `command` ingest rather than openclaw's
service task.
Measured on real stores before writing any of it, and two findings shape the
code:
- `expires` MUST come from the access token's own `exp` claim. Writing 0 or a
past value reads as harmless ("it will just refresh"), but the consumer's
refresh call REJECTS a codex-issued refresh token — verified back to back,
the agent's own token at expires:0 refreshed and rotated, codex's returned
invalid_state. A box would have come up with auth that never worked.
- Because of that a seeded box cannot renew itself; the credential fan-out
re-pushing the host's login is the only renewal path. Hence the gate is a
hash of the SEED, so a re-push re-imports.
The store is a map that may already hold other providers, so the ingest merges
one key rather than rewriting the file. The test executes the generated script
against fixtures — it is generated shell wrapping generated JS, so running it
is the only test worth having.
Env-key sources are deliberately NOT declared yet: those keys still ride
`forwardedEnvKeys`, and moving them has to be atomic with the grant filtering
or boxes would lose keys they have today. The registry invariant test enforces
that a key is never both granted and sprayed.
Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
A TUI agent has nowhere to hang a supervisor task, so the host runs its ingest directly, at the one point that is both after the binary exists and before the session starts: - docker create, between ensureInstalled and startSession - docker start, before the session comes up - cloud, beside seedDeclaredFilesForLaunch The start seams re-run it unconditionally for a box holding a grant. That is not redundant: the ingest is hash-gated on the seed, so it is a no-op unless the credential fan-out has pushed a refreshed login since the last start — which is the only way a seeded box's auth is ever renewed, since the consumer cannot refresh a borrowed token itself. `agentbox <tui-agent>` also gains `--model-auth` and finally threads the result into createBox and cloudAgentCreate. That path resolved nothing before, so a TUI agent could not express a grant at all even once its row declared one. The flag is only added for a row that declares sources, so claude and codex still show none. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Rewrites the `modelAuth` reference in docs/agents.md for sources/ingest, and documents login sharing for users in run-an-agent.mdx — pi and opencode can run on the Codex login you already have. Both say the thing that is easy to discover the hard way: a seeded box cannot refresh a borrowed login itself, because the provider rejects a refresh from a different tool. It works until the copied token expires and is renewed by `agentbox credentials propagate`. Also states why the Claude OAuth blob is not shareable and what to use instead. Also drops the `name:` prefix `runModelAuthIngest` added to ingest output — every ingest script already tags its own lines, so it logged "pi-model-auth: pi-model-auth: imported ...". Verified live end to end: `agentbox pi --model-auth codex` seeded the login, the ingest imported it as `openai-codex`, `pi auth check` reported ready in the box, and a real turn answered. A create with no flag on a non-TTY correctly recorded no grant. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Records the design, the four measured facts that decide it, and what is left: env sources (phase 3) and the multi-select renderers (phase 4). Both are written up with the constraints that make them non-obvious — the env move must be atomic or boxes silently lose keys, and the shipped tray decodes the `credential` detail strictly, so an env-backed source must never go through that variant. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The gate already emitted `multiple: true` once a row declares two or more sources, but the clack asker had no branch for it and fell through to `select` — the user could tick only one, silently, and the answer would still be well-formed. A latent trap rather than a visible bug, which is why it is worth closing before anything declares a second source. Empty selection is the "none" answer, so clack's `required` is off; the joined value is ordered by the OFFER, never by pick order, so the same choice always produces the same string. `credential-list` gets a detail renderer. Drive-verified: ticking both yields `codex,claude`, submitting empty yields `none`. The unit test covers the non-TTY half — `multiple` adds no branch there, because one fallback value is always a valid multi answer. Phase 3 (turning forwardedEnvKeys into a grant) is dropped: copying an API key by hand is not a real burden and does not justify rewiring ~10 forwarding sites. The `env` source kind stays in the schema, unused. With no env sources, every agent has exactly one source today, so the interactive prompt is still the plain yes/no. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Two changes to who sees a question and what it looks like. `promptOnCreate` on the row gates asking, opt-in, and openclaw is the only row that sets it. A coding agent has its own sign-in, so a question on every create is noise for a capability most of its boxes do not want; a service agent has no TUI to sign in through, which is why it is the one that asks. pi and opencode keep the whole capability — the flag, the config key, the ingest, the fan-out — they simply never prompt, which is the behaviour they had before any of this. The question itself becomes a LIST of the logins the host can lend plus "None", with one pick, replacing the yes/no. A yes/no only reads correctly while there is exactly one thing to say yes TO, and it hid which login it meant behind the word "Yes"; a multi-select would be wrong the other way, since a box runs on one model provider. A single `agent` source still carries the richer `credential` detail every shipped client already draws. `multiple` stays in the schema and the clack renderer stays tested, now unreached, for a prompt that genuinely wants several. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
`execa` was listed in `serverExternalPackages`, and turbopack emitted it as an
async external under a GENERATED id — `e.y("execa-e735fb92b3a795bd")` — naming a
package that exists nowhere on disk. Every server-rendered PAGE died with
ERR_MODULE_NOT_FOUND; the API routes never load that chunk, which is why the
hub looked half-alive and the failure survived this long unexplained.
It is reached from the @agentbox/* packages that are NOT external here (relay,
sandbox-cloud, …), so turbopack has to resolve it either way. Bundling is safe:
execa is plain ESM over `node:` builtins, with none of the dynamic requires
that force `pg` out.
Declaring execa as a hub dependency alone did NOT fix it — the generated id
survived — so it is listed too, because the hub genuinely depends on it and
resolving it should not rely on a sibling package's node_modules.
Pre-existing and unrelated to model-auth; it just blocked verifying anything in
the web UI. `/` and `/system` now render; `/boxes` is a control-plane route that
wants POSTGRES_URL, which is a separate matter.
Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
A box created from the tray, the web UI or `-i` got the borrowed login copied in and never imported it — the queue worker is a second hand-written copy of "install, then start" that predates the ingest, so it silently produced a pi or opencode box with no model auth. `restoreAgentSessions` had the same hole on its docker leg, where the cloud leg gets the ingest for free inside `cloudAgentStartDetached`; that one matters because the fan-out re-running the ingest is the ONLY way a seeded box's auth is renewed. The queue worker's per-agent block is split into install / ingest / start so the ingest sits between them once, rather than being pasted into four branches — pasting is what produced the drift in the first place. Then a source-level guard, because this failure is invisible at runtime: the login is copied either way, so a box that skipped the ingest comes up looking healthy and simply is not authenticated. The test scans src/ for anything that launches an agent session and fails if it is not a listed seam. It found a fifth on its first run: `dashboard.ts` launches agents itself instead of going through agent-sessions, across seven call sites. Fixed too. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The web modal drew an unknown detail as a monospace slab of its summary, which loses each row's provider and caveat and leaves the reader pairing that slab against an unrelated row of buttons. It now draws a card per credential, with the caveat INLINE rather than as a button tooltip — for a credential choice the caveat is the deciding information, and a tooltip hides it. `<PromptView>` gets a `key`. Harmless while the component is stateless, and required the moment it is not: two queued prompts occupy the same element slot, so React would otherwise hand the second one the first one's state. The OpenAPI schema still described `file-table | credential | text` and a choice with no `exclusive`; it now covers `credential-list` and `multiple`. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
The idempotence marker was written to the consuming agent's own directory — `~/.pi/agent/.agentbox-model-auth.sha256`. That directory is a SHARED docker volume (`agentbox-pi-config`, mounted into every pi box), so the second pi box read the FIRST box's hash, logged "this Codex login is already imported", and imported nothing. It then came up running whatever credential the shared volume happened to hold — in the smoke test, the host's own pi login. Nothing failed. `pi auth check` reported ready, the box was healthy, and only a real turn showed it was authenticated as something else. Exactly the silent class of failure this feature keeps producing. The marker moves to `/run/agentbox/model-auth-<agent>.sha256`, a per-box bind (`~/.agentbox/boxes/<box>/run`) that survives restarts and dies with the box. The invariant is now asserted: a command ingest's marker must be under /run/agentbox and must not sit inside any of the agent's staticPaths box dirs. Found by the smoke test, which is the only reason it was found at all — the first box of any agent works fine. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ba15968. Configure here.
Reaching this question at all means the host holds a login the box can use, so making the user pick it every time is friction on the common case. The offered login is now the default, which also makes it the emphasised button in the tray and the filled one in the web modal — the styling follows `defaultValue`, so neither client needed a change. `fallback` still declines, and the split is deliberate: a default is what you get by pressing enter with the question in front of you, the fallback is what happens when nobody is there to read it at all. A scripted or non-TTY create must not gain a subscription token silently — it says so with `--model-auth`, or once with the config key. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
|
bugbot run |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_b013ef95-0097-4e25-bf46-1b139e8102a9) |
`forward()` documented itself as "idempotent per (boxId, remotePort)", and it was — within one process. But the ControlMaster outlives any single `agentbox` invocation while the in-memory cache does not, so every command that resolved a preview URL minted ANOTHER `-O forward` on the same master and nothing reaped them. One openclaw box had accumulated seven. It also made the recorded `webUrl` go stale: each call handed out a different local port, so a URL captured earlier pointed at a forward nobody was using. The map is now persisted beside the control socket and keyed by the master's pid, which is the only handle OpenSSH gives on master identity — there is no "list forwards" query. A restarted master reuses the socket path but not its forwards, so a pid mismatch invalidates the whole map rather than handing back dead ports, and an adopted port is probed for a listener before it is trusted. Liveness and identity are kept separate: `ssh -O check` exiting 0 means a master answered, and a build that does not print `pid=` still has a perfectly live one. An unparsed pid therefore means "alive, but do not adopt its forwards" — never "dead" — which is what keeps the existing behaviour intact. Verified on the live hetzner box: four separate CLI invocations returned the same port and the host's forward count went up by one, not four. The test drives a stub `ssh` on PATH and fails without the fix; it also isolates HOME, because `controlSockPath` resolves through `homedir()` and would otherwise write into the developer's real ~/.agentbox/cm. Claude-Session: https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
|
bugbot run |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_0e4eb5a1-8ffd-47ba-b74e-fef7792c3982) |

Generalises model-auth from "openclaw borrows codex" to a source model any agent
can use, then closes the gaps that made it work only from the CLI.
Supersedes #377 — this branch is stacked on
feat/create-prompts, so mergingit brings both. #377 can be closed on merge.
What changed
AgentSyncSpec.modelAuthbecomes{ sources, ingest?, promptOnCreate? }:agent(another agent's login FILE) orenv(a provider APIkey in the host env).
modelAuthSourceIdkeeps agent ids bare (codex) andprefixes env keys (
env:XAI_API_KEY), so every existing--model-auth codexinvocation and stored config value keeps working.
serviceTask(openclaw's, in its own DAG) or acommandthe host runs at the launch seam. A TUI agent must use
command: it has nosupervisor unit, and ctl's wire cannot express a task without a service.
ChatGPT-subscription profile in the same five-field shape under the same OAuth
client, so the import is a field mapping, not a plugin install.
promptOnCreate). Coding agents have their ownsign-in; they keep the flag, the config key, the ingest and the fan-out, they
just never prompt. The question is a one-pick list of the available logins plus
"None", replacing a yes/no that hid which login it meant behind the word Yes.
--model-auth <source...>reaches every declaring agent, andenum-listlets
<agent>.modelAuthhold several.Two measurements that shaped the code
Taken before writing it, on real credential stores:
expiresmust come from the access token's ownexpclaim. Writing 0 ora past value reads as harmless — "it will just refresh" — but the consumer's
refresh call REJECTS a codex-issued refresh token (
invalid_state), verifiedback to back against a token that refreshed and rotated fine. The plan said to
write it in the past; that would have shipped auth that never worked.
re-pushing the host's login is the only renewal path, which is why every
ingest gates on a hash of the SEED.
Claude's OAuth blob stays non-borrowable: a consumer's refresh rotates the token
and logs the host and every claude box out.
Bugs found by the smoke test
pi/opencode box got the login copied in and never imported it. Silently
unauthenticated.
restoreAgentSessionshad the same hole. A source-level guardnow scans for anything launching an agent session; it found a fifth seam on
its first run (
dashboard.ts, seven call sites).~/.pi/agent/, which is the SHAREDagentbox-pi-configvolume. Box feat(ctl): split in-box relay onto :8788 (host relay stays :8787) #2 readbox feat(cli): -i/--initial-prompt + background queue for claude/codex/op… #1's hash, logged "already imported", and came up on the wrong credential
while
pi auth checkstill said ready. Moved to a per-box/run/agentbox/path. openclaw was never affected: its volume is per-box.
execainserverExternalPackagesmade turbopack emit an async external under agenerated id naming no real package; API routes never load that chunk, which is
why the hub looked half-alive.
Verified
Every agent, every surface, with a real model turn as the pass condition — box
readiness proves nothing here.
OCL_OKPI_OKOC_OKWEB_PI_OKvia queue workerTray also proven against a malformed
credentialdetail: it used a plaintryover non-optional paths, so one bad detail killed the whole
PreflightResponseand created the box with no answers. It now degrades to its summary, and all
four prompts in the test payload still decode.
https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs
Note
High Risk
Changes how host model-provider credentials are chosen, copied, and ingested across CLI, queue worker, dashboard, and credential fan-out—mistakes surface as silent auth failures rather than obvious errors.
Overview
Extends model-auth beyond OpenClaw: agents that declare
modelAuthget--model-auth, a host-sideresolveModelAuthgate before create,borrowCredentialson the box record, andrunModelAuthIngestimmediately before any agent session starts (foreground create, start/attach, session restore, queue worker, dashboard).agentbox credentials propagatealso re-pushes borrowed logins and forces ingest for every agent on the box.Stacks create-time prompts on the same shared gates: carry and model-auth logic live in
@agentbox/sandbox-corewith aPromptRequest/clackAskerCLI path; the hub addsPOST …/create-preflight,runCreateGates+ collecting/replay askers,opts.promptAnswers/carryYes, and the create-box modalPromptView. Clones inheritborrowCredentialsand setcarryYes: trueso required carry does not block headless creates.Smaller fixes: service sign-in URLs for VNC/in-box browser,
enum-listvalidation for--agent-setting,cpcopy helpers moved to sandbox-core, hubexecabundling (drops broken turbopack external), and docs for model-auth sources and API preflight.Reviewed by Cursor Bugbot for commit 8c63203. Configure here.