Skip to content

feat(model-auth): model-provider logins for every agent, not just openclaw - #378

Merged
madarco merged 27 commits into
nightlyfrom
feat/model-auth-sources
Sep 9, 2026
Merged

feat(model-auth): model-provider logins for every agent, not just openclaw#378
madarco merged 27 commits into
nightlyfrom
feat/model-auth-sources

Conversation

@madarco

@madarco madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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 merging
it brings both. #377 can be closed on merge.

What changed

AgentSyncSpec.modelAuth becomes { sources, ingest?, promptOnCreate? }:

  • A source is agent (another agent's login FILE) or env (a provider API
    key in the host env). modelAuthSourceId keeps agent ids bare (codex) and
    prefixes env keys (env:XAI_API_KEY), so every existing --model-auth codex
    invocation and stored config value keeps working.
  • An ingest is a serviceTask (openclaw's, in its own DAG) or a command
    the host runs at the launch seam. A TUI agent must use command: it has no
    supervisor unit, and ctl's wire cannot express a task without a service.
  • pi and opencode can now run on your Codex login. Both already store a
    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.
  • Only openclaw asks (promptOnCreate). Coding agents have their own
    sign-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, and enum-list
    lets <agent>.modelAuth hold several.

Two measurements that shaped the code

Taken before writing it, on real credential stores:

  1. 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 (invalid_state), verified
    back 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.
  2. A seeded box therefore cannot renew itself. The credential fan-out
    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

  • The hub's docker queue worker never ran the ingest — a tray- or web-created
    pi/opencode box got the login copied in and never imported it. Silently
    unauthenticated. restoreAgentSessions had the same hole. A source-level guard
    now scans for anything launching an agent session; it found a fifth seam on
    its first run (dashboard.ts, seven call sites).
  • The ingest marker ran once per HOST, not per box — it lived in
    ~/.pi/agent/, which is the SHARED agentbox-pi-config volume. Box feat(ctl): split in-box relay onto :8788 (host relay stays :8787) #2 read
    box 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 check still said ready. Moved to a per-box /run/agentbox/
    path. openclaw was never affected: its volume is per-box.
  • Every hub PAGE 500'd (pre-existing, unrelated). execa in
    serverExternalPackages made turbopack emit an async external under a
    generated 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.

openclaw pi opencode claude codex
preflight asks? yes no no no no
CLI turn OCL_OK PI_OK OC_OK session live, no seed session live, own login
Web prompt renders WEB_PI_OK via queue worker
Tray decode verified vs live payload

Tray also proven against a malformed credential detail: it used a plain try
over non-optional paths, so one bad detail killed the whole PreflightResponse
and 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 modelAuth get --model-auth, a host-side resolveModelAuth gate before create, borrowCredentials on the box record, and runModelAuthIngest immediately before any agent session starts (foreground create, start/attach, session restore, queue worker, dashboard). agentbox credentials propagate also 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-core with a PromptRequest / clackAsker CLI path; the hub adds POST …/create-preflight, runCreateGates + collecting/replay askers, opts.promptAnswers / carryYes, and the create-box modal PromptView. Clones inherit borrowCredentials and set carryYes: true so required carry does not block headless creates.

Smaller fixes: service sign-in URLs for VNC/in-box browser, enum-list validation for --agent-setting, cp copy helpers moved to sandbox-core, hub execa bundling (drops broken turbopack external), and docs for model-auth sources and API preflight.

Reviewed by Cursor Bugbot for commit 8c63203. Configure here.

`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
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
agentbox-web Skipped Skipped Sep 9, 2026 3:03pm UTC

Request Review

@madarco

madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ 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.

Comment thread apps/cli/src/agents/command/create-action.ts
Comment thread apps/hub/lib/hub-backend.ts
Comment thread apps/cli/src/commands/dashboard.ts
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
@madarco

madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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
@madarco

madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

bugbot run

@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@madarco
madarco merged commit 8c63203 into nightly Sep 9, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant