Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cda2b50
fix(stage): don't try to copy a unix socket into the stage dir
madarco Sep 8, 2026
38218cb
fix(screen): open the in-box browser on the signed service URL
madarco Sep 8, 2026
4ca97db
feat(openclaw): seed a box with the host's Codex login as its model p…
madarco Sep 9, 2026
74b37dc
fix(openclaw): gate the model-auth import on the seed's hash, and doc…
madarco Sep 9, 2026
f99958b
fix(openclaw): keep the host's exec-approvals state out of the static…
madarco Sep 9, 2026
7421736
feat(prompts): one schema for every create-time host-boundary question
madarco Sep 9, 2026
fef21d4
feat(hub): ask create-time prompts over the API
madarco Sep 9, 2026
0108017
feat(hub-web): render create-time prompts in the create modal
madarco Sep 9, 2026
3c26d4c
docs: create-time prompts in the UI and over the API
madarco Sep 9, 2026
c14e8f0
fix(hub): let the preflight reach every gate
madarco Sep 9, 2026
2c01c2d
fix(prompts): plainer wording, and buttons that fit
madarco Sep 9, 2026
2a18b04
fix(prompts): header reads as a title with a subtitle
madarco Sep 9, 2026
ff298eb
fix(hub): address bugbot on PR #377
madarco Sep 9, 2026
bfa6c84
feat(model-auth): model-auth sources, replacing single-agent borrows
madarco Sep 9, 2026
11af541
feat(model-auth): variadic --model-auth, and one ingest runner
madarco Sep 9, 2026
33ba9ba
feat(pi,opencode): borrow the host's Codex login as OpenAI auth
madarco Sep 9, 2026
0352d02
feat(model-auth): run the ingest at the agent launch seam
madarco Sep 9, 2026
9949fb4
docs(model-auth): sources, and the renewal caveat
madarco Sep 9, 2026
f365eb4
docs: durable plan for model-auth sources, with phase status
madarco Sep 9, 2026
b397ed7
feat(cli): render a multi-source model-auth prompt as a multiselect
madarco Sep 9, 2026
386b77e
feat(model-auth): only openclaw asks, and it asks for one pick
madarco Sep 9, 2026
cd2c733
fix(hub): every server-rendered page 500d on a bogus execa external
madarco Sep 9, 2026
2921ed1
fix(model-auth): run the ingest on every launch seam, not just the CLI's
madarco Sep 9, 2026
700bde9
feat(hub): render a credential list, and document the schema additions
madarco Sep 9, 2026
ba15968
fix(model-auth): the ingest marker ran once per HOST, not once per box
madarco Sep 9, 2026
bffc6f7
feat(model-auth): pre-select the offered login
madarco Sep 9, 2026
8c63203
fix(ssh): reuse a box's port forward across processes
madarco Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Each topic has a dedicated file under [`docs/`](./docs). Read the relevant one b
- [`docs/terminal-integration.md`](./docs/terminal-integration.md) — host-terminal attach placement (tmux/cmux/Herdr/iTerm2), terminal titles, the cmux sidebar status, and the Herdr integration + plugin.
- [`docs/agents.md`](./docs/agents.md) — **the agent reference**: agents as data (`AGENT_SYNC_SPECS` + install recipes), one-agent-per-box, the agentless-base/derived-layer image tiers, adding an agent to a running box, and the checklist for adding a new agent. **Two surfaces**, declared on `caps.surface`: a `tui` agent (claude, codex, opencode, pi) is a tmux session you attach to; a `service` agent (**openclaw**) is a daemon ctl's supervisor runs, whose units and layered config ride the `agents.list` RPC and whose CLI ends at "ready + URL" — it has no CLI module, no attach wrapper and no `box.isolate*Config` key, and the whole per-agent cost is a registry row plus a docker volume module. **An agent can also arrive as an npm package** — `agentbox agent add <pkg>` snapshots its `AgentSyncSpec` into `~/.agentbox/agents.json` (read sync + offline by everything, exactly as `plugin add` does for providers) and its `agentSyncModule`, if it ships one, is loaded by a variable `import()` from `@agentbox/agent-modules`. A plugin cannot shadow a built-in agent's id or alias, in either half.
- [`docs/agents-remaining-work.md`](./docs/agents-remaining-work.md) — what is still open in the agent layer after the agents-as-packages work: the four claude-named files still in the shared packages and the one decision that blocks them (**does the hub load agent modules?** — it loads none today, so a registration seam would silently stop the hub's ssh-config prune), and the never-started bake-on-first-use UX. The live measure is `apps/cli/test/no-agent-named-exports.test.ts`, whose allowlist can only shrink.
- [`docs/model-auth-sources-plan.md`](./docs/model-auth-sources-plan.md) — **model-auth sources**: which host model-provider credentials a box may be seeded with (`agent` — another agent's login file; `env` — a provider API key in the host env), the two ingest shapes and why a TUI agent's must run at the host's launch seam, the measured facts that decide it (a consumer CANNOT refresh a borrowed codex token, so a seeded box is renewed only by the credential fan-out; claude's OAuth blob stays non-borrowable), and the phase status.
- [`docs/agent-settings-plan.md`](./docs/agent-settings-plan.md) — **agent settings**: an agent declares its own settings on its registry row, config generates `<agent>.<key>` keys from them (built-ins *and* `agentbox agent add` packages), and every call site carries one opaque `agentSettings` map — the agent's own recipe / `postInstall` / launch env is the only thing that knows what a setting means. `box.claudeInstall`/`box.claudeTui` are now `claude.install`/`claude.tui`.
- [`docs/host-tools.md`](./docs/host-tools.md) — the box→host CLI proxy: how any host CLI (`gh`, `terraform`, `aws`, `ntn`, `linear`) reaches a box through one generic shim, the request-vs-grant trust split, and the built-in credential deny list.
- [`docs/provider-plugins.md`](./docs/provider-plugins.md) — external / community providers on the published `@madarco/agentbox-provider-sdk`, the `agentbox plugin add` registry, and the SDK-version gate.
Expand Down
50 changes: 35 additions & 15 deletions apps/cli/src/agent-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
*/
import { loadEffectiveConfig } from '@agentbox/config';
import type { AgentId, BoxRecord, Provider } from '@agentbox/core';
import { agentIds, resolveAgentSpec } from '@agentbox/sandbox-core';
import { agentIds, resolveAgentSpec, runModelAuthIngest } from '@agentbox/sandbox-core';
import { execInBox } from '@agentbox/sandbox-docker';
import { loadAgentModule, loadAgentModuleOrNull } from './agents/index.js';
import { cloudAgentStartDetached } from './commands/_cloud-attach.js';

Expand Down Expand Up @@ -112,6 +113,37 @@ export interface RestoreOptions {
force?: boolean;
}

/**
* Start a docker agent session, running its model-auth ingest first.
*
* One place, because the cloud leg gets this for free inside
* `cloudAgentStartDetached` and the docker leg had two `runtime.startSession`
* call sites with neither. The ingest is hash-gated, so this 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 model auth is ever renewed.
*/
async function startDockerSession(
box: BoxRecord,
kind: AgentId,
sessionName: string,
args: string[],
): Promise<void> {
if ((box.borrowedCredentials ?? []).length > 0) {
await runModelAuthIngest(resolveAgentSpec(kind), async (argv) => {
const r = await execInBox(box.container, argv);
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
});
}
const { runtime } = await loadAgentModule(kind);
await runtime.startSession({
container: box.container,
args,
sessionName,
boxName: box.name,
workspacePath: box.workspacePath,
});
}

/** Start a fresh (no-resume) detached agent session. */
async function startFreshSession(
box: BoxRecord,
Expand All @@ -124,13 +156,7 @@ async function startFreshSession(
const args =
cfg && runtime.skipPermissions ? runtime.skipPermissions.apply([], cfg.effective) : [];
if (isDocker) {
await runtime.startSession({
container: box.container,
args,
sessionName,
boxName: box.name,
workspacePath: box.workspacePath,
});
await startDockerSession(box, kind, sessionName, args);
} else {
await cloudAgentStartDetached({ box, binary: kind, sessionName, extraArgs: args });
}
Expand Down Expand Up @@ -174,13 +200,7 @@ export async function restoreAgentSessions(
: resume;
try {
if (isDocker) {
await runtime.startSession({
container: box.container,
args,
sessionName,
boxName: box.name,
workspacePath: box.workspacePath,
});
await startDockerSession(box, kind, sessionName, args);
} else {
await cloudAgentStartDetached({ box, binary: kind, sessionName, extraArgs: args });
}
Expand Down
44 changes: 44 additions & 0 deletions apps/cli/src/agents/command/create-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import {
findProjectRoot,
agentSettings,
loadEffectiveConfig,
resolveBoxImage,
resolveDefaultCheckpoint,
Expand All @@ -20,6 +21,7 @@ import {
createBox,
DEFAULT_BOX_IMAGE,
detectEngine,
execInBox,
recordLastAgent,
} from '@agentbox/sandbox-docker';
import { intro, log, outro } from '@agentbox/cli-kit';
Expand All @@ -45,6 +47,8 @@ import { ensureProjectRepoOnControlPlane } from '../../control-plane/ensure-repo
import { resolveCreateRouting, type CreateRouting } from '../../control-plane/route-create.js';
import { dockerProviderRefusal, remoteHubConfigured } from '../../control-plane/remote-hub.js';
import { runCarryGate, runQueuedCarryGate } from '../../lib/carry-gate.js';
import { runModelAuthIngest } from '@agentbox/sandbox-core';
import { resolveModelAuth } from '../../lib/model-auth-gate.js';
Comment thread
cursor[bot] marked this conversation as resolved.
import { runToolsGate } from '../../lib/tools-gate.js';
import { directGitModeRefusal, resolveGitCredsCarry } from '../../lib/git-creds-gate.js';
import { FromBranchError, UseBranchError, resolveBranchSelection } from '../../lib/from-branch.js';
Expand Down Expand Up @@ -510,6 +514,23 @@ export async function runAgentCreate(
fail(err instanceof Error ? err.message : String(err), 1);
}

// Model-auth gate: which of the host's model-provider logins this box gets.
// Decided here, at the host boundary, before anything is created — a refused
// value must not cost a box, and a prompt must not appear under a spinner.
let modelAuthSources: string[] = [];
try {
modelAuthSources = await resolveModelAuth({
spec: a.spec,
...(opts.modelAuth !== undefined ? { flags: opts.modelAuth } : {}),
settings: agentSettings(cfg, a.spec.id),
sources: cfgLoaded.sources,
yes: !!opts.yes,
});
for (const id of modelAuthSources) cmdLog.write(`model auth: granting ${id}`);
} catch (err) {
fail(err instanceof Error ? err.message : String(err), 1);
}

// Host-tool gate (agentbox.yaml's `tools:` block): a committed yaml can
// only REQUEST host CLIs; the grant is the host's decision. Never blocks.
try {
Expand Down Expand Up @@ -634,6 +655,7 @@ export async function runAgentCreate(
withEnv: cfg.box.withEnv,
...(adjust.envFilesToImport ? { envFilesToImport: adjust.envFilesToImport } : {}),
carry: carryEntries,
...(modelAuthSources.length > 0 ? { borrowCredentials: modelAuthSources } : {}),
vnc: { enabled: cfg.box.vnc },
...(persistent !== undefined ? { persistent } : {}),
limits: resolveLimits(cfg.box, opts),
Expand Down Expand Up @@ -717,6 +739,7 @@ export async function runAgentCreate(
withEnv: cfg.box.withEnv,
...(adjust.envFilesToImport ? { envFilesToImport: adjust.envFilesToImport } : {}),
carry: carryEntries,
...(modelAuthSources.length > 0 ? { borrowCredentials: modelAuthSources } : {}),
vnc: { enabled: cfg.box.vnc },
...(persistent !== undefined ? { persistent } : {}),
docker: { sharedCache: cfg.box.dockerCacheShared },
Expand Down Expand Up @@ -746,6 +769,27 @@ export async function runAgentCreate(
});
}

// Turn a seeded login into this agent's own auth store. HERE, not in the
// provider's create: the binary has to exist first (ensureInstalled above),
// and the session must not start before its auth is in place. A service
// agent's ingest is a ctl task in its own DAG instead, so this is a no-op
// for it.
if (modelAuthSources.length > 0) {
await runModelAuthIngest(
a.spec,
async (argv) => {
const r = await execInBox(result.record.container, argv);
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
},
{
onLog: (line) => {
s.message(line);
cmdLog.write(line);
},
},
);
}

const afterCreate = await a.hooks?.afterCreate?.(result.record, {
...ctx,
message: (line) => {
Expand Down
9 changes: 9 additions & 0 deletions apps/cli/src/agents/command/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { Command } from 'commander';
import { ATTACH_IN_HELP, INLINE_HELP, NO_ATTACH_HELP } from '../../commands/_attach-in.js';
import type { AgentCliSpec } from '@agentbox/cli-kit';
import { modelAuthHelp } from '../../lib/model-auth-gate.js';

/** Flags shared by the create action and both subcommands. */
export interface AgentCreateOptions {
Expand All @@ -30,6 +31,9 @@ export interface AgentCreateOptions {
carryYes?: boolean;
/** `--carry <mode>`: 'skip' disables carry for this run (also AGENTBOX_CARRY=skip). */
carry?: 'skip' | 'ask';
/** `--model-auth <source...>`: which host model-provider logins to seed.
* Only present for an agent whose row declares sources. */
modelAuth?: string[];
/** `--dangerously-with-credentials`: copy a git credential into the box
* (git.pushMode=direct); cloud only. Token-vs-SSH is chosen ONLY at the
* interactive prompt (TTY required). */
Expand Down Expand Up @@ -223,6 +227,11 @@ export function addCreateOptions(cmd: Command, a: AgentCliSpec): Command {
`[${id}-args...]`,
`extra args passed to ${id} inside the box; place after \`--\`, e.g. \`agentbox ${id} -- ${text.argsExample}\``,
);
// Only for a row that declares sources — an agent with none would get a flag
// whose every value is an error.
if (a.spec.modelAuth?.sources.length) {
cmd.option('--model-auth <source...>', modelAuthHelp(a.spec));
}
cmd.description(`Create a sandboxed box and launch ${productName} in a detachable tmux session`);
return cmd;
}
Expand Down
24 changes: 23 additions & 1 deletion apps/cli/src/agents/command/service-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
* host involvement at all. This command only waits for it.
*/

import { findProjectRoot, loadEffectiveConfig, resolveBoxImage } from '@agentbox/config';
import {
agentSettings,
findProjectRoot,
loadEffectiveConfig,
resolveBoxImage,
} from '@agentbox/config';
import type {
AgentServiceUrlField,
AgentSyncSpec,
Expand All @@ -39,6 +44,7 @@ import {
import { portlessUnalias, readBoxStatus, recordLastAgent } from '@agentbox/sandbox-docker';
import { webProxyWarning } from '../../lib/web-proxy-warning.js';
import { runCarryGate } from '../../lib/carry-gate.js';
import { resolveModelAuth } from '../../lib/model-auth-gate.js';
import { handleLifecycleError } from '../../commands/_errors.js';
import { providerForBox, providerForCreate } from '../../provider/registry.js';
import {
Expand Down Expand Up @@ -74,6 +80,8 @@ export interface ServiceAgentOptions {
persistent?: boolean;
/** Seconds to wait for the service to report ready. */
timeout?: string;
/** `--model-auth <source...>`: which host model-provider logins to seed. */
modelAuth?: string[];
/** `--restore <bot>`: recreate that bot from its backup, identity included. */
restore?: string;
/** `--stamp <s>`: which backup (default: the `latest` link). */
Expand Down Expand Up @@ -402,6 +410,19 @@ export async function runServiceAgent(
// derived from the registry row's `caps.surface`, exactly like the
// config-volume isolation below, never from an agent id. `--no-persistent`
// is the opt-out; `undefined` leaves the call to `box.persistent`.
// Which host login, if any, the box borrows as its model provider.
// Decided here, at the host boundary, before anything is created: a
// refused value must not cost a box, and a prompt must not appear
// under a spinner.
const borrowCredentials = await resolveModelAuth({
spec,
...(opts.modelAuth !== undefined ? { flags: opts.modelAuth } : {}),
settings: agentSettings(cfg, spec.id),
sources: cfgLoaded.sources,
yes: !!opts.yes,
});
for (const a of borrowCredentials) cmdLog.write(`model auth: granting ${a}`);

const persistent = resolveCreatePersistent({ spec, flag: opts.persistent });
if (persistent ?? cfg.box.persistent) {
// Refused off the provider NAME, before the provider module is loaded —
Expand All @@ -424,6 +445,7 @@ export async function runServiceAgent(
// This box is FOR this agent: only its credentials and config are
// wired in.
agents: [spec.id],
...(borrowCredentials.length > 0 ? { borrowCredentials } : {}),
image: resolveBoxImage(cfg, provider.name),
checkpointRef: opts.snapshot,
withPlaywright: cfg.box.withPlaywright,
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/agents/command/service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { log } from '@agentbox/cli-kit';
import type { AgentSyncSpec } from '@agentbox/core';
import { renderStatusTable, type ServiceState, type ServiceStatus } from '@agentbox/ctl';
import { readBoxStatus } from '@agentbox/sandbox-docker';
import { modelAuthHelp } from '../../lib/model-auth-gate.js';
import { webProxyWarning } from '../../lib/web-proxy-warning.js';
import { openServiceRepl } from '../service-repl.js';
import { ATTACH_IN_HELP, INLINE_HELP, resolveAttachInOption } from '../../commands/_attach-in.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ export function buildServiceAgentCommand(spec: AgentSyncSpec): Command {
)
.option('--timeout <seconds>', 'how long to wait for the service to report ready', '180')
.option('--verbose', 'stream create progress instead of a spinner')
.option('--model-auth <source...>', modelAuthHelp(spec))
.option(
'--restore <bot>',
`recreate a bot from its backup under <project>/.agentbox/bots/<bot>/: the box runs on a copy of the backed-up workspace AND gets the captured ${spec.id} state dir back, identity included. Always creates a new box`,
Expand Down
17 changes: 17 additions & 0 deletions apps/cli/src/agents/command/start-attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import { loadEffectiveConfig, type EffectiveConfig, type UserConfig } from '@agentbox/config';
import {
inspectBox,
execInBox,
recordLastAgent,
seedAgentDeclaredFiles,
startBox,
unpauseBox,
type BoxRecord,
} from '@agentbox/sandbox-docker';
import { runModelAuthIngest } from '@agentbox/sandbox-core';
import type { Command } from 'commander';
import { intro, log, outro, spinner } from '@agentbox/cli-kit';
import { reattachRef, resolveBoxOrExit, resolveBoxOrShift } from '../../box-ref.js';
Expand Down Expand Up @@ -174,6 +176,21 @@ async function startOrAttach(
onProgress: (line) => s.message(clampSpinnerLine(line)),
});

// Re-run the model-auth ingest before the session comes up. Hash-gated, so
// this is a no-op unless the fan-out has pushed a refreshed login since the
// last start — which is the ONLY way a seeded box's auth is renewed, because
// the consumer cannot refresh a borrowed token itself.
if ((box.borrowedCredentials ?? []).length > 0) {
await runModelAuthIngest(
a.spec,
async (argv) => {
const r = await execInBox(box.container, argv);
return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
},
{ onLog: (line) => s.message(clampSpinnerLine(line)) },
);
}

let effectiveArgs = a.runtime.skipPermissions
? a.runtime.skipPermissions.apply(agentArgs, cfg)
: agentArgs;
Expand Down
Loading
Loading