Skip to content

feat(prompts): ask create-time questions from the tray and the hub UI - #377

Closed
madarco wants to merge 13 commits into
nightlyfrom
feat/create-prompts
Closed

feat(prompts): ask create-time questions from the tray and the hub UI#377
madarco wants to merge 13 commits into
nightlyfrom
feat/create-prompts

Conversation

@madarco

@madarco madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner

A box created from the tray or the hub web UI silently skipped every
host-boundary decision the CLI asks about. Two were live bugs:

  • carry: — the gate lived in apps/cli and was called only by the three
    CLI create commands. The hub never parsed carry:; POST /api/v1/boxes
    accepted a pre-resolved opts.carry that only the CLI ever sent. A tray box
    got no carried files, no error, no log line, and resyncCarryFiles could not
    recover it (it returns early when box.carry.entries is empty).
  • --model-auth — same shape. GET /api/v1/agents offers openclaw, so a
    tray-created OpenClaw box came up with no model provider. The queue plumbing
    for borrowCredentials already existed end to end, but parseCreateBoxOpts
    never parsed the field, so the key hub-backend reads was dead on the wire.

Both gates were TTY-only by construction: carry threw on a non-TTY,
model-auth silently resolved to none. Neither could reach a GUI.

One mechanism, not two fixes

PromptRequest in @agentbox/core is deliberately two halves. The generic one
kind / title / body / choices / fallback — renders and answers any
prompt, including a topic a client has never seen, so the hub can add a gate
without shipping new UI. PromptDetail is the opt-in half a client may draw
richly (file-table, credential); every variant carries a plain summary, so
an unrecognised one still renders. Ids are content-addressed over the question,
so an answer collected before agentbox.yaml changed no longer matches.

Both gates moved to @agentbox/sandbox-core and take a PromptAsker instead of
calling a prompt library. Three implementations drive them: clack in the CLI, a
collecting asker that is the hub's preflight, and a map-backed one
replaying the client's answers. The preflight cannot drift from what the create
asks, because it runs the same gates.

POST /api/v1/projects/:id/create-preflight returns the questions; answers ride
back as opts.promptAnswers. Gates run before the job is queued — the same
front-loading the CLI's -i path does — so the queue worker is unchanged.

CLI behaviour is preserved by its asker: -y still does not auto-approve carry,
and a non-TTY still refuses a required prompt with the gate's own hint.

Verified against a live hub

  • Preflight returns this repo's real 15-entry carry table with correct sizes and
    for missing optional entries.
  • Answering approve copied 13/15 entries into a hub-created box at 0600
    owned by vscode; skip-this-run left them out. The answer changes behaviour.
  • A create with no answer, and one with a stale id, are both refused.
  • Tray models decode the live payload, and a simulated future detail variant
    falls back to its summary rather than failing the decode.

That testing caught a real bug: the collecting asker answers each prompt with
its fallback, and carry's fallback is cancel — so the carry gate aborted the
run and model-auth never appeared in the list. Fixed with an explicit dry-run
mode, plus a regression test.

Note on scope

This branch also carries three openclaw model-auth commits it is built on
(model-auth-gate.ts exists only there), plus two unrelated fixes that were
already local. Happy to split if you'd rather review them separately.

The macOS tray half is in madarco/agentbox-tray on nightly.

Known gap

The hub web modal is written, typechecks and lints, but could not be exercised:
every hub page 500s in the standalone build on a pre-existing untraced-execa
error that reproduces identically on the pre-branch staged bundle. Unrelated to
this change, but it means the web renderer is unverified.

https://claude.ai/code/session_016kDGJ95vXMRpE2KGhBLGrs


Note

High Risk
Changes how host secrets and borrowed OAuth logins are approved and copied at create time across CLI, hub API, and UI; incorrect gate replay or validation could skip carry, mis-seed credentials, or refuse valid creates.

Overview
Introduces a shared create-time prompt model (PromptRequest / PromptAsker in @agentbox/core) and moves carry and model-auth decisions into @agentbox/sandbox-core, so the CLI, hub backend, and web UI run the same gates instead of TTY-only CLI logic.

Hub & API: Adds POST /projects/{id}/create-preflight (collecting asker) and threads opts.promptAnswers plus parsed borrowCredentials through box create validation and hub-backend before the job is queued. The create-box modal runs preflight first, renders prompts via PromptView, and surfaces unavailable when the hub cannot read local project files or host logins.

CLI: Replaces local carry prompting with the shared gate + clackAsker; adds --model-auth on service agents and resolveModelAuth before box creation; propagates borrowCredentials through queued jobs. OpenClaw gains modelAuth registry settings (openclaw.modelAuth), an openclaw-model-auth ingest task, and credential fan-out for boxes that borrow Codex logins.

Other: BoxRecord.borrowedCredentials and clone/create plumbing; signed service URLs for VNC/web open paths; carry resolver import moved to sandbox-core; docs and OpenAPI updated for preflight and model auth.

Reviewed by Cursor Bugbot for commit 2c01c2d. 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
@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 11:34am 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 4 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 2c01c2d. Configure here.

ask: answerMapAsker(o.promptAnswers as PromptAnswer[] | undefined),
});
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clone fails when project has carry

High Severity

Hub create now always runs the host-boundary gates, and carry: is required. Clone still calls create with no promptAnswers, so any project with a non-empty carry: block is refused instead of spawning the new box.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c01c2d. Configure here.

Comment thread apps/hub/lib/hub-backend.ts Outdated
carry: gated.carry as QueueJobCreateOpts['carry'],
borrowCredentials: gated.borrowCredentials.length
? gated.borrowCredentials
: o.borrowCredentials,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty gate falls back to client opts

Medium Severity

When the model-auth gate resolves to none, borrowCredentials still takes opts.borrowCredentials if that array is empty. An explicit none answer therefore loses to a client-supplied list, and a create for an agent with no modelAuth can still seed borrowed logins.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c01c2d. Configure here.

}
setJobId(res.jobId);
router.refresh(); // surface the box as `creating`
await submitCreate([]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Carry prompts appear after base bake

Medium Severity

Create still bakes first, then startCreate fetches preflight. Required carry: / model-auth questions therefore appear only after a bake that can take minutes, and bakeJobId stays set so the dialog still reads as a base-image build while asking them.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c01c2d. Configure here.

`${pad(r.src, srcW)} -> ${pad(r.dest, destW)} ${pad(size, 9)} ${flags.join(', ')}`,
);
}
return out.join('\n');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CLI carry table hides mode and owner

Low Severity

The shared carry summary, which the CLI prints as the gate, no longer includes mode or user. Those fields sit on the row object but renderCarryTable only joins flags, so a TTY approval no longer shows permission or owner overrides.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c01c2d. Configure here.

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

madarco commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #378, which is stacked on this branch and therefore contains all 13 of these commits plus the model-auth work. Closing so there is one review surface rather than two overlapping ones — the code here is not abandoned, it merges to nightly as part of #378.

@madarco madarco closed this Sep 9, 2026
@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