diff --git a/apps/cli/src/agents/command/service-action.ts b/apps/cli/src/agents/command/service-action.ts index 6decdc098..c9fcd865e 100644 --- a/apps/cli/src/agents/command/service-action.ts +++ b/apps/cli/src/agents/command/service-action.ts @@ -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, @@ -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 { @@ -74,6 +80,8 @@ export interface ServiceAgentOptions { persistent?: boolean; /** Seconds to wait for the service to report ready. */ timeout?: string; + /** `--model-auth `: which host login to seed as the model provider. */ + modelAuth?: string; /** `--restore `: recreate that bot from its backup, identity included. */ restore?: string; /** `--stamp `: which backup (default: the `latest` link). */ @@ -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, + flag: opts.modelAuth, + settings: agentSettings(cfg, spec.id), + sources: cfgLoaded.sources, + yes: !!opts.yes, + }); + for (const a of borrowCredentials) cmdLog.write(`model auth: borrowing the ${a} login`); + const persistent = resolveCreatePersistent({ spec, flag: opts.persistent }); if (persistent ?? cfg.box.persistent) { // Refused off the provider NAME, before the provider module is loaded — @@ -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, diff --git a/apps/cli/src/agents/command/service-factory.ts b/apps/cli/src/agents/command/service-factory.ts index 1888eb307..8fdab1136 100644 --- a/apps/cli/src/agents/command/service-factory.ts +++ b/apps/cli/src/agents/command/service-factory.ts @@ -74,6 +74,21 @@ function toStatusRow(s: HubApiServiceView): ServiceStatus { }; } +/** + * Help for `--model-auth`, rendered from the row: the accepted values are the + * agents it declares it can borrow, so the text cannot drift from the data. + */ +function modelAuthHelp(spec: AgentSyncSpec): string { + const borrows = spec.modelAuth?.borrows ?? []; + if (borrows.length === 0) return `not applicable: ${spec.id} borrows no host login`; + const values = ['none', ...borrows.map((b) => b.agent)].join('|'); + return ( + `which host login to seed as the model provider (${values}; default: ${spec.id}.modelAuth, ` + + `else ask). ` + + borrows.map((b) => `${b.agent}: ${b.label}`).join('; ') + ); +} + export function buildServiceAgentCommand(spec: AgentSyncSpec): Command { const service = spec.service; if (!service) { @@ -102,6 +117,7 @@ export function buildServiceAgentCommand(spec: AgentSyncSpec): Command { ) .option('--timeout ', 'how long to wait for the service to report ready', '180') .option('--verbose', 'stream create progress instead of a spinner') + .option('--model-auth ', modelAuthHelp(spec)) .option( '--restore ', `recreate a bot from its backup under /.agentbox/bots//: 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`, diff --git a/apps/cli/src/carry-prompt.ts b/apps/cli/src/carry-prompt.ts deleted file mode 100644 index 2b5c70d2a..000000000 --- a/apps/cli/src/carry-prompt.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { log, select } from '@agentbox/cli-kit'; -import { fmtBytes } from './fmt.js'; -import type { ResolvedCarryEntry } from './lib/carry-resolve.js'; - -export type CarryDecision = 'approve' | 'cancel' | 'skip-this-run'; - -export interface CarryPromptArgs { - resolved: ResolvedCarryEntry[]; - /** --carry-yes flag (or AGENTBOX_CARRY_YES=1) — auto-approves the carry. */ - carryYes?: boolean; - /** --carry=skip flag (or AGENTBOX_CARRY=skip) — proceed with carry disabled. */ - carrySkip?: boolean; - /** The generic --yes / -y flag (does NOT auto-approve carry). */ - yes?: boolean; - /** Caller-controlled TTY check; default: process.stdin.isTTY. */ - isTTY?: boolean; -} - -/** - * Decide whether to carry the resolved entries. Caller invokes this once per - * `create`-style command. The returned decision tells the caller to proceed - * with the entries, skip them, or abort the whole create. - * - * Throws when `--yes` is passed on a non-TTY with non-empty carry entries — - * `-y` MUST NOT silently exfiltrate host secrets. The error message tells the - * user the explicit env var to set. - */ -export async function promptForCarry(args: CarryPromptArgs): Promise { - // Empty carry: nothing to ask. Always approve so the caller's branch is - // uniform ("if decision === 'approve' && entries.length") regardless of - // whether the user has a carry: block at all. - if (args.resolved.length === 0) return 'approve'; - - if (args.carrySkip) return 'skip-this-run'; - if (args.carryYes) return 'approve'; - - const tty = args.isTTY ?? process.stdin.isTTY; - - if (args.yes) { - if (!tty) { - throw new Error( - 'carry: requires approval but stdin is not a TTY and --carry-yes was not set. ' + - 'In CI, set AGENTBOX_CARRY_YES=1 to opt in to copying host secrets/files into this box, ' + - 'or AGENTBOX_CARRY=skip to skip the carry block.', - ); - } - // -y on a TTY still falls through to the prompt — the prompt is the - // user's explicit gate, not a "wizard default". - } - - if (!tty) { - throw new Error( - 'carry: requires approval but stdin is not a TTY. ' + - 'Set AGENTBOX_CARRY_YES=1 to opt in, or AGENTBOX_CARRY=skip to skip.', - ); - } - - printSummary(args.resolved); - - const choice = await select({ - message: 'Copy these files inside the box?', - options: [ - { value: 'approve', label: 'yes' }, - { value: 'skip-this-run', label: 'skip' }, - { value: 'cancel', label: 'cancel' }, - ], - initialValue: 'approve', - }); - - return choice; -} - -function printSummary(entries: ResolvedCarryEntry[]): void { - const rows: string[] = []; - const srcW = Math.max(3, ...entries.map((e) => e.rawSrc.length)); - const destW = Math.max(4, ...entries.map((e) => e.rawDest.length)); - rows.push(` ${pad('src', srcW)} → ${pad('dest', destW)} size flags`); - for (const e of entries) { - const flags: string[] = []; - if (e.kind === 'missing') flags.push('optional'); - else if (e.optional) flags.push('optional'); - if (e.kind === 'dir') flags.push('dir'); - if (e.mode !== undefined) flags.push(`mode ${e.mode.toString(8).padStart(4, '0')}`); - // Unset means "the box user", which is the calm common case and needs no - // flag. Any explicit `user:` is an override worth seeing at the gate — - // including `user: 1000`, which now pins a literal uid rather than - // restating the default. - if (e.user !== undefined) flags.push(`user ${String(e.user)}`); - if (e.symlinkInfo === 'outside-home') flags.push('symlink → outside $HOME!'); - const size = e.kind === 'missing' ? '—' : fmtBytes(e.bytes ?? 0); - rows.push( - ` ${pad(e.rawSrc, srcW)} → ${pad(e.rawDest, destW)} ${pad(size, 9)} ${flags.join(', ')}`, - ); - } - log.message(rows.join('\n')); -} - -function pad(s: string, w: number): string { - if (s.length >= w) return s; - return s + ' '.repeat(w - s.length); -} diff --git a/apps/cli/src/commands/_run-queued-job.ts b/apps/cli/src/commands/_run-queued-job.ts index 052802678..d9b53e74c 100644 --- a/apps/cli/src/commands/_run-queued-job.ts +++ b/apps/cli/src/commands/_run-queued-job.ts @@ -372,6 +372,7 @@ async function runDockerJob( // no other agent's volume, credentials or home dir. `noAgent` (a plain // `create`) selects none; an agent can still be added on demand later. agents: plan.agents, + ...(opts.borrowCredentials?.length ? { borrowCredentials: [...opts.borrowCredentials] } : {}), // The agent's own `box.isolateConfig` accessor, through its CLI // module — the same one `create-action` uses — instead of a switch over // three named options. An agent this build has no module for (a service @@ -609,6 +610,7 @@ async function runCloudJob( // Same authoritative selection the docker branch above applies: the job // names exactly one agent, so the box carries only that one's credentials. agents: plan.agents, + ...(opts.borrowCredentials?.length ? { borrowCredentials: [...opts.borrowCredentials] } : {}), // `--build` (allowPull:false) + registry, credential-sync, bundle depth, and // the push mode: the foreground `create` conversion routes cloud creates // through this worker, so each must ride the job to match the old inline path. diff --git a/apps/cli/src/commands/cp.ts b/apps/cli/src/commands/cp.ts index faf9febc3..1a172cf1d 100644 --- a/apps/cli/src/commands/cp.ts +++ b/apps/cli/src/commands/cp.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path'; import { log } from '@clack/prompts'; import { Command } from 'commander'; import { loadEffectiveConfig } from '@agentbox/config'; +import { effectiveExcludes, fmtBytes, measureCopy, toTarExcludes } from '@agentbox/sandbox-core'; import { downloadFromBox, inspectBox, @@ -11,7 +12,7 @@ import { } from '@agentbox/sandbox-docker'; import { resolveBoxOrExit } from '../box-ref.js'; import { providerForBox } from '../provider/registry.js'; -import { effectiveExcludes, fmtBytes, measureCopy, toTarExcludes } from '../lib/dir-breakdown.js'; + import { handleLifecycleError } from './_errors.js'; interface CpOptions { diff --git a/apps/cli/src/commands/credentials.ts b/apps/cli/src/commands/credentials.ts index f61575899..0876bf9f5 100644 --- a/apps/cli/src/commands/credentials.ts +++ b/apps/cli/src/commands/credentials.ts @@ -1,5 +1,7 @@ import { Command } from 'commander'; import { + borrowIngestTask, + findAgentSpec, isRealAgentCredential, planPropagateTargets, pushCredentialToBox, @@ -113,6 +115,43 @@ const propagateCommand = new Command('propagate') } } + // Boxes that BORROW this login as model auth (an OpenClaw box on the + // user's Codex account). No volume to write: the blob goes to the + // canonical path over the transport, then the consuming agent's ingest + // task re-runs — a no-op while its own imported profile is still usable, + // a re-import once that profile has died. + for (const target of plan.borrowingBoxes) { + try { + const provider = await providerForBox(target); + if (!provider.syncTransport) { + process.stdout.write(`${target.name}: no transport; skipped\n`); + continue; + } + const insp = await provider.inspect(target); + if (insp.state !== 'running') { + process.stdout.write(`${target.name}: ${insp.state}; skipped (borrowed login)\n`); + continue; + } + await pushCredentialToBox(provider.syncTransport(target), agent, content); + pushed += 1; + process.stdout.write(`pushed ${agent} credential to ${target.name} (borrowed)\n`); + const ingest = (target.agents ?? []) + .map((a) => findAgentSpec(a)) + .map((spec) => (spec ? borrowIngestTask(spec) : undefined)) + .find((task) => task !== undefined); + if (ingest) { + await provider.exec(target, ['agentbox-ctl', 'run-task', ingest, '--force'], { + user: 'vscode', + }); + } + } catch (err) { + failed += 1; + process.stderr.write( + `${target.name}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + process.stdout.write( `propagated ${agent} credential to ${String(pushed)} target(s)` + (failed > 0 ? `, ${String(failed)} failed` : '') + diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index 9b47d1416..0944fa86c 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -40,11 +40,7 @@ import { ensureOpencodeInstalled, startOpencodeSession, } from '@agentbox/agent-opencode'; -import { - DEFAULT_PI_SESSION, - ensurePiInstalled, - startPiSession, -} from '@agentbox/agent-pi'; +import { DEFAULT_PI_SESSION, ensurePiInstalled, startPiSession } from '@agentbox/agent-pi'; import { DEFAULT_CODEX_SESSION, ensureCodexInstalled, @@ -57,6 +53,7 @@ import { readState, removeBoxRecord, resolveAgentSpec, + withServiceSignIn, } from '@agentbox/sandbox-core'; import { resolveBoxPromptSource } from '../control-plane/box-plane.js'; import { resolveHubApiTarget } from './control-plane.js'; @@ -662,13 +659,20 @@ export const dashboardCommand = new Command('dashboard') try { const box = await findBox(boxId); const web = webTarget(box); - if (web.exposed) exposedWebUrl = web.url; + // A service agent's UI takes its token from the URL fragment, so both + // browsers need the signed form or they open on its sign-in prompt. + let webUrl = web.url; + if (web.exposed) { + const record = await loadBoxRecord(boxId); + webUrl = await withServiceSignIn(await providerForBox(record), record, web.url); + exposedWebUrl = webUrl; + } // Show the app inside the VNC desktop on the same URL the host uses; // ensureBoxBrowser routes a Portless `.localhost` URL via the host proxy. const br = await ensureBoxBrowser( box.container, undefined, - web.exposed ? web.url : 'about:blank', + web.exposed ? webUrl : 'about:blank', ); if (!br.up) return `VNC: in-box browser unavailable (${br.reason ?? 'box not running?'})`; } catch { diff --git a/apps/cli/src/commands/screen.ts b/apps/cli/src/commands/screen.ts index 0e4155b0c..7b27b0c59 100644 --- a/apps/cli/src/commands/screen.ts +++ b/apps/cli/src/commands/screen.ts @@ -49,7 +49,9 @@ function parseTtlOrExit(raw: string | undefined): number | undefined { * provider even when the VNC URL comes off the hub payload. */ async function dockerBrowserPrep(box: BoxRecord): Promise { - const br = await ensureBoxBrowserShowingApp(box); + // The provider is what reads a service agent's token out of the box, so the + // in-box browser lands on the dashboard rather than its sign-in prompt. + const br = await ensureBoxBrowserShowingApp(box, await providerForBox(box)); if (br.up && !br.alreadyRunning) { log.info( br.target !== 'about:blank' diff --git a/apps/cli/src/control-plane/hub-api-client.ts b/apps/cli/src/control-plane/hub-api-client.ts index bebb0c775..66470d9de 100644 --- a/apps/cli/src/control-plane/hub-api-client.ts +++ b/apps/cli/src/control-plane/hub-api-client.ts @@ -77,6 +77,8 @@ export interface HubApiBox { webPort?: number; previewUrls?: Record; lastAgent?: AgentId; + /** Other agents' logins this box borrows as model auth. */ + borrowedCredentials?: string[]; topology?: string; } diff --git a/apps/cli/src/lib/ask-clack.ts b/apps/cli/src/lib/ask-clack.ts new file mode 100644 index 000000000..a0c6ac560 --- /dev/null +++ b/apps/cli/src/lib/ask-clack.ts @@ -0,0 +1,105 @@ +/** + * The terminal asker: renders a {@link PromptRequest} with clack and returns the + * user's answer. One of three implementations of the same seam (the hub has a + * collecting asker for its preflight and a map-backed asker that replays what a + * client posted), so a gate's logic is written once and asked three ways. + * + * Non-TTY is where the two halves of the schema earn their keep: a `required` + * prompt has no safe silent answer, so it throws with the gate's own + * `nonInteractiveHint`; anything else takes `fallback` and says so. This is what + * preserves the rule that `-y` must never auto-approve a `carry:` block. + */ + +import { confirm, isCancel, log, select, text } from '@agentbox/cli-kit'; +import type { PromptAnswer, PromptAsker, PromptRequest } from '@agentbox/core'; + +export interface ClackAskerOptions { + /** Caller-controlled TTY check; defaults to `process.stdin.isTTY`. */ + isTTY?: boolean; + onLog?: (line: string) => void; +} + +/** Build a clack-backed {@link PromptAsker}. */ +export function clackAsker(opts: ClackAskerOptions = {}): PromptAsker { + return async (req: PromptRequest): Promise => { + const tty = opts.isTTY ?? process.stdin.isTTY; + if (!tty) return nonInteractive(req, opts.onLog); + + printDetail(req); + + if (req.kind === 'text') { + const answer = await text({ + message: req.title, + ...(req.defaultValue !== undefined ? { initialValue: req.defaultValue } : {}), + }); + return isCancel(answer) + ? { id: req.id, value: req.fallback.value, cancelled: true } + : { id: req.id, value: String(answer) }; + } + + if (req.kind === 'confirm') { + const answer = await confirm({ + message: req.title, + initialValue: req.defaultValue !== 'n', + }); + return isCancel(answer) + ? { id: req.id, value: req.fallback.value, cancelled: true } + : { id: req.id, value: answer ? 'y' : 'n' }; + } + + const choices = req.choices ?? []; + const answer = await select({ + message: req.title, + options: choices.map((c) => ({ + value: c.value, + label: c.label, + ...(c.hint ? { hint: c.hint } : {}), + })), + ...(req.defaultValue !== undefined ? { initialValue: req.defaultValue } : {}), + }); + return isCancel(answer) + ? { id: req.id, value: req.fallback.value, cancelled: true } + : { id: req.id, value: answer }; + }; +} + +/** + * No TTY: refuse a `required` prompt, else take the fallback out loud. + * + * The refusal message is the gate's own `nonInteractiveHint`, so the escape + * hatch a user is told about is always the one that actually exists. + */ +function nonInteractive(req: PromptRequest, onLog?: (line: string) => void): PromptAnswer { + if (req.required) { + const hint = req.nonInteractiveHint ? ` ${req.nonInteractiveHint}` : ''; + throw new Error(`${req.title} Needs an answer, but this isn't an interactive terminal.${hint}`); + } + onLog?.(`${req.topic}: ${req.fallback.reason}`); + return { id: req.id, value: req.fallback.value }; +} + +/** Render the typed detail, richest form first, falling back to `summary`. */ +function printDetail(req: PromptRequest): void { + if (req.body) log.message(req.body); + const d = req.detail; + if (!d) return; + switch (d.type) { + case 'file-table': + log.message(indent(d.summary)); + break; + case 'credential': + log.message(indent(`${d.label}\n${d.hostPath} -> ${d.boxPath}`)); + if (d.caveat) log.warn(d.caveat); + break; + default: + // An unknown variant still has a summary — that is the contract. + log.message(indent(d.summary)); + } +} + +function indent(s: string): string { + return s + .split('\n') + .map((l) => ` ${l}`) + .join('\n'); +} diff --git a/apps/cli/src/lib/carry-gate.ts b/apps/cli/src/lib/carry-gate.ts index 413d02770..f0195694d 100644 --- a/apps/cli/src/lib/carry-gate.ts +++ b/apps/cli/src/lib/carry-gate.ts @@ -1,11 +1,21 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +/** + * CLI-side wrapper over the shared `carry:` gate. + * + * The gate itself (resolve, safety-check, ask) lives in + * `@agentbox/sandbox-core` so the hub can run the identical decision for a box + * created from the tray or the web UI. What stays here is the CLI's own + * context: reading `agentbox.yaml`, the effective size cap, the `--carry-yes` / + * `AGENTBOX_CARRY` escape hatches, and a terminal asker. + */ + import { log } from '@clack/prompts'; import { loadEffectiveConfig } from '@agentbox/config'; -import { parseCarrySection, parseReplacementsSection } from '@agentbox/ctl'; import type { ResolvedCarryEntry } from '@agentbox/core'; -import { promptForCarry } from '../carry-prompt.js'; -import { resolveCarry } from './carry-resolve.js'; +import { loadCarrySpec } from '@agentbox/ctl'; +import { runCarryGate as runSharedCarryGate, type CarryGateResult } from '@agentbox/sandbox-core'; +import { clackAsker } from './ask-clack.js'; + +export type { CarryGateResult }; export interface CarryGateArgs { /** Absolute project root (dir holding agentbox.yaml). */ @@ -19,70 +29,32 @@ export interface CarryGateArgs { onLog?: (line: string) => void; } -export type CarryGateResult = - | { decision: 'approve'; entries: ResolvedCarryEntry[] } - | { decision: 'skip'; entries: [] } - | { decision: 'cancel' }; - /** - * Run the host-side carry gate once for a `create`-style command: - * 1. read `/agentbox.yaml`'s `carry:` block (empty when missing), - * 2. resolve + safety-check each entry, - * 3. prompt the user (or honor --carry-yes / --carry=skip / env vars), - * 4. return the approved entries (or signal cancel). + * Run the host-side carry gate once for a `create`-style command. * * Throws on hard resolver errors (non-optional missing src, denylist hit, size - * cap, etc.) so the caller can abort *before* the box is created. + * cap, ...) so the caller can abort *before* the box is created — and, on a + * non-TTY without an explicit opt-in, because a silent approval would move host + * secrets. `-y` alone deliberately does not answer this question. */ export async function runCarryGate(args: CarryGateArgs): Promise { - const emit = args.onLog ?? (() => {}); - const yamlPath = join(args.projectRoot, 'agentbox.yaml'); - - // Read agentbox.yaml once; parse both the carry and replacements sections - // from the same text (a single readFile + parse). - let yamlText = ''; - try { - yamlText = await readFile(yamlPath, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - const items = parseCarrySection(yamlText); + const { items, replacements } = await loadCarrySpec(args.projectRoot); if (items.length === 0) return { decision: 'approve', entries: [] }; const cfg = await loadEffectiveConfig(args.projectRoot); - const replacements = parseReplacementsSection(yamlText); - const resolved = await resolveCarry(items, { + const carryYes = args.carryYesFlag ?? process.env.AGENTBOX_CARRY_YES === '1'; + const carrySkip = args.carrySkipFlag ?? process.env.AGENTBOX_CARRY === 'skip'; + + return runSharedCarryGate({ projectRoot: args.projectRoot, - maxBytes: cfg.effective.box.cpMaxBytes, + items, replacements, - }); - if (resolved.errors.length > 0) { - const msg = ['carry: refused to proceed:', ...resolved.errors.map((e) => ` - ${e}`)].join( - '\n', - ); - throw new Error(msg); - } - - const carryYesEnv = process.env.AGENTBOX_CARRY_YES === '1'; - const carrySkipEnv = process.env.AGENTBOX_CARRY === 'skip'; - const carryYes = args.carryYesFlag ?? carryYesEnv; - const carrySkip = args.carrySkipFlag ?? carrySkipEnv; - - const decision = await promptForCarry({ - resolved: resolved.entries, - yes: args.yes, + maxBytes: cfg.effective.box.cpMaxBytes, + ask: clackAsker({ ...(args.onLog ? { onLog: args.onLog } : {}) }), carryYes, carrySkip, + ...(args.onLog ? { onLog: args.onLog } : {}), }); - - if (decision === 'cancel') return { decision: 'cancel' }; - if (decision === 'skip-this-run') { - emit( - `carry: skipped for this box (${String(resolved.entries.length)} entry/entries not copied)`, - ); - return { decision: 'skip', entries: [] }; - } - return { decision: 'approve', entries: resolved.entries }; } /** @@ -104,7 +76,7 @@ export async function runQueuedCarryGate(args: { yes: !!args.opts.yes, carryYesFlag: args.opts.carryYes ? true : undefined, carrySkipFlag: args.opts.carry === 'skip' ? true : undefined, - onLog: args.onLog, + ...(args.onLog ? { onLog: args.onLog } : {}), }); if (gate.decision === 'cancel') { log.warn('carry: cancelled — not queuing the job'); diff --git a/apps/cli/src/lib/carry-resync.ts b/apps/cli/src/lib/carry-resync.ts index 4c50df2cc..8c007ba75 100644 --- a/apps/cli/src/lib/carry-resync.ts +++ b/apps/cli/src/lib/carry-resync.ts @@ -1,13 +1,13 @@ import { join } from 'node:path'; import { loadEffectiveConfig } from '@agentbox/config'; import { loadCarrySection } from '@agentbox/ctl'; +import { resolveCarry } from '@agentbox/sandbox-core'; import { carrySourceHash, copyCarryPathsToBox, recordBox, type BoxRecord, } from '@agentbox/sandbox-docker'; -import { resolveCarry } from './carry-resolve.js'; export interface ResyncCarryResult { /** Approved carry entries whose host source changed and were re-copied. */ diff --git a/apps/cli/src/lib/model-auth-gate.ts b/apps/cli/src/lib/model-auth-gate.ts new file mode 100644 index 000000000..7a17a552c --- /dev/null +++ b/apps/cli/src/lib/model-auth-gate.ts @@ -0,0 +1,51 @@ +/** + * CLI-side wrapper over the shared model-auth gate. + * + * The decision (flag > config > ask) lives in `@agentbox/sandbox-core` so the + * hub can run it for a tray/web create. What stays here is the CLI's context: + * where "the user set this explicitly" comes from, and a terminal asker that + * declines on a non-TTY. + */ + +import type { AgentSettings, AgentSyncSpec } from '@agentbox/core'; +import type { ConfigSource } from '@agentbox/config'; +import { + MODEL_AUTH_NONE, + MODEL_AUTH_SETTING, + resolveModelAuth as resolveSharedModelAuth, + type AvailableBorrow, +} from '@agentbox/sandbox-core'; +import { clackAsker } from './ask-clack.js'; + +export { MODEL_AUTH_NONE, MODEL_AUTH_SETTING }; + +export interface ModelAuthGateArgs { + spec: Pick; + /** `--model-auth ` as typed, if passed. */ + flag?: string; + /** The agent's settings block, defaults applied. */ + settings: AgentSettings; + /** Where each leaf came from; `default` means the user set nothing. */ + sources: Record; + yes?: boolean; + isTTY?: boolean; + /** Injectable for tests: which borrows this host can satisfy. */ + listAvailable?: (spec: ModelAuthGateArgs['spec']) => Promise; +} + +/** The agents whose logins the create should seed, in declaration order. */ +export async function resolveModelAuth(args: ModelAuthGateArgs): Promise { + const tty = args.isTTY ?? process.stdin.isTTY; + return resolveSharedModelAuth({ + spec: args.spec, + ...(args.flag !== undefined ? { flag: args.flag } : {}), + settings: args.settings, + configuredExplicitly: + (args.sources[`${args.spec.id}.${MODEL_AUTH_SETTING}`] ?? 'default') !== 'default', + // `-y` and a non-TTY both mean "don't ask" — the asker's fallback is + // `none`, which is also the config default, so the box comes up without + // model auth rather than silently holding a subscription token. + ask: clackAsker({ isTTY: !args.yes && !!tty }), + ...(args.listAvailable ? { listAvailable: args.listAvailable } : {}), + }); +} diff --git a/apps/cli/test/_fixtures/agent-cli-surface.json b/apps/cli/test/_fixtures/agent-cli-surface.json index 34a687301..8fe6c5fc4 100644 --- a/apps/cli/test/_fixtures/agent-cli-surface.json +++ b/apps/cli/test/_fixtures/agent-cli-surface.json @@ -1012,6 +1012,13 @@ "mandatory": false, "negate": false }, + { + "flags": "--model-auth ", + "description": "which host login to seed as the model provider (none|codex; default: openclaw.modelAuth, else ask). codex: Your Codex login (ChatGPT subscription OAuth)", + "defaultValue": null, + "mandatory": false, + "negate": false + }, { "flags": "--restore ", "description": "recreate a bot from its backup under /.agentbox/bots//: the box runs on a copy of the backed-up workspace AND gets the captured openclaw state dir back, identity included. Always creates a new box", diff --git a/apps/cli/test/carry-prompt.test.ts b/apps/cli/test/carry-prompt.test.ts deleted file mode 100644 index 0b506fa4f..000000000 --- a/apps/cli/test/carry-prompt.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { promptForCarry } from '../src/carry-prompt.js'; -import type { ResolvedCarryEntry } from '../src/lib/carry-resolve.js'; - -vi.mock('@clack/prompts', () => ({ - cancel: vi.fn(), - isCancel: (v: unknown) => v === SYMBOL_CANCEL, - log: { message: vi.fn() }, - select: vi.fn(), -})); - -const SYMBOL_CANCEL = Symbol('cancel'); - -const { select } = (await import('@clack/prompts')) as unknown as { - select: ReturnType; -}; - -function entry(over: Partial = {}): ResolvedCarryEntry { - return { - rawSrc: '~/.agentbox/secrets.env', - rawDest: '~/.agentbox/secrets.env', - absSrc: '/home/marco/.agentbox/secrets.env', - absDest: '~/.agentbox/secrets.env', - kind: 'file', - bytes: 100, - optional: false, - ...over, - }; -} - -describe('promptForCarry', () => { - it('empty entries → approve immediately, no prompt', async () => { - const result = await promptForCarry({ resolved: [] }); - expect(result).toBe('approve'); - expect(select).not.toHaveBeenCalled(); - }); - - it('--carry-yes skips the prompt and approves', async () => { - const result = await promptForCarry({ resolved: [entry()], carryYes: true }); - expect(result).toBe('approve'); - expect(select).not.toHaveBeenCalled(); - }); - - it('--carry=skip skips the prompt and returns skip-this-run', async () => { - const result = await promptForCarry({ resolved: [entry()], carrySkip: true }); - expect(result).toBe('skip-this-run'); - expect(select).not.toHaveBeenCalled(); - }); - - it('-y + non-TTY + non-empty entries throws fail-loud', async () => { - await expect(promptForCarry({ resolved: [entry()], yes: true, isTTY: false })).rejects.toThrow( - /AGENTBOX_CARRY_YES=1/, - ); - }); - - it('non-TTY without any opt-in also throws (carry never silently runs in CI)', async () => { - await expect(promptForCarry({ resolved: [entry()], isTTY: false })).rejects.toThrow( - /AGENTBOX_CARRY_YES=1/, - ); - }); - - it('-y on a TTY still falls through to the prompt', async () => { - select.mockResolvedValueOnce('approve'); - const result = await promptForCarry({ resolved: [entry()], yes: true, isTTY: true }); - expect(result).toBe('approve'); - expect(select).toHaveBeenCalledOnce(); - }); - - it('on TTY, returns the user’s selection (approve / skip / cancel)', async () => { - select.mockResolvedValueOnce('skip-this-run'); - expect(await promptForCarry({ resolved: [entry()], isTTY: true })).toBe('skip-this-run'); - - select.mockResolvedValueOnce('cancel'); - expect(await promptForCarry({ resolved: [entry()], isTTY: true })).toBe('cancel'); - }); - - // The default owner is "the box user", resolved in-box — so an explicit - // `user:` is always an override the human should see before approving, - // including `user: 1000`, which now pins a literal uid rather than - // restating the default (vscode is not 1000 on vercel/e2b). - it('shows a user flag whenever user: is set explicitly, and none when unset', async () => { - const { log } = (await import('@clack/prompts')) as unknown as { - log: { message: ReturnType }; - }; - - async function render(over: Partial): Promise { - log.message.mockClear(); - select.mockResolvedValueOnce('approve'); - await promptForCarry({ resolved: [entry(over)], isTTY: true }); - return log.message.mock.calls.map((c) => String(c[0])).join('\n'); - } - - expect(await render({ user: 1000 })).toContain('user 1000'); - expect(await render({ user: 0 })).toContain('user 0'); - expect(await render({})).not.toContain('user '); - }); - - it('Ctrl-C (isCancel) hard-exits with code 130', async () => { - select.mockResolvedValueOnce(SYMBOL_CANCEL); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code?: string | number | null) => { - throw new Error(`exit:${String(code)}`); - }); - try { - await expect(promptForCarry({ resolved: [entry()], isTTY: true })).rejects.toThrow( - 'exit:130', - ); - expect(exitSpy).toHaveBeenCalledWith(130); - } finally { - exitSpy.mockRestore(); - } - }); -}); diff --git a/apps/cli/test/model-auth-gate.test.ts b/apps/cli/test/model-auth-gate.test.ts new file mode 100644 index 000000000..13dc6e963 --- /dev/null +++ b/apps/cli/test/model-auth-gate.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAgentSpec } from '@agentbox/sandbox-core'; +import { resolveModelAuth } from '../src/lib/model-auth-gate.js'; + +/** + * The CLI wrapper's only job beyond the shared gate: turn `--yes` / a missing + * TTY into "don't ask". The decision itself (flag > config > ask) is covered in + * packages/sandbox-core/test/model-auth-gate.test.ts. + */ +const openclaw = resolveAgentSpec('openclaw'); +const available = async () => [ + { + agent: 'codex', + label: 'Your Codex login', + hostPath: '/home/u/.codex/auth.json', + boxPath: '/home/vscode/.codex/auth.json', + }, +]; + +function args(over: Partial[0]> = {}) { + return { + spec: openclaw, + settings: { modelAuth: 'none' } as const, + sources: {}, + listAvailable: available, + ...over, + } as Parameters[0]; +} + +describe('resolveModelAuth (CLI wrapper)', () => { + it('declines rather than asks without a TTY or under --yes', async () => { + // The asker's fallback for this prompt is `none`, which is also the config + // default — a scripted create must not hand a subscription token to a daemon. + expect(await resolveModelAuth(args({ isTTY: false }))).toEqual([]); + expect(await resolveModelAuth(args({ isTTY: true, yes: true }))).toEqual([]); + }); + + it('still honours the flag and the config key with no TTY', async () => { + expect(await resolveModelAuth(args({ isTTY: false, flag: 'codex' }))).toEqual(['codex']); + expect( + await resolveModelAuth( + args({ + isTTY: false, + settings: { modelAuth: 'codex' }, + sources: { 'openclaw.modelAuth': 'global' }, + }), + ), + ).toEqual(['codex']); + }); +}); diff --git a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts index a995440a2..398b6939b 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts @@ -879,6 +879,45 @@ export function buildOpenApi(): Record { }, }, }, + '/projects/{id}/create-preflight': { + post: { + tags: ['Projects'], + summary: 'What would creating a box here ask the user?', + description: + 'The host-boundary questions a create for this project would ask — a project\'s `carry:` block, the host login a service agent would borrow — as `PromptRequest` objects a client renders and answers. Answers ride back on `POST /api/v1/boxes` as `opts.promptAnswers`.\n\nThe list comes from running the real gates with a collecting asker, so it can never differ from what the create asks. `unavailable` names each gate this hub cannot run and why: a control box has no local checkout of the project, so it can read neither the files a `carry:` block names nor the host logins a box would borrow, and says so rather than returning an empty list that looks like "nothing to ask".\n\nA client that skips this endpoint still creates boxes: every prompt carries a `fallback`. But a prompt marked `required` — `carry:`, whose silent answer would move host secrets — has no safe fallback, and a create that leaves it unanswered is refused.', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['agent'], + properties: { + agent: { + type: 'string', + description: + 'Agent the box would run; decides which agent-specific gates apply. `none` for an agentless box.', + }, + provider: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'The questions this create would ask', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/CreatePreflight' } }, + }, + }, + '400': errorResponse, + '401': errorResponse, + '503': errorResponse, + }, + }, + }, '/projects/{id}/seed': { get: { tags: ['Projects'], @@ -2066,6 +2105,80 @@ export function buildOpenApi(): Record { }, required: ['id', 'name'], }, + PromptRequest: { + type: 'object', + description: + 'One question to put to the user. The generic half (`kind`, `title`, `body`, `choices`) is enough to render and answer ANY prompt, including one this client has never seen. `detail` is the opt-in half: a client that recognises the variant draws it properly (a file table, a credential card) and one that does not renders its `summary`.', + required: ['id', 'topic', 'kind', 'title', 'fallback'], + properties: { + id: { + type: 'string', + description: + 'Content-addressed over the question itself (`:`). Echo it back in the answer; if the question has since changed the id no longer matches and the create refuses rather than applying an answer to a different question.', + }, + topic: { + type: 'string', + description: 'Machine-stable reason this prompt exists: `carry`, `model-auth`.', + }, + kind: { type: 'string', enum: ['confirm', 'select', 'text'] }, + title: { type: 'string' }, + body: { type: 'string' }, + choices: { + type: 'array', + description: 'Required for `select`.', + items: { + type: 'object', + required: ['value', 'label'], + properties: { + value: { type: 'string' }, + label: { type: 'string' }, + hint: { type: 'string' }, + danger: { type: 'boolean' }, + }, + }, + }, + defaultValue: { type: 'string' }, + detail: { + type: 'object', + description: + 'Typed extra content, discriminated by `type` (`file-table` | `credential` | `text`). Every variant carries `summary`, so an unknown one still renders.', + required: ['type', 'summary'], + properties: { type: { type: 'string' }, summary: { type: 'string' } }, + additionalProperties: true, + }, + fallback: { + type: 'object', + description: 'What happens if nobody answers.', + required: ['value', 'reason'], + properties: { value: { type: 'string' }, reason: { type: 'string' } }, + }, + required: { + type: 'boolean', + description: + 'No safe fallback: a create that leaves this unanswered is refused rather than defaulted.', + }, + nonInteractiveHint: { + type: 'string', + description: 'The flags or env vars that decide this question up front.', + }, + }, + }, + CreatePreflight: { + type: 'object', + required: ['prompts', 'unavailable'], + properties: { + prompts: { type: 'array', items: { $ref: '#/components/schemas/PromptRequest' } }, + unavailable: { + type: 'array', + description: 'Gates this hub cannot run, and why.', + items: { + type: 'object', + required: ['topic', 'reason'], + properties: { topic: { type: 'string' }, reason: { type: 'string' } }, + }, + }, + }, + }, ProjectSeed: { type: 'object', properties: { @@ -2374,6 +2487,26 @@ export function buildOpenApi(): Record { description: "Always-on box: never auto-paused, never idle-lapsed, skipped by prune, restarted after a host reboot. OMIT for no opinion — a service agent then defaults to `true` and everything else to the hub's `box.persistent`. `true` on e2b/vercel is refused with `conflict`.", }, + promptAnswers: { + type: 'array', + description: + 'Answers to the questions POST /projects/{id}/create-preflight returned. Each `id` is matched against the question the create actually asks, so a stale answer is ignored rather than applied. Omitting an answer to a `required` prompt fails the create.', + items: { + type: 'object', + required: ['id', 'value'], + properties: { + id: { type: 'string' }, + value: { type: 'string' }, + cancelled: { type: 'boolean' }, + }, + }, + }, + borrowCredentials: { + type: 'array', + items: { type: 'string' }, + description: + "Other agents' host logins to seed as this box's model auth (`--model-auth`; service agents). Normally decided by the `model-auth` prompt instead.", + }, }, }, provider: { diff --git a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts index d30520001..0ad2c4ef1 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts @@ -176,6 +176,7 @@ function parseCreateBoxOpts(v: unknown): Parsed { 'build', 'credentialSync', 'dangerouslySkipPermissions', + 'carryYes', ] as const; for (const f of boolFields) { const r = optionalBool(v[f], `opts.${f}`); @@ -200,9 +201,46 @@ function parseCreateBoxOpts(v: unknown): Parsed { if (!Array.isArray(v.carry)) return { ok: false, message: 'opts.carry must be an array' }; out.carry = v.carry; } + // Other agents' host logins to seed as model auth. Parsed rather than dropped: + // the backend has always read this field, so without it the key was dead. + const bc = optionalStringArray(v.borrowCredentials, 'opts.borrowCredentials'); + if (!bc.ok) return bc; + if (bc.value !== undefined) out.borrowCredentials = bc.value; + // promptAnswers: PromptAnswer[] — the client's answers to create-preflight. + // Only the shape is checked here; the backend matches each id against the + // question it actually asks, and refuses a stale one. + if (v.promptAnswers !== undefined) { + if (!Array.isArray(v.promptAnswers)) { + return { ok: false, message: 'opts.promptAnswers must be an array' }; + } + for (const [i, a] of v.promptAnswers.entries()) { + if (!isObject(a) || typeof a.id !== 'string' || typeof a.value !== 'string') { + return { + ok: false, + message: `opts.promptAnswers[${String(i)}] must be { id: string, value: string }`, + }; + } + } + out.promptAnswers = v.promptAnswers; + } return { ok: true, value: out }; } +// Body of POST /api/v1/projects/:id/create-preflight. The project comes from the +// path (resolved server-side, never a client path); the agent decides which +// agent-specific gates run. +export function parseCreatePreflight(body: unknown): Parsed<{ agent: string; provider?: string }> { + if (!isObject(body)) return { ok: false, message: 'body must be a JSON object' }; + const agent = typeof body.agent === 'string' ? body.agent.trim() : ''; + if (agent.length === 0) return { ok: false, message: 'agent is required (string)' }; + const provider = optionalString(body.provider, 'provider'); + if (!provider.ok) return provider; + return { + ok: true, + value: { agent, ...(provider.value !== undefined ? { provider: provider.value } : {}) }, + }; +} + // Rename a box: set (or clear, with an empty string) its cosmetic display label. // The backend trims + clears on blank; here we only enforce the shape + a length // cap (matching the CLI's --set-name cap). diff --git a/apps/hub/app/(dashboard)/api/v1/projects/[id]/create-preflight/route.ts b/apps/hub/app/(dashboard)/api/v1/projects/[id]/create-preflight/route.ts new file mode 100644 index 000000000..badc1e000 --- /dev/null +++ b/apps/hub/app/(dashboard)/api/v1/projects/[id]/create-preflight/route.ts @@ -0,0 +1,29 @@ +// POST /api/v1/projects/:id/create-preflight — what would creating a box here ask +// the user? Returns the questions (`PromptRequest[]`) a client renders before it +// POSTs /api/v1/boxes, plus the gates this hub cannot run and why. +// +// The questions come from running the REAL gates with a collecting asker, so a +// client can never be shown a set that differs from what the create asks. +import { backendOrNull } from '../../../lib/backend'; +import { fail, ok } from '../../../lib/envelope'; +import { parseCreatePreflight, readJson } from '../../../lib/validate'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: Request, + ctx: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await ctx.params; + const backend = backendOrNull(); + if (!backend) return fail('backend_unavailable', 'hub backend unavailable (run the hub server)'); + + const parsedBody = await readJson(req); + if (!parsedBody.ok) return fail('invalid_request', parsedBody.message); + const parsed = parseCreatePreflight(parsedBody.value); + if (!parsed.ok) return fail('invalid_request', parsed.message); + + const res = await backend.createPreflight({ ...parsed.value, projectId: id }); + return ok(res); +} diff --git a/apps/hub/app/(dashboard)/boxes/components/create-box-modal.tsx b/apps/hub/app/(dashboard)/boxes/components/create-box-modal.tsx index 455b3d7ee..017d5e910 100644 --- a/apps/hub/app/(dashboard)/boxes/components/create-box-modal.tsx +++ b/apps/hub/app/(dashboard)/boxes/components/create-box-modal.tsx @@ -23,6 +23,13 @@ import { useStore } from '@/lib/boxes/store'; import type { AgentOption, Project, ProviderOption } from '@/lib/boxes/types'; import { cn } from '@/lib/utils'; import { JobLogStream, type JobLoginState } from './job-log-stream'; +import { PromptView, type PromptRequest } from './prompt-view'; + +/** Mirror of @agentbox/core's PromptAnswer (kept out of the Next bundle). */ +interface PromptAnswer { + id: string; + value: string; +} type Agent = CreateBoxInput['agent']; @@ -134,6 +141,13 @@ function CreateBoxModal({ const [sizeRebake, setSizeRebake] = useState<{ required: boolean; reason?: string } | null>(null); const [error, setError] = useState(null); const [jobId, setJobId] = useState(null); + // Questions the hub says this create needs approved, asked one at a time, and + // the answers gathered so far. Empty when there is nothing to ask. + const [prompts, setPrompts] = useState([]); + const [answers, setAnswers] = useState([]); + // Gates this hub could not run (a control box cannot read this machine's + // files) — shown so a dropped `carry:` block is never silent. + const [unavailable, setUnavailable] = useState<{ topic: string; reason: string }[]>([]); const [jobStatus, setJobStatus] = useState('streaming'); const [loginPhase, setLoginPhase] = useState(null); // Two-phase create: when the provider's base image needs baking, a prepare @@ -308,12 +322,67 @@ function CreateBoxModal({ // the effect on projectId/jobId is sufficient. }, [projectId, jobId]); + /** + * Create was pressed. Ask the host-boundary questions FIRST — before any bake + * — then run the normal bake-or-create decision. Asking after the bake would + * put a required `carry:` question minutes into a build the user has been + * watching, under a card that still says "Building base image". + */ const submit = () => { setError(null); if (!projectId) { setError('pick a project'); return; } + startTransition(async () => { + const asked = await askPreflight(); + // Questions shown: `answerPrompt` calls `proceed()` once they are answered. + if (!asked) proceed(); + }); + }; + + /** + * Fetch the create-time questions. Returns true when there are some to show. + * Advisory: a hub too old to serve the route still creates boxes, and a + * `required` prompt is refused by the create itself rather than slipping + * through silently. + */ + const askPreflight = async (): Promise => { + try { + const res = await fetch( + `/api/v1/projects/${encodeURIComponent(projectId)}/create-preflight`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent, provider }), + }, + ); + const j = (await res.json()) as { + prompts?: PromptRequest[]; + unavailable?: { topic: string; reason: string }[]; + } | null; + setUnavailable(j?.unavailable ?? []); + if (res.ok && j?.prompts?.length) { + setPrompts(j.prompts); + setAnswers([]); + return true; + } + } catch { + /* advisory; fall through to creating */ + } + return false; + }; + + /** + * Everything after the questions: bake first when needed, else create. + * + * `collected` is threaded rather than read from state because the no-bake path + * runs in the SAME tick as the `setAnswers` that produced it, and would + * otherwise post the create with the previous render's answers. The bake path + * re-enters through `startCreate` a job later, by which time state has caught + * up — so its default is the state value. + */ + const proceed = (collected: PromptAnswer[] = answers) => { // A size the base has to be re-baked at is the same two-phase flow as a // missing base, minus the stale-base question (the user just asked for // this, so there is nothing to confirm). @@ -339,7 +408,7 @@ function CreateBoxModal({ startBake(); return; } - startCreate(); + startCreate(collected); }; // Phase 1: bake the provider's base image (or attach to an in-flight bake). @@ -384,61 +453,100 @@ function CreateBoxModal({ }); }; - const startCreate = () => { + /** + * Post the create. The questions were already asked and answered in `submit`, + * so this fires straight after a bake (from the bake stream's `end: done`) + * without stopping to prompt. + */ + const startCreate = (collected: PromptAnswer[] = answers) => { setError(null); startTransition(async () => { - const res = await createBoxAction({ - projectId, - agent, - provider, - name: name.trim() || undefined, - prompt: agent === 'none' ? undefined : prompt.trim() || undefined, - // Only pin an explicit base when it differs from the current branch; leaving the current - // branch selected bases the box on the host's literal HEAD (like `agentbox create` with no - // --from-branch) and skips a redundant fetch. Uncommitted + untracked files carry over either way. - fromBranch: - fromBranch.trim() && fromBranch.trim() !== selected?.currentBranch - ? fromBranch.trim() - : undefined, - setupWizard: agent !== 'none' && runSetup, - // Both options land in ONE `opts`: two conditional `{ opts: ... }` - // spreads would overwrite each other, silently dropping `persistent` - // whenever a size was also picked. + await submitCreate(collected); + }); + }; + + /** Post the create with whatever answers were collected. */ + const submitCreate = async (promptAnswers: PromptAnswer[]) => { + const res = await createBoxAction({ + projectId, + agent, + provider, + name: name.trim() || undefined, + prompt: agent === 'none' ? undefined : prompt.trim() || undefined, + // Only pin an explicit base when it differs from the current branch; leaving the current + // branch selected bases the box on the host's literal HEAD (like `agentbox create` with no + // --from-branch) and skips a redundant fetch. Uncommitted + untracked files carry over either way. + fromBranch: + fromBranch.trim() && fromBranch.trim() !== selected?.currentBranch + ? fromBranch.trim() + : undefined, + setupWizard: agent !== 'none' && runSetup, + // Both options land in ONE `opts`: two conditional `{ opts: ... }` + // spreads would overwrite each other, silently dropping `persistent` + // whenever a size was also picked. + // + // `persistent` is omitted unless asked for — sending `false` would + // override the hub's own `box.persistent`, turning "no opinion" into an + // opt-out. `size` goes only to providers that apply one per create; + // daytona and e2b reject it there and got theirs baked in above. + ...(() => { + const opts: { + persistent?: boolean; + size?: string; + promptAnswers?: PromptAnswer[]; + } = {}; + // The user's answers to the preflight questions. Each id is matched + // against the question the create actually asks, so a stale one is + // refused rather than applied to a different question. + if (promptAnswers.length > 0) opts.promptAnswers = promptAnswers; + // Sent only when it differs from what the API would derive on its own. + // Silence is not `false`: it lets the hub's `box.persistent` decide, + // and for a service agent it is what keeps the box always-on. // - // `persistent` is omitted unless asked for — sending `false` would - // override the hub's own `box.persistent`, turning "no opinion" into an - // opt-out. `size` goes only to providers that apply one per create; - // daytona and e2b reject it there and got theirs baked in above. - ...(() => { - const opts: { persistent?: boolean; size?: string } = {}; - // Sent only when it differs from what the API would derive on its own. - // Silence is not `false`: it lets the hub's `box.persistent` decide, - // and for a service agent it is what keeps the box always-on. - // - // A capped provider is the case where silence is WRONG. e2b and vercel - // refuse `persistent: true`, and for a service agent the API derives - // exactly that — so omitting the field made every OpenClaw create on - // those providers fail, with the toggle sitting disabled and unable to - // say otherwise. It has to opt out explicitly, which is what the CLI's - // `--no-persistent` does. - if (persistentCapped) { - if (persistentByDefault) opts.persistent = false; - } else if (persistent !== persistentByDefault) { - opts.persistent = persistent; - } - if (chosenSize.length > 0 && providerOption?.sizeAppliesAt !== 'bake') { - opts.size = chosenSize; - } - return Object.keys(opts).length > 0 ? { opts } : {}; - })(), - }); - if (!res.ok) { - setError(res.error); - return; - } - setJobId(res.jobId); - router.refresh(); // surface the box as `creating` + // A capped provider is the case where silence is WRONG. e2b and vercel + // refuse `persistent: true`, and for a service agent the API derives + // exactly that — so omitting the field made every OpenClaw create on + // those providers fail, with the toggle sitting disabled and unable to + // say otherwise. It has to opt out explicitly, which is what the CLI's + // `--no-persistent` does. + if (persistentCapped) { + if (persistentByDefault) opts.persistent = false; + } else if (persistent !== persistentByDefault) { + opts.persistent = persistent; + } + if (chosenSize.length > 0 && providerOption?.sizeAppliesAt !== 'bake') { + opts.size = chosenSize; + } + return Object.keys(opts).length > 0 ? { opts } : {}; + })(), }); + if (!res.ok) { + setError(res.error); + return; + } + setJobId(res.jobId); + setPrompts([]); + router.refresh(); // surface the box as `creating` + }; + + /** Record one answer; the last one submits the create. */ + const answerPrompt = (value: string) => { + const current = prompts[0]; + if (!current) return; + const next = [...answers, { id: current.id, value }]; + const remaining = prompts.slice(1); + // Any answer that abandons the create ends it here rather than sending a + // decision the backend would only refuse. + if (value === 'cancel') { + setPrompts([]); + setAnswers([]); + return; + } + setAnswers(next); + setPrompts(remaining); + // Threaded, not read back from state: this runs in the same tick as the + // `setAnswers` above. + if (remaining.length === 0) proceed(next); }; return ( @@ -449,7 +557,13 @@ function CreateBoxModal({
- {!jobId && bakeJobId ? 'Building base image' : 'Create box'} + + {prompts.length > 0 + ? 'Create box' + : !jobId && bakeJobId + ? 'Building base image' + : 'Create box'} + {selected ? selected.name : 'Start a box in a project'} @@ -462,7 +576,23 @@ function CreateBoxModal({ ) : null} - {jobId ? ( + {/* Gates this hub could not run. Shown above everything else so a + dropped `carry:` block is visible before the box is committed to, + not discovered later inside a box missing its files. */} + {unavailable.length > 0 && !jobId && !bakeJobId ? ( +
+ {unavailable.map((u) => ( +
+ {u.topic} + — {u.reason} +
+ ))} +
+ ) : null} + {prompts.length > 0 ? ( + // One question at a time: answering the last one starts the create. + + ) : jobId ? ( {error ?
{error}
: null} - ) : ( + ) : prompts.length > 0 ? null : ( <> {!project ? ( @@ -727,7 +857,20 @@ function CreateBoxModal({ )}
- {jobId || bakeJobId ? ( + {prompts.length > 0 ? ( + // The prompt's own choice buttons are the action here; a second + // "Create" would let the user skip the question it is asking. + <> + + {prompts.length > 1 + ? `${String(prompts.length)} questions before this box is created` + : 'One question before this box is created'} + + + + ) : jobId || bakeJobId ? ( <> {jobStatus === 'streaming' ? ( diff --git a/apps/hub/app/(dashboard)/boxes/components/prompt-view.tsx b/apps/hub/app/(dashboard)/boxes/components/prompt-view.tsx new file mode 100644 index 000000000..1a0763286 --- /dev/null +++ b/apps/hub/app/(dashboard)/boxes/components/prompt-view.tsx @@ -0,0 +1,193 @@ +'use client'; + +/** + * Renders one create-time prompt the hub asked for. + * + * Two halves, deliberately. The generic half (title, body, choice buttons) can + * render ANY prompt, including a topic this build has never heard of — which is + * what lets the hub add a gate without shipping a new UI. The typed half draws + * the variants worth drawing properly: a `carry:` file table, a credential card. + * An unrecognised `detail` falls back to its `summary`, which every variant + * carries for exactly this reason. + */ + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +// Structural mirrors of @agentbox/core's prompt schema. Spelled locally for the +// same reason the rest of this app does it: a VALUE import of an @agentbox/* +// package pulls it into the Next bundle. +export interface PromptChoice { + value: string; + label: string; + hint?: string; + danger?: boolean; +} + +export interface PromptFileRow { + src: string; + dest: string; + bytes?: number; + kind: 'file' | 'dir' | 'missing'; + mode?: string; + user?: number; + flags: string[]; + warn?: boolean; +} + +export type PromptDetail = + | { type: 'file-table'; summary: string; rows: PromptFileRow[]; totalBytes: number } + | { + type: 'credential'; + summary: string; + agent: string; + label: string; + caveat?: string; + hostPath: string; + boxPath: string; + bytes?: number; + } + | { type: string; summary: string }; + +export interface PromptRequest { + id: string; + topic: string; + kind: 'confirm' | 'select' | 'text'; + /** Short card header, with `title` as the line under it. */ + heading?: string; + title: string; + body?: string; + choices?: PromptChoice[]; + defaultValue?: string; + detail?: PromptDetail; + fallback: { value: string; reason: string }; + required?: boolean; + nonInteractiveHint?: string; +} + +export function PromptView({ + request, + onAnswer, + disabled, +}: { + request: PromptRequest; + onAnswer: (value: string) => void; + disabled?: boolean; +}) { + const choices = request.choices ?? defaultChoices(request); + return ( +
+
{request.heading ?? request.title}
+ {/* With no `heading` the question IS the header, so don't repeat it. */} + {request.heading ?
{request.title}
: null} + {request.body ? ( +

{request.body}

+ ) : null} + + + +
+ {choices.map((c) => ( + + ))} +
+
+ ); +} + +function DetailView({ detail }: { detail?: PromptDetail }) { + if (!detail) return null; + + if (detail.type === 'file-table' && 'rows' in detail) { + return ( +
+ + + + + + + + + + + {detail.rows.map((r) => ( + ${r.dest}`} + className={cn('border-t border-border/40', r.warn && 'bg-red-500/10')} + > + + + + + + ))} + +
FromToSizeNotes
{r.src}{r.dest} + {r.kind === 'missing' ? '—' : formatBytes(r.bytes ?? 0)} + + {rowFlags(r).join(', ')} +
+
+ ); + } + + if (detail.type === 'credential' && 'hostPath' in detail) { + return ( +
+
{detail.label}
+
+ {detail.hostPath} {detail.boxPath} +
+ {detail.caveat ?
{detail.caveat}
: null} +
+ ); + } + + // Unknown variant (a newer hub than this UI): every detail carries a plain + // `summary` precisely so this path still shows the user something correct. + return ( +
+      {detail.summary}
+    
+ ); +} + +/** A `confirm` (or a malformed `select`) still needs buttons to press. */ +function defaultChoices(request: PromptRequest): PromptChoice[] { + if (request.kind === 'confirm') { + return [ + { value: 'y', label: 'Yes' }, + { value: 'n', label: 'No' }, + ]; + } + return [{ value: request.fallback.value, label: 'Continue' }]; +} + +// `mode`/`user` are deliberately not shown: an octal permission bit is not +// something the person deciding needs to see. +function rowFlags(r: PromptFileRow): string[] { + return r.flags; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v < 10 ? v.toFixed(1) : String(Math.round(v))} ${units[i]}`; +} diff --git a/apps/hub/lib/boxes/backend-types.ts b/apps/hub/lib/boxes/backend-types.ts index 12d5faa68..9f3586394 100644 --- a/apps/hub/lib/boxes/backend-types.ts +++ b/apps/hub/lib/boxes/backend-types.ts @@ -143,6 +143,8 @@ export interface CreateBoxOpts { vnc?: boolean; /** `--persistent` / `--no-persistent`: always-on box (config `box.persistent`). */ persistent?: boolean; + /** Other agents' host logins to seed as model auth (`--model-auth`; service agents). */ + borrowCredentials?: string[]; resync?: boolean; sharedDockerCache?: boolean; portless?: boolean; @@ -181,6 +183,35 @@ export interface CreateBoxOpts { * files (the local file-queue path). */ carry?: unknown[]; + /** + * Approve the project's `carry:` block without asking — the API equivalent of + * the CLI's `--carry-yes`. `clone` sets it: it has no human to ask, and it + * inherits the grant the source box already holds for this project. + */ + carryYes?: boolean; + /** + * Answers to the questions GET/POST create-preflight returned + * (`PromptAnswer[]` from @agentbox/core — typed `unknown[]` here to keep that + * package out of the Next bundle). A create that omits an answer to a + * `required` prompt is refused rather than silently defaulted. + */ + promptAnswers?: unknown[]; +} + +// What a create would ask the user, before anything is provisioned. `prompts` +// is a `PromptRequest[]` (typed `unknown[]` to keep @agentbox/core out of the +// Next bundle); `unavailable` names each gate this hub cannot run and why — a +// remote control box cannot read the caller's files, and saying so is better +// than silently dropping them. +export interface CreatePreflightResult { + prompts: unknown[]; + unavailable: { topic: string; reason: string }[]; +} + +export interface CreatePreflightInput { + projectId: string; + agent: string; + provider?: string; } // Input for creating a box. The client sends EITHER a `projectId` (a registered @@ -465,6 +496,10 @@ export interface HubBackend { providersWithFreshness(opts?: { expandRemoteDockerHosts?: boolean }): Promise; // Enqueue a background create job for a registered project; returns the jobId. create(input: CreateBoxInput): Promise; + // What would a create for this project + agent ask the user? Runs the real + // gates with a collecting asker, so the questions returned here are by + // construction the questions `create` asks. + createPreflight(input: CreatePreflightInput): Promise; // Persist a provider's credentials (validated against the cloud, then written // to ~/.agentbox/secrets.env). `fields` is provider-specific (e.g. { apiKey }, // { token }, { token, teamId?, projectId? }). Never returns secret values. @@ -711,6 +746,8 @@ export type PrepareCloneResult = * hub's config layers. Explicit input wins; else the source box's. */ persistent?: boolean; + /** The source box's borrowed model logins, so the clone borrows the same. */ + borrowedCredentials?: string[]; } | { ok: false; error: string }; diff --git a/apps/hub/lib/boxes/clone-create.ts b/apps/hub/lib/boxes/clone-create.ts index 808d92276..8dbe4b986 100644 --- a/apps/hub/lib/boxes/clone-create.ts +++ b/apps/hub/lib/boxes/clone-create.ts @@ -34,6 +34,19 @@ export function cloneCreateInput(prepared: StagedClone): CreateBoxInput { // Only when the hub HAS an opinion: `prepareClone` returns undefined when // neither the request nor the source box said anything, and sending `false` // there would override the hub's own `box.persistent`. - ...(prepared.persistent !== undefined ? { opts: { persistent: prepared.persistent } } : {}), + opts: { + ...(prepared.persistent !== undefined ? { persistent: prepared.persistent } : {}), + // A bot on the user's Codex account stays on it when spawned: the + // seed is re-read from the host, never copied from the source box. + ...(prepared.borrowedCredentials?.length + ? { borrowCredentials: [...prepared.borrowedCredentials] } + : {}), + // A clone has no human to ask, and `carry:` is a `required` prompt — so + // without this every clone of a project that declares one is refused. The + // grant is inherited rather than invented: the source box was approved for + // this same project's block, which is the rule `resyncCarryFiles` already + // applies when it re-copies within an existing grant. + carryYes: true, + }, }; } diff --git a/apps/hub/lib/boxes/types.ts b/apps/hub/lib/boxes/types.ts index 049fabf65..5d9f3f972 100644 --- a/apps/hub/lib/boxes/types.ts +++ b/apps/hub/lib/boxes/types.ts @@ -91,6 +91,8 @@ export interface Box { previewUrls?: Record; // The agent the box was created for → BoxRecord.lastAgent. lastAgent?: AgentId; + /** Other agents' logins this box borrows as model auth (`--model-auth`). Read-only. */ + borrowedCredentials?: string[]; // Sync federation shape ('cloud' | 'control-plane'); a hub-created box is // 'control-plane'. Absent for docker. topology?: string; diff --git a/apps/hub/lib/hub-backend.ts b/apps/hub/lib/hub-backend.ts index 36d26218e..58f542e4d 100644 --- a/apps/hub/lib/hub-backend.ts +++ b/apps/hub/lib/hub-backend.ts @@ -34,6 +34,7 @@ import { type BoxRecord, type CloudSandboxSummary, type ExecResult, + type PromptAnswer, type Provider, } from '@agentbox/core'; import type { BoxStatus as CtlBoxStatus, StatusReply } from '@agentbox/ctl'; @@ -57,6 +58,8 @@ import { type QueueJobCreateOpts, type RelayServerHandle, } from '@agentbox/relay'; +import { collectAsker, answerMapAsker } from './prompts/askers.js'; +import { runCreateGates } from './prompts/create-gates.js'; import { mergeRemoteProviders } from './boxes/provider-origin.js'; import { hydratePreparedFromCustody } from './prepared-hydrate.js'; import { fetchRemoteProviders, resolveRemoteHub } from './remote-hub.js'; @@ -144,6 +147,7 @@ import type { CloudOrphanView, CreateBoxInput, CreateBoxResult, + CreatePreflightResult, CreateProjectInput, DirEntry, GitInfo, @@ -420,6 +424,7 @@ function mapBox(b: ListedBox, regroup?: ProjectRegrouping, originUrl?: string): webPort: b.cloud?.webPort, previewUrls: b.cloud?.previewUrls, lastAgent: b.lastAgent, + borrowedCredentials: b.borrowedCredentials, topology: b.cloud?.topology, }; } @@ -2152,7 +2157,7 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { // the box's web app, not a blank X desktop. Browser-launch failures are // logged, not thrown — the viewer URL still works without it. if ((box.provider ?? 'docker') === 'docker') { - const br = await ensureBoxBrowserShowingApp(box); + const br = await ensureBoxBrowserShowingApp(box, provider); if (!br.up) console.warn( `[hub] screen ${box.name}: in-box browser failed: ${br.reason ?? 'unknown'}`, @@ -2357,6 +2362,31 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { // worker's config defaults. `workspace`/`name`/`fromBranch` are authoritative // here (resolved server-side), so they win over any opts echo. const o = input.opts ?? {}; + // The host-boundary gates (`carry:`, model auth) run HERE, before the job + // is queued — the same front-loading the CLI's `-i` path does. A client + // that ran the preflight replays its answers; one that did not gets each + // prompt's fallback, and a `required` one (carry, which moves host + // secrets) fails the create loudly instead of silently skipping. + let gated; + try { + gated = await runCreateGates({ + workspace, + agent: input.agent, + ask: answerMapAsker(o.promptAnswers as PromptAnswer[] | undefined), + // Both are the API equivalents of a CLI flag, so they are threaded + // INTO the gate rather than merged with its answer afterwards — + // precedence lives in one place, and an explicit empty + // `borrowCredentials` (a deliberate "none") cannot lose to a + // fall-through. + ...(o.carryYes ? { carryYes: true } : {}), + ...(o.borrowCredentials !== undefined + ? { borrowCredentials: o.borrowCredentials } + : {}), + }); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + if (gated.cancelled) return { ok: false, error: 'create cancelled' }; const { job } = await enqueueQueueJob({ agent, boxName: name ?? '', @@ -2397,9 +2427,12 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { location: o.location, inbound: o.inbound, remoteHost: o.remoteHost, - // ResolvedCarryEntry[] on the wire is typed unknown[] (Next-bundle - // hygiene); the worker reads the concrete shape. - carry: o.carry as QueueJobCreateOpts['carry'], + // Resolved server-side by the gates above, so a tray/web create + // carries the same files and borrows the same login a CLI one does. + // These win over any client echo: the answer is the client's, the + // resolution is ours. + carry: gated.carry as QueueJobCreateOpts['carry'], + borrowCredentials: gated.borrowCredentials, }, }); handle.pokeQueue(); @@ -2408,6 +2441,50 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } }, + async createPreflight(input): Promise { + const empty: CreatePreflightResult = { prompts: [], unavailable: [] }; + const workspace = input.projectId ? await resolveProjectPath(input.projectId) : null; + // A control box's projects are repos, not folders: its worker clones them + // on the VPS and can read neither the caller's `agentbox.yaml` nor their + // host logins. Say so — a client that is told carry is unavailable can + // tell the user, where a silent empty list looks like "nothing to ask". + if (!workspace || !existsSync(workspace)) { + return { + prompts: [], + unavailable: [ + { + topic: 'carry', + reason: + "This hub runs somewhere else, so it can't reach files on your machine. They won't be copied.", + }, + { + topic: 'model-auth', + reason: "This hub runs somewhere else, so it can't reach your logins.", + }, + ], + }; + } + try { + const asker = collectAsker(); + const res = await runCreateGates({ + workspace, + agent: input.agent, + ask: asker.ask, + collecting: true, + }); + return { prompts: asker.collected, unavailable: res.unavailable }; + } catch (err) { + // A hard resolver error (a missing non-optional src, an over-cap entry) + // is the same failure `create` would hit. Surface it as an unavailable + // gate so the form can show it before the user commits to a box. + return { + ...empty, + unavailable: [ + { topic: 'carry', reason: err instanceof Error ? err.message : String(err) }, + ], + }; + } + }, async setProviderCredentials(id, fields): Promise { try { if (!isRuntimeProviderName(id)) return { ok: false, error: `unknown provider ${id}` }; @@ -3309,6 +3386,9 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { files: exported.files, ...(agent ? { agent } : {}), ...(persistent !== undefined ? { persistent } : {}), + ...(rp.box.borrowedCredentials?.length + ? { borrowedCredentials: [...rp.box.borrowedCredentials] } + : {}), }; } catch (err) { return { ok: false, error: errMsg(err) }; diff --git a/apps/hub/lib/prompts/askers.ts b/apps/hub/lib/prompts/askers.ts new file mode 100644 index 000000000..478969e39 --- /dev/null +++ b/apps/hub/lib/prompts/askers.ts @@ -0,0 +1,70 @@ +/** + * The hub's two {@link PromptAsker}s. + * + * Together they turn a gate into a two-step HTTP conversation without the gate + * knowing: run it once with {@link collectAsker} to learn what it would ask + * (that IS the preflight — there is no second "what would I ask?" code path to + * drift from the real one), then run it again with {@link answerMapAsker} once + * the client has posted answers back. + */ + +import type { PromptAnswer, PromptAsker, PromptRequest } from '@agentbox/core'; + +export interface CollectingAsker { + ask: PromptAsker; + /** Every question the gate reached, in the order it asked. */ + collected: PromptRequest[]; +} + +/** + * Record each question and answer it with its own fallback, so the gate runs to + * completion and the caller ends up holding the full question list. + * + * The gate's side effects are all reads (resolve + stat), so running it for its + * questions costs nothing beyond the walk it would do anyway. + */ +export function collectAsker(): CollectingAsker { + const collected: PromptRequest[] = []; + return { + collected, + ask: (req) => { + collected.push(req); + return Promise.resolve({ id: req.id, value: req.fallback.value }); + }, + }; +} + +/** A prompt the client had to answer and didn't. */ +export class UnansweredPromptError extends Error { + constructor(public readonly request: PromptRequest) { + super( + `Waiting on an answer: "${request.title}"` + + (request.nonInteractiveHint ? ` ${request.nonInteractiveHint}` : ''), + ); + this.name = 'UnansweredPromptError'; + } +} + +/** + * Replay the answers a client collected during its preflight. + * + * A missing answer is only fatal for a `required` prompt: those are the ones + * whose silent resolution would move host secrets. Everything else falls back, + * which is what lets an older client that knows nothing about a newly added + * prompt still create a box. + * + * An answer whose id does not match is treated as absent rather than applied. + * Ids are content-addressed, so a mismatch means the question changed between + * the preflight and the create — `agentbox.yaml` was edited, or a host login + * appeared or expired — and answering the new question with the old answer is + * exactly the mistake worth refusing. + */ +export function answerMapAsker(answers: readonly PromptAnswer[] | undefined): PromptAsker { + const byId = new Map((answers ?? []).map((a) => [a.id, a])); + return (req) => { + const hit = byId.get(req.id); + if (hit) return Promise.resolve(hit); + if (req.required) throw new UnansweredPromptError(req); + return Promise.resolve({ id: req.id, value: req.fallback.value }); + }; +} diff --git a/apps/hub/lib/prompts/create-gates.ts b/apps/hub/lib/prompts/create-gates.ts new file mode 100644 index 000000000..60ae7f41c --- /dev/null +++ b/apps/hub/lib/prompts/create-gates.ts @@ -0,0 +1,111 @@ +/** + * Run every create-time host-boundary gate for a hub create, once, with a + * caller-supplied asker. + * + * Both the preflight (`collectAsker` — what would you ask?) and the create + * itself (`answerMapAsker` — here are the answers) call THIS function, so the + * questions a client is shown are by construction the questions the create + * asks. Adding a gate here reaches all four front-ends at once. + */ + +import { agentSettings, loadEffectiveConfig, type ConfigSource } from '@agentbox/config'; +import type { PromptAsker, ResolvedCarryEntry } from '@agentbox/core'; +import { loadCarrySpec } from '@agentbox/ctl'; +import { + findAgentSpec, + MODEL_AUTH_SETTING, + resolveModelAuth, + runCarryGate, +} from '@agentbox/sandbox-core'; + +/** A gate that could not run here, and why — reported rather than skipped. */ +export interface UnavailableGate { + topic: string; + reason: string; +} + +export interface CreateGateInput { + /** Absolute project root on THIS machine. */ + workspace: string; + /** The agent the box is being created for; `none` for an agentless box. */ + agent: string; + ask: PromptAsker; + /** + * The carry decision, already made without asking — the API equivalent of the + * CLI's `--carry-yes`. Used by `clone`, which has no human to ask and inherits + * the grant the source box already holds for this project (the same rule + * `resyncCarryFiles` applies when it re-copies within an existing grant). + */ + carryYes?: boolean; + /** + * Model-auth chosen up front, the API equivalent of `--model-auth`. Takes + * precedence over asking, exactly as the flag does; an empty array means an + * explicit "none", which is why it is threaded here rather than merged with + * the gate's answer afterwards. + */ + borrowCredentials?: string[]; + /** + * Dry run: ask every gate its question and DECIDE nothing. + * + * Load-bearing for the preflight. A collecting asker answers each prompt with + * its own fallback, and carry's fallback is `cancel` — so without this the + * carry gate would abort the run and every later gate's question would be + * missing from the list the client is shown. + */ + collecting?: boolean; + onLog?: (line: string) => void; +} + +export interface CreateGateResult { + /** Approved `carry:` entries — empty when skipped or when there are none. */ + carry: ResolvedCarryEntry[]; + /** Agents whose host login the box should be seeded with. */ + borrowCredentials: string[]; + /** True when the user asked to abandon the create. */ + cancelled: boolean; + unavailable: UnavailableGate[]; +} + +export async function runCreateGates(input: CreateGateInput): Promise { + const emit = input.onLog ?? (() => {}); + const unavailable: UnavailableGate[] = []; + + const { items, replacements } = await loadCarrySpec(input.workspace); + const cfg = await loadEffectiveConfig(input.workspace); + + const gate = await runCarryGate({ + projectRoot: input.workspace, + items, + replacements, + maxBytes: cfg.effective.box.cpMaxBytes, + ask: input.ask, + ...(input.carryYes ? { carryYes: true } : {}), + onLog: emit, + }); + if (gate.decision === 'cancel' && !input.collecting) { + return { carry: [], borrowCredentials: [], cancelled: true, unavailable }; + } + const carry = gate.decision === 'approve' ? gate.entries : []; + + const spec = input.agent === 'none' ? undefined : findAgentSpec(input.agent); + let borrowCredentials: string[] = []; + if (input.borrowCredentials !== undefined) { + // Chosen up front — don't ask, and don't let an empty array (an explicit + // "none") fall through to anything else. + borrowCredentials = input.borrowCredentials; + } else if (spec?.modelAuth) { + // `sources` says whether the user set `.modelAuth` themselves; a hub + // create reads the hub's own effective config, the same way a bake does. + const sources = cfg.sources as Record; + borrowCredentials = await resolveModelAuth({ + spec, + settings: agentSettings(cfg.effective, spec.id), + configuredExplicitly: + (sources[`${spec.id}.${MODEL_AUTH_SETTING}`] ?? 'default') !== 'default', + ask: input.ask, + }); + for (const a of borrowCredentials) emit(`model auth: borrowing the ${a} login`); + } + + return { carry, borrowCredentials, cancelled: false, unavailable }; +} diff --git a/apps/hub/test/clone-create.test.ts b/apps/hub/test/clone-create.test.ts index 65ed10a7b..5edf4f3fb 100644 --- a/apps/hub/test/clone-create.test.ts +++ b/apps/hub/test/clone-create.test.ts @@ -11,6 +11,14 @@ const staged: StagedClone = { }; describe('cloneCreateInput', () => { + it('approves carry, so a project that declares one can still be cloned', () => { + // `carry:` is a `required` prompt with no safe fallback, and a clone has no + // human to ask — without this every clone of a project with a carry block is + // refused. The grant is inherited, not invented: the source box was approved + // for this same project's block. + expect(cloneCreateInput(staged).opts?.carryYes).toBe(true); + }); + it('enqueues the follow-on create in the FOREGROUND lane', () => { // The regression: the create moved from the CLI (which passed // `foreground: true`) into the route, which did not. A clone then queued @@ -36,9 +44,11 @@ describe('cloneCreateInput', () => { }); it('forwards the resolved persistent, and only when the hub has an opinion', () => { - expect(cloneCreateInput({ ...staged, persistent: true }).opts).toEqual({ persistent: true }); - expect(cloneCreateInput({ ...staged, persistent: false }).opts).toEqual({ persistent: false }); - // Absent: sending `false` here would override the hub's own box.persistent. - expect(cloneCreateInput(staged).opts).toBeUndefined(); + expect(cloneCreateInput({ ...staged, persistent: true }).opts?.persistent).toBe(true); + expect(cloneCreateInput({ ...staged, persistent: false }).opts?.persistent).toBe(false); + // Absent, not `false`: sending `false` here would override the hub's own + // box.persistent. (`opts` itself is always present now — it carries + // `carryYes` — so this asserts the KEY, not the object.) + expect(cloneCreateInput(staged).opts).not.toHaveProperty('persistent'); }); }); diff --git a/apps/hub/test/create-gates-collect.test.ts b/apps/hub/test/create-gates-collect.test.ts new file mode 100644 index 000000000..a89705859 --- /dev/null +++ b/apps/hub/test/create-gates-collect.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { collectAsker } from '../lib/prompts/askers'; +import { runCreateGates } from '../lib/prompts/create-gates'; + +/** + * The preflight learns its questions by running the REAL gates with a + * collecting asker. That only works if collection reaches every gate — and a + * collecting asker answers each prompt with its own fallback, which for + * `carry:` is `cancel`. + * + * Without `collecting: true` the carry gate aborted the run and the model-auth + * question never appeared in the list, so an OpenClaw box created from the tray + * or the web UI silently came up with no model provider. This is the guard. + */ +let root: string; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'agentbox-preflight-')); + await writeFile(join(root, 'secret.env'), 'TOKEN=abc\n'); + await writeFile( + join(root, 'agentbox.yaml'), + 'carry:\n - src: ./secret.env\n dest: ~/.secret.env\n', + ); +}); + +describe('runCreateGates in collecting mode', () => { + it('keeps going past a gate whose fallback is cancel', async () => { + const asker = collectAsker(); + const res = await runCreateGates({ + workspace: root, + // An agent that declares `modelAuth`, so a second gate exists to reach. + agent: 'openclaw', + ask: asker.ask, + collecting: true, + }); + expect(asker.collected.map((p) => p.topic)).toContain('carry'); + // The whole point: carry answered `cancel` (its fallback) and the run did + // NOT stop there. + expect(res.cancelled).toBe(false); + }); + + it('still aborts on cancel when it is deciding, not collecting', async () => { + const res = await runCreateGates({ + workspace: root, + agent: 'none', + ask: (req) => Promise.resolve({ id: req.id, value: 'cancel' }), + }); + expect(res.cancelled).toBe(true); + expect(res.carry).toEqual([]); + }); + + it('an explicit borrowCredentials wins, empty array included', async () => { + const refuses = () => { + throw new Error('must not ask when the answer was given up front'); + }; + // The API equivalent of `--model-auth`. An empty array is a deliberate + // "none" and must not fall through to anything else — it used to lose to a + // client-supplied list because the merge happened after the gate. + const chosen = await runCreateGates({ + workspace: root, + agent: 'openclaw', + ask: refuses, + carryYes: true, + borrowCredentials: ['codex'], + }); + expect(chosen.borrowCredentials).toEqual(['codex']); + + const declined = await runCreateGates({ + workspace: root, + agent: 'openclaw', + ask: refuses, + carryYes: true, + borrowCredentials: [], + }); + expect(declined.borrowCredentials).toEqual([]); + }); + + it('carryYes approves without asking - the clone path', async () => { + const res = await runCreateGates({ + workspace: root, + agent: 'none', + ask: () => { + throw new Error('must not ask when carryYes was set'); + }, + carryYes: true, + }); + expect(res.cancelled).toBe(false); + expect(res.carry).toHaveLength(1); + }); + + it('asks nothing for a project with no carry block and no borrowing agent', async () => { + const bare = await mkdtemp(join(tmpdir(), 'agentbox-preflight-bare-')); + const asker = collectAsker(); + const res = await runCreateGates({ + workspace: bare, + agent: 'none', + ask: asker.ask, + collecting: true, + }); + expect(asker.collected).toEqual([]); + expect(res.cancelled).toBe(false); + }); +}); diff --git a/apps/hub/test/prompt-askers.test.ts b/apps/hub/test/prompt-askers.test.ts new file mode 100644 index 000000000..eff303580 --- /dev/null +++ b/apps/hub/test/prompt-askers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { PromptRequest } from '@agentbox/core'; +import { answerMapAsker, collectAsker, UnansweredPromptError } from '../lib/prompts/askers'; + +/** + * The two askers that turn a gate into a two-step HTTP conversation. Pure: no + * filesystem, no backend. + */ +const REQUIRED: PromptRequest = { + id: 'carry:abc123', + topic: 'carry', + kind: 'select', + title: 'Copy 2 host entries into the box?', + fallback: { value: 'cancel', reason: 'nobody could approve the copy' }, + required: true, + nonInteractiveHint: 'Set AGENTBOX_CARRY_YES=1 to opt in.', +}; + +const OPTIONAL: PromptRequest = { + id: 'model-auth:def456', + topic: 'model-auth', + kind: 'select', + title: 'Seed this openclaw box with a model provider login?', + fallback: { value: 'none', reason: 'not asked' }, +}; + +describe('collectAsker', () => { + it('records the question and answers it with its own fallback', async () => { + const { ask, collected } = collectAsker(); + expect(await ask(REQUIRED)).toEqual({ id: REQUIRED.id, value: 'cancel' }); + expect(await ask(OPTIONAL)).toEqual({ id: OPTIONAL.id, value: 'none' }); + // Answering with the fallback is what lets the gate run to completion, so + // one pass yields every question it would have asked. + expect(collected.map((p) => p.topic)).toEqual(['carry', 'model-auth']); + }); +}); + +describe('answerMapAsker', () => { + it('replays a matching answer', async () => { + const ask = answerMapAsker([{ id: REQUIRED.id, value: 'approve' }]); + expect(await ask(REQUIRED)).toEqual({ id: REQUIRED.id, value: 'approve' }); + }); + + it('falls back for an unanswered optional prompt', async () => { + expect(await answerMapAsker([])(OPTIONAL)).toEqual({ id: OPTIONAL.id, value: 'none' }); + // An older client that knows nothing about a newly added prompt must still + // be able to create a box. + expect(await answerMapAsker(undefined)(OPTIONAL)).toEqual({ id: OPTIONAL.id, value: 'none' }); + }); + + it('refuses an unanswered required prompt, naming the escape hatch', () => { + expect(() => answerMapAsker([])(REQUIRED)).toThrow(UnansweredPromptError); + expect(() => answerMapAsker([])(REQUIRED)).toThrow(/AGENTBOX_CARRY_YES=1/); + }); + + it('treats a stale id as no answer at all', () => { + // The question changed between the preflight and the create (agentbox.yaml + // was edited), so the old answer names a different question. Applying it is + // exactly the mistake the content-addressed id exists to prevent. + const ask = answerMapAsker([{ id: 'carry:staleaaa', value: 'approve' }]); + expect(() => ask(REQUIRED)).toThrow(UnansweredPromptError); + }); +}); diff --git a/apps/hub/test/provider-origin.test.ts b/apps/hub/test/provider-origin.test.ts index bf64c5a81..ba9e42e02 100644 --- a/apps/hub/test/provider-origin.test.ts +++ b/apps/hub/test/provider-origin.test.ts @@ -8,13 +8,18 @@ function local(): ProviderOption[] { { id: 'remote-docker', label: 'Remote Docker', configured: true }, // This host has hetzner baked and e2b not — neither of which a create on the // control box would consult. - { id: 'hetzner', label: 'Hetzner', configured: true, hasCredentials: true, baseStatus: 'stale' }, + { + id: 'hetzner', + label: 'Hetzner', + configured: true, + hasCredentials: true, + baseStatus: 'stale', + }, { id: 'e2b', label: 'E2B', configured: false, hasCredentials: false }, ]; } -const byId = (rows: ProviderOption[], id: string): ProviderOption => - rows.find((r) => r.id === id)!; +const byId = (rows: ProviderOption[], id: string): ProviderOption => rows.find((r) => r.id === id)!; describe('mergeRemoteProviders', () => { it('leaves everything local when no control box is configured', () => { @@ -41,7 +46,11 @@ describe('mergeRemoteProviders', () => { configured: false, }); expect(byId(rows, 'hetzner').baseStatus).toBeUndefined(); - expect(byId(rows, 'e2b')).toMatchObject({ origin: 'hub', configured: true, baseStatus: 'fresh' }); + expect(byId(rows, 'e2b')).toMatchObject({ + origin: 'hub', + configured: true, + baseStatus: 'fresh', + }); }); it('keeps the local label so the picker reads consistently', () => { @@ -53,7 +62,11 @@ describe('mergeRemoteProviders', () => { }); it('reports unknown — never this host’s state — when the control box is unreachable', () => { - const rows = mergeRemoteProviders({ local: local(), remote: null, hubUrl: 'https://hub.example' }); + const rows = mergeRemoteProviders({ + local: local(), + remote: null, + hubUrl: 'https://hub.example', + }); const hetzner = byId(rows, 'hetzner'); // Locally this row is `configured: true`. Showing that under a "control box" // label would be a claim about a machine we did not reach. diff --git a/apps/hub/test/validate.test.ts b/apps/hub/test/validate.test.ts index 5535a9808..7f62a162c 100644 --- a/apps/hub/test/validate.test.ts +++ b/apps/hub/test/validate.test.ts @@ -126,6 +126,32 @@ describe('parseCreateBox', () => { expect(r.value.opts?.carry).toHaveLength(1); }); + it('threads promptAnswers and borrowCredentials through', () => { + const r = parseCreateBox({ + projectId: 'p', + agent: 'claude', + opts: { + promptAnswers: [{ id: 'carry:abc123', value: 'approve' }], + borrowCredentials: ['codex'], + }, + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.value.opts?.promptAnswers).toEqual([{ id: 'carry:abc123', value: 'approve' }]); + // `borrowCredentials` was read by the backend but never parsed here, so the + // key was dead on the wire. + expect(r.value.opts?.borrowCredentials).toEqual(['codex']); + }); + + it('rejects a malformed promptAnswers entry', () => { + const bad = (promptAnswers: unknown) => + parseCreateBox({ projectId: 'p', agent: 'none', opts: { promptAnswers } }).ok; + expect(bad('approve')).toBe(false); + expect(bad([{ id: 'carry:abc' }])).toBe(false); + expect(bad([{ value: 'approve' }])).toBe(false); + expect(bad([{ id: 'carry:abc', value: 5 }])).toBe(false); + }); + it('rejects a wrong-typed opts field and a bad gitPushMode', () => { expect(parseCreateBox({ projectId: 'p', agent: 'none', opts: { image: 5 } }).ok).toBe(false); expect( diff --git a/apps/web/content/docs/agentbox-yaml.mdx b/apps/web/content/docs/agentbox-yaml.mdx index e37cc1a2d..f06a5cfaf 100644 --- a/apps/web/content/docs/agentbox-yaml.mdx +++ b/apps/web/content/docs/agentbox-yaml.mdx @@ -345,6 +345,14 @@ agentbox claude --carry skip You can also set `AGENTBOX_CARRY_YES=1` or `AGENTBOX_CARRY=skip`. Note that `-y`/`--yes` does **not** auto-approve carry — a non-TTY `-y` with non-empty entries fails loud and asks for the explicit env var. `agentbox fork` is the exception: it sends carry by default (opt out with `agentbox fork --carry skip`). +### Approving carry outside the terminal + +The same approval happens when you create a box from the **hub web UI** or the **macOS tray**: before anything is provisioned they ask the hub what the create needs approved (`POST /api/v1/projects/{id}/create-preflight`), and show the file table — each src→dest with size, mode, and symlink warnings — as a question you answer in the UI. The answers ride back with the create as `opts.promptAnswers`. + +A client that skips that step does not silently drop your files: carry has no safe default answer, so the create is refused with the same message the terminal gives you. Answers are content-addressed to the question, so one collected against a `carry:` block that has since changed is refused rather than applied to the new one. + +One case genuinely cannot ask: a **remote control box** has no checkout of your project and cannot read the files a `carry:` block names. It reports that (`unavailable`) and the UI tells you, instead of building a box quietly missing them. Create from the CLI on the machine that holds the files, which uploads the approved material with the create. + `carry` deliberately bypasses `.gitignore` and copies host files into the box. Treat the box as the untrusted side and only carry what the agent genuinely needs (credentials, env files). The diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 3f5569304..0bb59d64b 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -128,13 +128,13 @@ Checkpoints are **durable per-project assets** — a docker image or cloud snaps ### Projects -| Method + path | Description | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /projects` | Registered projects (create targets). Each carries `currentBranch` (the default base) and `needsSetup` (no `agentbox.yaml` + no default snapshot). | -| `POST /projects` | Register a folder as a project, or create one. Exactly one body: `{ path }` (absolute, existing folder) registers it; `{ parent, name, git? }` creates `/` — empty by default, or with `git: true` a repo on `main` with a `.gitignore` (`.agentbox/`) and an initial commit — and registers it. A create is refused when the target exists or when `parent` sits inside another project (its `agentbox.yaml` would claim the new folder). Both answer `{ ok, id, path }`. | -| `GET /projects/{id}/branches` | The project's branches (local + remote) and current HEAD, for the create base-branch picker: `{ current, branches }`. | -| `GET /projects/{id}/seed` | The project's seed / custody status on a control box — what `agentbox hub project push` stored (untracked + env/secret tarballs + manifest): `{ custodyAvailable, seed }` as paths, hashes and timestamps only, never seed contents. `custodyAvailable: false` on a hub that is not a control box. | -| `DELETE /projects/{id}` | Unregister an **empty** project (0 boxes). Folder/files on disk are untouched; `409` if it still has boxes. | +| Method + path | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /projects` | Registered projects (create targets). Each carries `currentBranch` (the default base) and `needsSetup` (no `agentbox.yaml` + no default snapshot). | +| `POST /projects` | Register a folder as a project, or create one. Exactly one body: `{ path }` (absolute, existing folder) registers it; `{ parent, name, git? }` creates `/` — empty by default, or with `git: true` a repo on `main` with a `.gitignore` (`.agentbox/`) and an initial commit — and registers it. A create is refused when the target exists or when `parent` sits inside another project (its `agentbox.yaml` would claim the new folder). Both answer `{ ok, id, path }`. | +| `GET /projects/{id}/branches` | The project's branches (local + remote) and current HEAD, for the create base-branch picker: `{ current, branches }`. | +| `GET /projects/{id}/seed` | The project's seed / custody status on a control box — what `agentbox hub project push` stored (untracked + env/secret tarballs + manifest): `{ custodyAvailable, seed }` as paths, hashes and timestamps only, never seed contents. `custodyAvailable: false` on a hub that is not a control box. | +| `DELETE /projects/{id}` | Unregister an **empty** project (0 boxes). Folder/files on disk are untouched; `409` if it still has boxes. | ### Providers @@ -242,6 +242,49 @@ fields mirror the CLI's `agentbox create` flags: `agent`, `provider`, `name`, `prompt`, `agentArgs`, `fromBranch`, and an `opts` object for box-shaping knobs (`image`, `snapshot`, `size`, `location`, `envFiles`, `carry`, …). +### Questions a create has to ask first + +Some creates need a human decision before anything is provisioned: whether to +copy the host files a project's [`carry:`](/docs/agentbox-yaml#carry) block +names, or whether to seed a service agent with a host login. `POST +/projects/{id}/create-preflight` returns those questions; you render them, and +send the answers back with the create. + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"agent":"claude","provider":"docker"}' \ + http://127.0.0.1:8787/api/v1/projects//create-preflight +# -> { "prompts": [ { "id": "carry:98310458c334", "topic": "carry", "kind": "select", +# "title": "Copy 15 host entries into the box?", +# "choices": [ ... ], "detail": { "type": "file-table", ... }, +# "required": true, "fallback": { "value": "cancel", ... } } ], +# "unavailable": [] } + +curl -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"projectId":"","agent":"claude","opts":{"promptAnswers":[ + {"id":"carry:98310458c334","value":"approve"}]}}' \ + http://127.0.0.1:8787/api/v1/boxes +``` + +A `PromptRequest` has two halves. The generic one — `kind` (`confirm` / +`select` / `text`), `title`, `body`, `choices` — is enough to render and answer +**any** prompt, including a `topic` your client has never seen, so the hub can +add a question without breaking you. `detail` is the optional half a client +_may_ draw richly (`file-table`, `credential`); every variant carries a plain +`summary`, so render that for one you do not recognise. + +The list comes from running the real gates, so it can never differ from what the +create asks. Skipping the endpoint is allowed — every prompt has a `fallback` — +except for one marked `required` (`carry:`, whose silent answer would move host +secrets): a create that leaves that unanswered is refused, not defaulted. Ids +are content-addressed over the question, so an answer collected before the +project's `agentbox.yaml` changed no longer matches and is refused too. + +`unavailable` names each gate this hub cannot run and why. A control box has no +local checkout, so it can read neither your `agentbox.yaml` nor your host +logins; it says so rather than returning an empty list that looks like "nothing +to ask". + A box being built shows up in `GET /boxes` with `status: "creating"` right away, then flips to `running` once the container is up. `GET /jobs` lists every create job (the unified queue view behind `agentbox queue list` / `hub jobs`); diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index e53db0a2e..1b1b1586e 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -89,7 +89,7 @@ agentbox openclaw restart [box] agentbox openclaw stop [box] # stop the daemon; the box keeps running ``` -It takes the create flags a box still needs — `-w`, `-n`, `-p/--provider`, `--image`, `--snapshot`, `-y`, `--carry-yes`, `--carry `, `--verbose` — plus `--timeout ` (how long to wait for the service to report ready, default 180). A service agent's box is [persistent](/docs/checkpoints-and-pausing#persistent-boxes) by default: it hosts a daemon, so an autopause or an idle lapse would be an outage. Pass `--no-persistent` for an expendable one. That default also means `--provider e2b` / `--provider vercel` are **refused** — their platform session cap makes an always-on box impossible — so pick a provider without one, or opt out with `--no-persistent`. See [OpenClaw](/docs/openclaw). +It takes the create flags a box still needs — `-w`, `-n`, `-p/--provider`, `--image`, `--snapshot`, `-y`, `--carry-yes`, `--carry `, `--verbose` — plus `--timeout ` (how long to wait for the service to report ready, default 180) and `--model-auth ` (`none` | `codex`: seed the box with your Codex login as its model provider; default: the `openclaw.modelAuth` key, else ask on a TTY). A service agent's box is [persistent](/docs/checkpoints-and-pausing#persistent-boxes) by default: it hosts a daemon, so an autopause or an idle lapse would be an outage. Pass `--no-persistent` for an expendable one. That default also means `--provider e2b` / `--provider vercel` are **refused** — their platform session cap makes an always-on box impossible — so pick a provider without one, or opt out with `--no-persistent`. See [OpenClaw](/docs/openclaw). See [Run an agent](/docs/run-an-agent), [Teleport a project](/docs/teleport-a-project), [Background & parallel](/docs/background-and-parallel), and [Sync & git](/docs/sync-and-git). diff --git a/apps/web/content/docs/configuration.mdx b/apps/web/content/docs/configuration.mdx index 1d2e1f926..1fa5dd156 100644 --- a/apps/web/content/docs/configuration.mdx +++ b/apps/web/content/docs/configuration.mdx @@ -252,10 +252,11 @@ Pi ships with no permission prompts at all). An agent can also declare **settings of its own**, and those become config keys in the same block. Claude declares two: -| Key | Type | Default | Meaning | -| ---------------- | ---------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `claude.install` | enum `native`/`npm` | `native` | how Claude Code is installed into a box: `native` runs Anthropic's installer, `npm` installs `@anthropic-ai/claude-code`. A fallback for cloud egress IPs the native installer's CDN 403s. Bake-time — changing it re-derives the agent layer (the agentless base is unaffected). Override per-bake with `prepare --agent-setting claude.install=npm` | -| `claude.tui` | enum `default`/`fullscreen`/`auto` | `default` | terminal renderer Claude Code uses **inside a box**. Claude's `fullscreen` renderer repaints differentially and leaves stale characters in the blank areas of the screen over a network transport — visible while scrolling, cleared only by resizing the terminal. Boxes pin the classic renderer; set `fullscreen` to opt back in, or `auto` to let Claude decide. Rides the agent's next launch | +| Key | Type | Default | Meaning | +| -------------------- | ---------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude.install` | enum `native`/`npm` | `native` | how Claude Code is installed into a box: `native` runs Anthropic's installer, `npm` installs `@anthropic-ai/claude-code`. A fallback for cloud egress IPs the native installer's CDN 403s. Bake-time — changing it re-derives the agent layer (the agentless base is unaffected). Override per-bake with `prepare --agent-setting claude.install=npm` | +| `claude.tui` | enum `default`/`fullscreen`/`auto` | `default` | terminal renderer Claude Code uses **inside a box**. Claude's `fullscreen` renderer repaints differentially and leaves stale characters in the blank areas of the screen over a network transport — visible while scrolling, cleared only by resizing the terminal. Boxes pin the classic renderer; set `fullscreen` to opt back in, or `auto` to let Claude decide. Rides the agent's next launch | +| `openclaw.modelAuth` | enum `none`/`codex` | `none` | which host login a new OpenClaw box is seeded with as its model provider. `codex` copies your Codex (ChatGPT) OAuth login into the box, where OpenClaw imports it and refreshes it independently from then on; `none` leaves model auth to you. `--model-auth` overrides per create; with neither set, an interactive create asks. See [OpenClaw](/docs/openclaw#model-provider) | These are deliberately **not** generic keys. Which installer Claude Code uses and which of its two renderers it picks are facts about Claude, not about agents in diff --git a/apps/web/content/docs/openclaw.mdx b/apps/web/content/docs/openclaw.mdx index e310995c6..cf847c069 100644 --- a/apps/web/content/docs/openclaw.mdx +++ b/apps/web/content/docs/openclaw.mdx @@ -61,7 +61,7 @@ inside the box (in there the gateway is `http://localhost:18789`). Re-read it wi | `agentbox openclaw restart [box]` | Restart the gateway | | `agentbox openclaw stop [box]` | Stop the gateway; the box keeps running | -The create form takes the box flags you already know — `-w`, `-n`, `-p/--provider`, `--image`, `--snapshot`, `-y`, `--carry-yes`, `--verbose` — plus `--timeout ` for how long to wait for the gateway to report ready (default 180). +The create form takes the box flags you already know — `-w`, `-n`, `-p/--provider`, `--image`, `--snapshot`, `-y`, `--carry-yes`, `--verbose` — plus `--timeout ` for how long to wait for the gateway to report ready (default 180), and `--model-auth ` to seed a model-provider login (see [Model provider](#model-provider)). OpenClaw's box is a [persistent box](/docs/checkpoints-and-pausing#persistent-boxes): never @@ -84,11 +84,37 @@ Nothing here is OpenClaw-specific code. The units below are declared on OpenClaw 1. **Install, on demand.** `npm i -g openclaw` runs into the live box the first time. It is **not** baked into any base image: OpenClaw is ~893 MB installed, a ~29% increase on the base image, and baking it would stale every provider's snapshot. 2. **`openclaw-onboard`** — a one-shot unit running `openclaw onboard --non-interactive --accept-risk --mode local --skip-channels --skip-health --no-install-daemon`. It writes the factory config, sets the agent workspace to `/workspace`, and **generates this box's gateway identity and auth token**. It is marked run-once, so a restart never replaces that identity. -3. **`openclaw-render`** — `agentbox-ctl agent render openclaw`, which applies your `openclaw:` overlay (below). -4. **`openclaw`** — `openclaw gateway`, `restart: always`, ready when `http://127.0.0.1:18789/healthz` answers, published on the box's web port. +3. **`openclaw-model-auth`** — imports a borrowed model login, if the create seeded one (see [Model provider](#model-provider)). A no-op otherwise. +4. **`openclaw-render`** — `agentbox-ctl agent render openclaw`, which applies your `openclaw:` overlay (below). +5. **`openclaw`** — `openclaw gateway`, `restart: always`, ready when `http://127.0.0.1:18789/healthz` answers, published on the box's web port. The gateway binds **loopback only**. AgentBox forwards the box's `:80` to `127.0.0.1:18789` inside the same container, so the Control UI is reachable without ever widening the bind — which is both sufficient and safer than exposing the daemon on `0.0.0.0`. +## Model provider + +A fresh box has a gateway identity and **no model provider**: onboarding runs with auth skipped, so the first turn fails until OpenClaw is given a login. Two ways to give it one. + +**Borrow your Codex login.** `agentbox openclaw --model-auth codex` seeds the box with the Codex (ChatGPT subscription) OAuth login your host already holds, and OpenClaw runs on it — the same login `agentbox codex` boxes use. OpenAI explicitly permits that OAuth in external tools, and its shape maps one-to-one onto OpenClaw's OpenAI profile. + +```bash +agentbox openclaw --model-auth codex # this create +agentbox config set openclaw.modelAuth codex # every create; `none` to stop asking +``` + +With neither set, an interactive create asks — defaulting to **No** — whenever your host holds a Codex login. `-y` and a non-TTY never ask and seed nothing. + +Creating an OpenClaw box from the **hub web UI** or the **macOS tray** asks the same question, as a card showing which login it is and where it would land (`~/.codex/auth.json` on the host → `~/.codex/auth.json` in the box). Declining is the default there too, and a client that does not ask simply gets `none` — this question, unlike `carry:`, has a safe silent answer. + +What happens is ordinary AgentBox machinery plus one unit on OpenClaw's row: + +- The host copies the login to **codex's own credential path** in the box (`~/.codex/auth.json`, 0600), the same place a runtime install of codex would put it, at the same step your `carry:` files land — before the first supervisor task runs. Nothing about OpenClaw's auth format lives on the host. +- **`openclaw-model-auth`** then installs the official `@openclaw/codex` plugin (the harness OpenClaw's default model already runs on; ~15s, once per box) and runs `openclaw migrate apply codex --item auth:openai`, OpenClaw's own import of a Codex home. It records a hash of the seeded file and does nothing on later boots unless the file changes. +- From here OpenClaw **owns the profile** and refreshes it in its own store. OpenAI does not invalidate the previous refresh token on rotation — measured with two boxes seeded from one host file, both refreshing independently while the host kept working — so the box is an independent session and the seed is never read again. A later `codex login` on the host reaches the box through the normal credential fan-out and re-imports only if the file changed. + +Borrowing is one-way: the box is a consumer of that login, never a source. It is not extracted back, not reconciled on resume, and a clone borrows the same login afresh from the host rather than copying the source box's session. + +**An API key.** `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the per-box env file OpenClaw loads (`~/.agentbox/openclaw/.env` on the host → `~/.openclaw/.env` in the box, see [Channels](#channels)) is read by the gateway with no other configuration; set the model in the overlay below. Claude's own subscription login is deliberately **not** borrowable: OpenClaw's refresh would rotate that token and log out the host and every claude box. + ## Configuring it: the `openclaw:` overlay Put gateway config in a top-level `openclaw:` block in your project's `agentbox.yaml`. It is an **overlay**, not the file: `agentbox-ctl agent render openclaw` sends it to OpenClaw's own `openclaw config patch --stdin`, which performs the recursive merge and validates as it goes. @@ -171,14 +197,15 @@ cloned box from inheriting the facts of the box it was cloned from. ## State, sync and clones -| Path in the box | What it is | Travels? | -| --------------------------------- | ------------------------------- | -------------------------------- | -| `~/.openclaw/openclaw.json` | Config **and gateway identity** | `--backup` / `--restore` only | -| `~/.openclaw/state/*.sqlite*` | Live gateway state | `--backup` / `--restore` only | -| `~/.openclaw/tmp/openclaw-/` | Lock databases | Never | -| `~/.openclaw/agents/` | Your agent definitions | Both directions | -| `~/.config/openclaw` | Auth-profile key | Stays in the box's config volume | -| `~/.openclaw/.env` | Channel tokens | Carried per box, never copied | +| Path in the box | What it is | Travels? | +| --------------------------------- | ------------------------------- | ------------------------------------- | +| `~/.openclaw/openclaw.json` | Config **and gateway identity** | `--backup` / `--restore` only | +| `~/.openclaw/state/*.sqlite*` | Live gateway state | `--backup` / `--restore` only | +| `~/.openclaw/tmp/openclaw-/` | Lock databases | Never | +| `~/.openclaw/agents/` | Your agent definitions | Both directions | +| `~/.config/openclaw` | OpenClaw's XDG config dir | Stays in the box's config volume | +| `~/.openclaw/.env` | Channel tokens | Carried per box, never copied | +| `~/.codex/auth.json` | Borrowed Codex login (seed) | Seeded from the host, never read back | `agentbox download openclaw [box]` pulls the agent definitions back to your host, additively — an item the host already has is never overwritten. There is deliberately no `--propagate`: an OpenClaw box is a tenant, and copying one tenant's definitions into another's gateway is not a sensible default. diff --git a/docs/agents-remaining-work.md b/docs/agents-remaining-work.md index c47795441..eba8b156d 100644 --- a/docs/agents-remaining-work.md +++ b/docs/agents-remaining-work.md @@ -199,6 +199,7 @@ row — the mechanism it was waiting on exists. See [`agents.md`](./agents.md) and never was — an agent is absent from a bake unless `--agents` names it, and `ensureAgentInstalled` puts it in on demand. Nothing to declare.) -OpenClaw is also the obvious first user of `AgentSyncSpec.settings` if it needs -anything configured per host, and of the pull hook if its state is not a plain -file tree. +OpenClaw is now the second user of `AgentSyncSpec.settings` (`openclaw.modelAuth`, +which host login a new box borrows as its model provider — see +[`service-agent-model-auth-plan.md`](./service-agent-model-auth-plan.md)), and +would be the first user of the pull hook if its state were not a plain file tree. diff --git a/docs/agents.md b/docs/agents.md index 350868410..9182f2dd3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -500,6 +500,29 @@ name; the render lints for a secret-shaped literal and warns. everything else about it is unchanged. Absent is not a placeholder for "not wired up yet" — if the agent has a host-side login, declare it. + **`modelAuth` is the other direction: a login the agent CONSUMES.** A service + agent that speaks to a model provider with the user's Codex login declares + `modelAuth: { borrows: [{ agent: 'codex', label }], ingestTask }` — which + other agents' host-held `credential`s it may borrow, and the `service.tasks` + entry that turns the borrowed file into its own store. The host's whole job + is deciding WHICH login enters the box (`--model-auth`, the generated + `.modelAuth` key, or a TTY prompt defaulting to no) and landing it at + the LENDER's own `credential.boxAbsPath`, 0600, through the carry step every + provider already runs before the first supervisor task. AgentBox never learns + the consumer's auth format: openclaw's `openclaw-model-auth` installs the + official `@openclaw/codex` plugin and runs `openclaw migrate apply codex + --item auth:openai`, gated on a hash of the seed so a later boot is a no-op. + Borrowing is one-way — `box.agents` still gates extraction, the resume + reconcile and the watch, so the daemon's own refreshed chain never flows + back over the host's — and it is recorded as `BoxRecord.borrowedCredentials` + so the fan-out reaches the box (push to the canonical path, re-run the + ingest) and a clone borrows the same set afresh. Measured before it was + designed: OpenAI does not invalidate a prior refresh token on rotation, so + each seeded box is an independent session and no reverse sync is needed. + Claude's live OAuth blob is deliberately NOT borrowable — a refresh by the + consumer rotates the refresh token and logs out the host and every claude + box. See `docs/service-agent-model-auth-plan.md`. + **`clone` is optional, and only a SERVICE agent has ever needed it.** It answers "what must differ when a second instance of this agent is spawned from the same workspace?", in three lists: @@ -585,7 +608,8 @@ name; the render lints for a secret-shaped literal and warns. reporting. 6b. **If the agent is a SERVICE**, steps 2, 3 and 6 collapse: declare `caps.surface: 'service'` plus a `service` block (and `configRender` if its - tool ships a patch command), skip the `AGENT_MODULES` arm and the `src/cli/` + tool ships a patch command, `modelAuth` if it consumes another agent's + login), skip the `AGENT_MODULES` arm and the `src/cli/` tree entirely, and add `surface: 'service'` to its `AGENT_KINDS` row so no `box.isolateConfig` key is generated. The CLI command comes from `buildServiceAgentCommand` off the registry row. What remains is the docker diff --git a/docs/plans/service-boxes-backlog.md b/docs/plans/service-boxes-backlog.md index cf85e02ce..f4692901b 100644 --- a/docs/plans/service-boxes-backlog.md +++ b/docs/plans/service-boxes-backlog.md @@ -13,8 +13,10 @@ here rather than sidequesting. Promote an item to the plan if it turns out to be on-demand install is the default for good reason. - **Channel pairing is unverified.** `openclaw channels add --use-env` and *which* dirs a pairing needs to survive a restart both need a real credential. Do it when a channel is first wired. -- **`~/.config/openclaw` is empty after onboard.** It is persisted on the assumption it holds the - auth-profile encryption key; confirm when auth profiles are actually used. +- **`~/.config/openclaw` is empty after onboard — and stays empty once auth is in use.** Measured + on 2026.9.3 with a Codex login imported: the auth store is + `~/.openclaw/agents//agent/openclaw-agent.sqlite`, and the XDG dir gained nothing. The + relocation is harmless (one mount, no second dir to lose) but is no longer load-bearing for auth. ## From Phase 7 (deferred, needs real money / shared state) diff --git a/docs/service-agent-model-auth-plan.md b/docs/service-agent-model-auth-plan.md new file mode 100644 index 000000000..64377a962 --- /dev/null +++ b/docs/service-agent-model-auth-plan.md @@ -0,0 +1,90 @@ +# Service-agent model auth: borrowing the host's Codex login + +**Status: done** (2026-09-09). Codex only; Anthropic out of scope by decision. + +An `agentbox openclaw` box used to come up with a gateway identity and no model +provider. It can now be seeded with the Codex (ChatGPT subscription OAuth) +login the host already holds: `--model-auth codex`, the `openclaw.modelAuth` +config key, or a TTY prompt defaulting to no. + +## The shape + +One new registry field, `AgentSyncSpec.modelAuth`: + +```ts +modelAuth: { + borrows: [{ agent: 'codex', label: 'your Codex login (ChatGPT subscription OAuth)' }], + ingestTask: 'openclaw-model-auth', +} +``` + +| who | does what | +|---|---| +| host, at create | validates the request against `borrows` (`resolveBorrowedCredentials`), picks the freshest valid host file (`resolveHostCredentialFile`: freshness rule if declared, else mtime), and lands it at the LENDER's own `credential.boxAbsPath`, 0600, as one more carry entry (`borrowedCredentialCarry`) | +| box, first boot | `openclaw-model-auth` (a `service.tasks` entry on OpenClaw's row) installs the official `@openclaw/codex` plugin if absent and runs `openclaw migrate apply codex --from ~/.codex --include-secrets --item auth:openai --yes`, then records `sha256(seed)` in `~/.openclaw/.agentbox-model-auth.sha256` | +| box, later boots | same task, no-op while the seed's hash matches the marker | +| host, fan-out | `planPropagateTargets` lists boxes whose `borrowedCredentials` includes the agent; `credentials propagate` pushes over the transport and re-runs the ingest task, which re-imports only if the file changed | +| clone | `prepareClone` carries the source's `borrowedCredentials`; the clone borrows afresh from the host, never the source box's session | + +AgentBox never learns OpenClaw's auth format. Borrowing is one-way: `box.agents` +still gates extraction, the resume reconcile and the credential watch. + +Docker's carry step moved ahead of the ctl daemon launch, where its own +comment always said it belonged: after the daemon, a first-boot task that reads +a carried file raced it. Cloud creates already ran carry before the bootstrap. + +## What the PoC measured (openclaw 2026.9.3 in a box, 2026-09-09) + +1. **The auth store is SQLite** (`agents//agent/openclaw-agent.sqlite`). + The retired `auth-profiles.json` / `credentials/oauth.json` are never read + at runtime; `doctor --fix` migrates them once. Writing one is not a seam. +2. **`migrate apply codex --item auth:openai` is the supported import**, and + lives in the official `@openclaw/codex` plugin from ClawHub (not stock). + That plugin is also the harness the fresh onboard's default model + (`openai/gpt-5.6-sol`) already points at, so installing it completes the + default rather than changing it. ~15s, once per box, into the config volume. +3. **A bare `~/.codex/auth.json` is a trap.** `models status` shows a + bootstrapped `openai:default`, `source: store`, status `ok` — and a turn on + it fails with `selected_auth_profile_unavailable`. OpenClaw's status view + therefore cannot say "already imported"; the gate is the seed's hash. +4. **Rotation does not invalidate the previous refresh token.** Box 1 imported + the host file and refreshed (R0 -> R1). Box 2 imported the same host file + thirteen minutes later and refreshed from R0 successfully (R0 -> R2). Box 1 + then refreshed again (R1 -> R3). Every chain, the host's included, stayed + valid. So a seeded box is an independent session; no reverse sync, no + freshness fan-out is needed for correctness. +5. **The gateway needs no restart** on first boot: the ingest task is ordered + before the gateway (`needs`), so the plugin it installs is loaded on the + gateway's first start. +6. **The import applies on every run** (no built-in idempotency), which is the + other reason for the hash gate: a re-import would replace the box's own + newer chain with the seed's. + +## Deliberately not done + +- Borrowing claude's `.credentials.json`: a refresh by the consumer rotates the + refresh token and logs out the host and every claude box (`docs/agents.md`, + "rotates the refresh token"). API keys already reach the gateway through the + 0600 per-box `.env` carry. +- Any token in the `openclaw:` overlay or in container env; `forwardedEnvKeys` + stays empty. +- Mounting codex's config volume into the OpenClaw box. +- Re-seeding on `start` / resume: the box owns its session after import. + +## Verified live + +- `agentbox openclaw -n oc-borrow --model-auth codex -y`: seed logged, plugin + installed, import ran, marker written, gateway ready, `openclaw agent + --message` answered on `gpt-5.6-sol` via the Codex subscription. +- Warm boot: task logs "already imported", 14ms. +- `agentbox credentials propagate --agent codex`: `pushed codex credential to + oc-borrow (borrowed)`, ingest re-run, no-op on identical content. +- `BoxRecord.borrowedCredentials` and `GET /api/v1/boxes[].borrowedCredentials` + both `['codex']`. +- The TTY prompt renders with No selected (drive harness). + +## Known gap + +`agentbox openclaw -n ` reuses the project's existing OpenClaw box instead +of creating the named one (`findExistingBox` matches on workspace + agent and +ignores `-n`). Pre-existing; noted, not changed here. diff --git a/packages/agent-claude/src/host-stage.ts b/packages/agent-claude/src/host-stage.ts index f6d805aef..62a004330 100644 --- a/packages/agent-claude/src/host-stage.ts +++ b/packages/agent-claude/src/host-stage.ts @@ -24,6 +24,7 @@ import { mkStageDir, pathExists, resolveAgentSpec, + STAGE_NO_SPECIALS, STAGE_WRITABLE_CHMOD, stageSingleFileTarball, tarballFromDir, @@ -194,6 +195,7 @@ export async function stageClaudeStaticForUpload( await execa('rsync', [ '-a', STAGE_WRITABLE_CHMOD, + STAGE_NO_SPECIALS, '--copy-unsafe-links', ...excludes, `${hostClaude}/`, diff --git a/packages/agent-codex/src/host-stage.ts b/packages/agent-codex/src/host-stage.ts index 491499c34..246e36b6c 100644 --- a/packages/agent-codex/src/host-stage.ts +++ b/packages/agent-codex/src/host-stage.ts @@ -24,6 +24,7 @@ import { mkStageDir, pathExists, resolveAgentSpec, + STAGE_NO_SPECIALS, STAGE_WRITABLE_CHMOD, stageSingleFileTarball, tarballFromDir, @@ -146,6 +147,7 @@ export async function stageCodexStaticForUpload( await execa('rsync', [ '-a', STAGE_WRITABLE_CHMOD, + STAGE_NO_SPECIALS, '-L', ...codexBroken.map((r) => `--exclude=/${r}`), ...CODEX_STATIC_INCLUDES.map((p) => `--include=${p}`), diff --git a/packages/agent-openclaw/test/spec.test.ts b/packages/agent-openclaw/test/spec.test.ts index db7e2355a..f9e1a2d4c 100644 --- a/packages/agent-openclaw/test/spec.test.ts +++ b/packages/agent-openclaw/test/spec.test.ts @@ -40,19 +40,24 @@ describe('openclaw registry row', () => { expect(text).not.toContain('AGENTBOX_AUTO_SECRET'); }); - it('onboards once, seeds the box context, renders the overlay, then starts', () => { + it('onboards once, imports model auth, seeds the box context, renders the overlay, then starts', () => { const tasks = SPEC.service?.tasks ?? []; const onboard = tasks.find((t) => t.name === 'openclaw-onboard'); + const modelAuth = tasks.find((t) => t.name === 'openclaw-model-auth'); const env = tasks.find((t) => t.name === 'openclaw-agentbox-env'); const render = tasks.find((t) => t.name === 'openclaw-render'); // `runOnce` is what keeps a warm boot from re-onboarding and replacing the // identity the box already has. expect(onboard?.runOnce).toBe('marker'); expect(onboard?.command).toContain('--non-interactive'); - // The AgentBox-owned keys go in BETWEEN: after onboard wrote the config file - // they patch, and before the render, so the user's own overlay is the last - // word on any key both of them name. - expect(env?.needs).toEqual(['openclaw-onboard']); + // The borrowed-login import and the AgentBox-owned keys go in BETWEEN, in + // that order: after onboard wrote the config file they patch, serialized + // with one another (both read-modify-write openclaw.json through openclaw's + // own commands), and before the render, so the user's own overlay is the + // last word on any key they name. + expect(modelAuth?.needs).toEqual(['openclaw-onboard']); + expect(modelAuth?.runOnce).toBeUndefined(); + expect(env?.needs).toEqual(['openclaw-model-auth']); expect(render?.needs).toEqual(['openclaw-agentbox-env']); expect(SPEC.service?.needs).toEqual(['openclaw-render']); }); @@ -102,6 +107,9 @@ describe('openclaw registry row', () => { for (const name of ['openclaw.json', 'config-journal-fingerprint.key', 'state']) { expect(excludes, name).toContain(name); } + // Per-gateway approval state; the host's older-version copy broke a newer + // cloud box on its first turn. + expect(excludes).toContain('exec-approvals.json*'); // `tmp` holds lock sqlites under a dir keyed by the box user's UID, which // differs per provider (docker 1000, vercel 1001, e2b 1002). expect(excludes).toContain('tmp'); diff --git a/packages/agent-registry/src/specs/openclaw.ts b/packages/agent-registry/src/specs/openclaw.ts index 766e74e6d..2979975b2 100644 --- a/packages/agent-registry/src/specs/openclaw.ts +++ b/packages/agent-registry/src/specs/openclaw.ts @@ -31,6 +31,7 @@ import { BOX_HOME, BOX_USER, IDENTITY_RULES_SENTINEL, agentDirPrelude } from '@agentbox/core'; import type { AgentSyncSpec } from '@agentbox/core'; +import { codexSpec } from './codex.js'; /** OpenClaw's state root: config, sqlite state, per-agent dirs, migrations. */ const OPENCLAW_BOX_DIR = `${BOX_HOME}/.openclaw`; @@ -175,6 +176,85 @@ const IDENTITY_NUDGE_TEXT = [ '', ].join('\n'); +/** + * The model-auth task: turn a borrowed Codex login into OpenClaw's own OpenAI + * OAuth profile. Reads the file the host seeded at codex's OWN credential path + * (`AgentSyncSpec.modelAuth.borrows`), so nothing here knows how the host chose + * or moved it. + * + * Every step below was measured on openclaw 2026.9.3, not read from the plan: + * + * - The auth store is SQLite (`agents//agent/openclaw-agent.sqlite`); the + * retired `auth-profiles.json` / `credentials/oauth.json` are never read at + * runtime, so writing one is not a seam. + * - `openclaw migrate apply codex ... --item auth:openai` is the supported, + * non-interactive import of a Codex CLI home. It lives in the official + * `@openclaw/codex` plugin (ClawHub), which is also the harness the fresh + * onboard's default model (`openai/gpt-5.6-sol`) runs on — so installing it + * completes the default rather than changing it. The install is ~17s and + * lands in the config volume, so it is paid once per box. + * - A bare `~/.codex/auth.json` is enough for `models status` to SHOW a + * bootstrapped `openai:default` — reported as `source: store`, status `ok`, + * indistinguishable from a real row — but a turn on it fails with + * `selected_auth_profile_unavailable`. So OpenClaw's own status is NOT the + * gate for "already imported"; the import is what makes the profile real. + * - The import applies on every run. After it OpenClaw owns the profile and + * refreshes it in its own store, and OpenAI does not invalidate the prior + * refresh token on rotation (two boxes seeded from one host file refreshed + * independently and every chain, the host's included, stayed valid). So a + * re-import is never needed for freshness, and would only replace the box's + * own newer chain with the seed's. + * + * Hence the gate is the SEED itself: a hash of the file, recorded beside + * `.agentbox-overlay.json` once an import succeeds. Unchanged file, no work; + * a re-pushed login (the host logged in again) imports again. Idempotent and + * exit 0 on every "nothing to do": no seeded file, a file that is not a Codex + * login, or one already imported. The gateway is ordered after this task + * (`needs`), so the plugin it installs is loaded on the gateway's first start + * rather than needing a restart. + */ +function buildModelAuthScript(): string { + const auth = codexSpec.credential!.boxAbsPath; + const home = auth.slice(0, auth.lastIndexOf('/')); + return [ + 'set -u', + `auth=${sq(auth)}`, + `marker=${sq(MODEL_AUTH_MARKER)}`, + 'if [ ! -s "$auth" ]; then echo "openclaw-model-auth: no borrowed Codex login at $auth"; exit 0; fi', + // Shape gate, so a half-written or API-key-only file is "nothing to + // ingest" rather than a failed import. + `if ! node -e ${sq(MODEL_AUTH_IS_CODEX_LOGIN)} "$auth"; then echo "openclaw-model-auth: $auth is not a Codex ChatGPT login"; exit 0; fi`, + 'seen=$(sha256sum "$auth" | cut -d" " -f1)', + 'if [ -f "$marker" ] && [ "$(cat "$marker")" = "$seen" ]; then', + ' echo "openclaw-model-auth: this Codex login is already imported"', + ' exit 0', + 'fi', + // The plugin owns both the import command and the harness the default + // model runs on. + `if [ ! -d ${sq(`${OPENCLAW_BOX_DIR}/extensions/codex`)} ]; then`, + ' echo "openclaw-model-auth: installing the @openclaw/codex plugin"', + ' openclaw plugins install clawhub:@openclaw/codex || exit 0', + 'fi', + 'echo "openclaw-model-auth: importing the Codex login into the OpenClaw auth store"', + // `--no-backup --force`: the pre-migration archive would snapshot a state + // dir that is fresh on the boot this runs, and the migration report is + // still written. `--item auth:openai` keeps skills/plugins/config out. + `if openclaw migrate apply codex --from ${sq(home)} --include-secrets --item auth:openai --yes --no-backup --force; then`, + ' printf %s "$seen" > "$marker" && chmod 600 "$marker"', + 'fi', + 'exit 0', + ].join('\n'); +} + +/** Hash of the last seed imported. Beside the overlay record: AgentBox-owned, per box. */ +const MODEL_AUTH_MARKER = `${OPENCLAW_BOX_DIR}/.agentbox-model-auth.sha256`; + +/** argv[1] is the file. Exit 0 iff it is a Codex ChatGPT login with a refresh token. */ +const MODEL_AUTH_IS_CODEX_LOGIN = ` +const j = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); +process.exit(typeof j?.tokens?.refresh_token === 'string' && j.tokens.refresh_token.length > 0 ? 0 : 1); +`; + /** * Teach the box's gateway where it is running. * @@ -318,6 +398,12 @@ export const openclawSpec: AgentSyncSpec = { 'openclaw.json.bak', 'config-journal-fingerprint.key', '.agentbox-overlay.json', + '.agentbox-model-auth.sha256', + // Per-gateway exec-approval state, plus the `.migrated.` archives + // doctor leaves beside it. MEASURED: the host's copy from an older + // openclaw rode the cloud static push into a 2026.9.3 box, and every + // turn there failed with "Legacy exec approvals exist ... run doctor". + 'exec-approvals.json*', 'state', 'migration', 'tmp', @@ -346,6 +432,26 @@ export const openclawSpec: AgentSyncSpec = { // // Channel tokens are real secrets, but they ride a `carry:` entry into a 0600 // env file and the overlay references them by name; AgentBox never holds them. + // + // A MODEL-PROVIDER login is the one host secret it does consume, and that is + // `modelAuth`, not `credential`: the host seeds codex's file at codex's own + // path and the `openclaw-model-auth` task below imports it. Absent + // `credential` plus present `modelAuth.borrows` is the shape for a consumer. + modelAuth: { + borrows: [{ agent: 'codex', label: 'Your Codex login (ChatGPT subscription OAuth)' }], + ingestTask: 'openclaw-model-auth', + }, + settings: [ + { + key: 'modelAuth', + type: 'enum', + enumValues: ['none', 'codex'], + default: 'none', + description: + 'Which host login a new OpenClaw box is seeded with as its model provider. `codex` copies your Codex (ChatGPT) OAuth login into the box, where OpenClaw imports it and refreshes it independently from then on. `none` leaves model auth for you to configure in the box. `--model-auth` overrides per create.', + // Seeded at create, never baked: the file rides the carry step. + }, + ], forwardedEnvKeys: [], boxRunEnv: { // Honoured by `onboard`, which writes it to `agents.defaults.workspace` @@ -391,13 +497,22 @@ export const openclawSpec: AgentSyncSpec = { 'openclaw onboard --non-interactive --accept-risk --mode local ' + '--skip-channels --skip-health --no-install-daemon', }, + { + // The borrowed model login, if the host seeded one. After onboard so + // the agent dir and default model exist; before the gateway so the + // plugin it may install is loaded on the first start. No `runOnce`: + // it decides for itself, and a re-run is a no-op once imported. + name: 'openclaw-model-auth', + command: buildModelAuthScript(), + needs: ['openclaw-onboard'], + }, { // Everything AgentBox owns in this box: the skill root outside the // workspace, the derived box facts inside it, and the two config keys // that make openclaw read both. No `runOnce` — see the builder's doc. name: 'openclaw-agentbox-env', command: buildAgentboxContextScript(), - needs: ['openclaw-onboard'], + needs: ['openclaw-model-auth'], }, { name: 'openclaw-render', diff --git a/packages/agent-registry/test/model-auth.test.ts b/packages/agent-registry/test/model-auth.test.ts new file mode 100644 index 000000000..3fbf4bd1a --- /dev/null +++ b/packages/agent-registry/test/model-auth.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { AGENT_SPECS, findAgentSpec } from '../src/index.js'; + +/** + * `modelAuth` is data three things read without importing one another: the + * host seeds by the LENDER's credential path, the box runs the row's own + * ingest task, and config generates the `.modelAuth` key. Each link + * below is one that fails silently when it drifts. + */ +describe('AgentSyncSpec.modelAuth', () => { + const declaring = AGENT_SPECS.filter((s) => s.modelAuth !== undefined); + + it('is declared by openclaw, borrowing codex', () => { + expect(declaring.map((s) => s.id)).toContain('openclaw'); + expect(findAgentSpec('openclaw')?.modelAuth?.borrows.map((b) => b.agent)).toEqual(['codex']); + }); + + it('every borrow names an agent that has a host-side credential to lend', () => { + for (const spec of declaring) { + for (const b of spec.modelAuth!.borrows) { + const lender = findAgentSpec(b.agent); + expect(lender, `${spec.id} borrows unknown agent ${b.agent}`).toBeDefined(); + expect( + lender!.credential, + `${spec.id} borrows ${b.agent}, which lends nothing`, + ).toBeDefined(); + expect(b.label.length).toBeGreaterThan(0); + } + } + }); + + it('ingestTask names one of the row`s own service tasks', () => { + for (const spec of declaring) { + const names = (spec.service?.tasks ?? []).map((t) => t.name); + expect(names, spec.id).toContain(spec.modelAuth!.ingestTask); + } + }); + + it('the ingest script reads the lender at the lender`s own credential path', () => { + // The host lands the file where a runtime install of the lender would; the + // row must not restate that path by hand. + for (const spec of declaring) { + const task = spec.service!.tasks!.find((t) => t.name === spec.modelAuth!.ingestTask)!; + const script = String(task.command); + for (const b of spec.modelAuth!.borrows) { + expect(script).toContain(findAgentSpec(b.agent)!.credential!.boxAbsPath); + } + } + }); + + it('declares a `modelAuth` enum setting covering none + every borrow, defaulting to none', () => { + // The config key is generated from the setting; the opt-in gate reads it. + for (const spec of declaring) { + const setting = (spec.settings ?? []).find((s) => s.key === 'modelAuth'); + expect(setting, `${spec.id} has no modelAuth setting`).toBeDefined(); + expect(setting!.type).toBe('enum'); + expect(setting!.default).toBe('none'); + expect(setting!.affectsBake).toBeUndefined(); + expect([...(setting!.enumValues ?? [])].sort()).toEqual( + ['none', ...spec.modelAuth!.borrows.map((b) => b.agent)].sort(), + ); + } + }); + + it('stays JSON-serializable, like the rest of the row', () => { + for (const spec of declaring) { + expect(JSON.parse(JSON.stringify(spec.modelAuth))).toEqual(spec.modelAuth); + } + }); +}); + +describe('openclaw-model-auth', () => { + const spec = findAgentSpec('openclaw')!; + const task = spec.service!.tasks!.find((t) => t.name === 'openclaw-model-auth')!; + const script = String(task.command); + + it('is best-effort: exits 0 on every "nothing to do" and never sets -e', () => { + expect(script).toContain('set -u'); + expect(script).not.toContain('set -e'); + expect(script).toMatch(/no borrowed Codex login[^\n]*exit 0/); + expect(script).toMatch(/not a Codex ChatGPT login[^\n]*exit 0/); + expect(script.trimEnd().endsWith('exit 0')).toBe(true); + }); + + it('imports through openclaw`s own migrate command, scoped to the auth item', () => { + // Measured on 2026.9.3: the auth store is SQLite and the retired JSON + // files are never read, so `migrate apply codex` is the seam — and only + // the auth item, never the source's skills/plugins/config. + expect(script).toContain('openclaw migrate apply codex'); + expect(script).toContain('--item auth:openai'); + expect(script).toContain('--include-secrets'); + expect(script).toContain('--yes'); + expect(script).not.toContain('--overwrite'); + }); + + it('installs the official codex plugin only when absent', () => { + expect(script).toContain('plugins install clawhub:@openclaw/codex'); + expect(script).toMatch(/if \[ ! -d '[^']*\/\.openclaw\/extensions\/codex' \]/); + }); + + it('gates on the seed file`s hash, never on openclaw`s status view', () => { + // A bare seeded file makes `models status` show a bootstrapped profile + // that a turn cannot use, so status cannot say "already imported". The + // marker sits beside the overlay record and is excluded from the push. + expect(script).not.toContain('models status'); + expect(script).toContain('sha256sum "$auth"'); + expect(script).toContain('.agentbox-model-auth.sha256'); + expect(script).toMatch(/already imported[\s\S]*exit 0/); + expect(spec.staticPaths[0]!.exclude).toContain('.agentbox-model-auth.sha256'); + }); + + it('never writes the store or the config file by hand', () => { + expect(script).not.toContain('auth-profiles.json'); + expect(script).not.toContain('openclaw-agent.sqlite'); + expect(script).not.toContain('openclaw.json'); + }); +}); diff --git a/packages/agent-registry/test/openclaw-agentbox-env.test.ts b/packages/agent-registry/test/openclaw-agentbox-env.test.ts index 04a7a884c..547234003 100644 --- a/packages/agent-registry/test/openclaw-agentbox-env.test.ts +++ b/packages/agent-registry/test/openclaw-agentbox-env.test.ts @@ -14,10 +14,15 @@ const task = spec.service!.tasks!.find((t) => t.name === 'openclaw-agentbox-env' const script = task.command as string; describe('openclaw-agentbox-env', () => { - it('runs after onboard and before the render', () => { + it('runs after onboard (via the model-auth import) and before the render', () => { // onboard writes the config file this task patches; the render applies the // USER's overlay afterwards, so the user has the last word on a shared key. - expect(task.needs).toEqual(['openclaw-onboard']); + // The model-auth import sits between onboard and this task rather than + // beside it: both write openclaw.json through openclaw's own commands, and + // two read-modify-writes in parallel can drop one another's key. + const modelAuth = spec.service!.tasks!.find((t) => t.name === 'openclaw-model-auth')!; + expect(modelAuth.needs).toEqual(['openclaw-onboard']); + expect(task.needs).toEqual(['openclaw-model-auth']); const render = spec.service!.tasks!.find((t) => t.name === 'openclaw-render')!; expect(render.needs).toEqual(['openclaw-agentbox-env']); }); @@ -135,9 +140,7 @@ describe('the identity nudge', () => { // stale: the nudge is regenerated every boot and stops appearing the moment // the bot writes the rule-set. Mutating either half of this condition brings // back a prompt that never leaves, or one that never arrives. - expect(script).toMatch( - /!\s*grep -qs 'agentbox:identity-rules' \/workspace\/agentbox\.yaml/, - ); + expect(script).toMatch(/!\s*grep -qs 'agentbox:identity-rules' \/workspace\/agentbox\.yaml/); expect(script).toContain('$IDENTITY_NUDGE'); }); diff --git a/packages/config/schema/user-config.schema.json b/packages/config/schema/user-config.schema.json index 0cd1ac8eb..822138147 100644 --- a/packages/config/schema/user-config.schema.json +++ b/packages/config/schema/user-config.schema.json @@ -134,7 +134,8 @@ "type": "object", "additionalProperties": false, "properties": { - "sessionName": { "type": "string", "minLength": 1 } + "sessionName": { "type": "string", "minLength": 1 }, + "modelAuth": { "enum": ["none", "codex"] } } }, "example": { diff --git a/packages/config/src/agents.ts b/packages/config/src/agents.ts index 402ec4b05..eea71aaf8 100644 --- a/packages/config/src/agents.ts +++ b/packages/config/src/agents.ts @@ -134,6 +134,16 @@ export const AGENT_KINDS = [ // A gateway has no tool-approval prompts to bypass. hasSkipPermissions: false, surface: 'service', + settings: [ + { + key: 'modelAuth', + type: 'enum', + enumValues: ['none', 'codex'], + default: 'none', + description: + 'Which host login a new OpenClaw box is seeded with as its model provider. `codex` copies your Codex (ChatGPT) OAuth login into the box, where OpenClaw imports it and refreshes it independently from then on. `none` leaves model auth for you to configure in the box. `--model-auth` overrides per create.', + }, + ], }, // The hidden demo agent (see `@agentbox/agent-example`). Present so the // generated keys cover it too — an agent absent from this table would be the diff --git a/packages/core/src/box-record.ts b/packages/core/src/box-record.ts index 82b45df4a..e3557b7bb 100644 --- a/packages/core/src/box-record.ts +++ b/packages/core/src/box-record.ts @@ -486,6 +486,13 @@ export interface BoxRecord { * boxes created before per-agent selection, which correctly means "all". */ agents?: string[]; + /** + * Other agents' logins seeded into this box as model-provider auth, as + * passed to `create` (`borrowCredentials`). Durable so the credential + * fan-out can reach a box that consumes a login it does not run, and so a + * clone can borrow the same set. One-way: never a source for extraction. + */ + borrowedCredentials?: string[]; createdAt: string; // ISO-8601 } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 911b27508..257c1fc72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,7 @@ export type { InspectedBox, PrepareOptions, PrepareResult, + CarryItem, Provider, ProviderCheckpoint, ResolvedCarryEntry, @@ -76,4 +77,18 @@ export type { } from './cloud-backend.js'; export { AmbiguousBoxError, BoxNotFoundError, UserFacingError } from './errors.js'; export { BOX_ID_PREFIX, generateBoxId } from './identity.js'; +export { promptId, promptTopicOf } from './prompt.js'; +export type { + PromptAnswer, + PromptAsker, + PromptChoice, + PromptCredentialDetail, + PromptDetail, + PromptFallback, + PromptFileRow, + PromptFileTableDetail, + PromptKind, + PromptRequest, + PromptTextDetail, +} from './prompt.js'; export * from './sync/index.js'; diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts new file mode 100644 index 000000000..3685f2d5d --- /dev/null +++ b/packages/core/src/prompt.ts @@ -0,0 +1,171 @@ +/** + * The wire schema for a question AgentBox asks a human before it does something + * at the host boundary — copying `carry:` files into a box, seeding a box with + * another agent's login, granting a host CLI. + * + * One shape, four front-ends. The CLI renders it with clack, the hub web and the + * macOS tray render it as a panel, and a future client renders it without either + * of them changing: a client that understands only {@link PromptRequest} can + * always ask the question and post a valid answer. {@link PromptDetail} is the + * opt-in half — a client that recognises a variant draws it properly (a file + * table, a credential card) and one that does not falls back to its `summary`. + * + * The gates that ask are asker-agnostic: they take a {@link PromptAsker} rather + * than calling a prompt library, so the same gate serves a TTY, an HTTP + * preflight, and a recorded answer map. + */ + +import { createHash } from 'node:crypto'; + +/** Which widget a client must render. */ +export type PromptKind = 'confirm' | 'select' | 'text'; + +/** One option of a `select`. */ +export interface PromptChoice { + value: string; + label: string; + /** Second line, when the option carries a caveat worth reading before picking. */ + hint?: string; + /** Risky/destructive — a client may style it apart. */ + danger?: boolean; +} + +/** One row of a {@link PromptFileTableDetail}. */ +export interface PromptFileRow { + /** Source as the user wrote it, not the realpath. */ + src: string; + dest: string; + /** Absent for `missing`. */ + bytes?: number; + kind: 'file' | 'dir' | 'missing'; + /** Already formatted octal, e.g. `0600`. */ + mode?: string; + /** Numeric uid, when the entry pins one. */ + user?: number; + /** Free-form tags: `optional`, `dir`, `symlink-outside-home`. */ + flags: string[]; + /** The row deserves visual weight — it is why the question is being asked. */ + warn?: boolean; +} + +/** Files about to be copied somewhere. */ +export interface PromptFileTableDetail { + type: 'file-table'; + summary: string; + rows: PromptFileRow[]; + totalBytes: number; +} + +/** One host login about to be handed to a box. */ +export interface PromptCredentialDetail { + type: 'credential'; + summary: string; + /** The agent whose login this is (`codex`), not the one consuming it. */ + agent: string; + label: string; + caveat?: string; + hostPath: string; + boxPath: string; + bytes?: number; +} + +/** Nothing structured to say — `summary` is the whole detail. */ +export interface PromptTextDetail { + type: 'text'; + summary: string; +} + +/** + * Typed extra content a client MAY render richly. + * + * Every variant carries `summary`, plain text, so a client that does not know + * the variant still renders something correct rather than nothing. New variants + * are therefore additive: an older client degrades, it does not break. + */ +export type PromptDetail = PromptFileTableDetail | PromptCredentialDetail | PromptTextDetail; + +/** What an asker does when it cannot reach a human. */ +export interface PromptFallback { + value: string; + /** Said out loud when the fallback is taken, so it is never a silent default. */ + reason: string; +} + +export interface PromptRequest { + /** + * `:` — content-addressed over the question itself (see + * {@link promptId}). An answer therefore cannot be replayed onto a question + * that has since changed: the id no longer matches and the caller refuses. + */ + id: string; + /** Machine-stable reason this prompt exists: `carry`, `model-auth`, `tools`. */ + topic: string; + kind: PromptKind; + /** + * Two or three words for a card header ("Copy credentials"), with {@link title} + * as the line under it. Optional: a client without a header slot — the CLI — + * shows only `title`, which is always the actual question. + */ + heading?: string; + /** The question itself. Always present, and always answerable on its own. */ + title: string; + body?: string; + /** Required for `select`; ignored otherwise. */ + choices?: PromptChoice[]; + /** Pre-selected choice / prefilled text. For `confirm`, `'y'` or `'n'`. */ + defaultValue?: string; + detail?: PromptDetail; + fallback: PromptFallback; + /** + * There is no safe fallback: an asker that cannot reach a human must refuse + * rather than take `fallback`. Set by prompts whose silent answer would move + * secrets (`carry:`). + */ + required?: boolean; + /** + * The exact sentence to print when there is no way to ask — the flags or env + * vars that decide the question up front. Owned by the gate, because only the + * gate knows its own escape hatches. + */ + nonInteractiveHint?: string; +} + +export interface PromptAnswer { + id: string; + value: string; + /** The user dismissed rather than chose (Esc / closing the panel). */ + cancelled?: boolean; +} + +/** + * The one seam every gate takes instead of calling a prompt library. + * + * Implementations: a clack-backed asker in the CLI, a collecting asker that + * turns a gate into an HTTP preflight, and a map-backed asker that replays the + * answers a client posted back. + */ +export type PromptAsker = (req: PromptRequest) => Promise; + +/** + * Content-address a question: `:<12 hex of sha256(topic + payload)>`. + * + * `payload` must be canonical — the same question must serialize identically on + * every run, or a preflight answer would never match the create that replays it. + * Callers pass a stable projection of what they are asking about (the resolved + * carry table, the borrow list), never a timestamp or an absolute temp path. + */ +export function promptId(topic: string, payload: unknown): string { + const digest = createHash('sha256') + .update(topic) + .update(' ') + .update(JSON.stringify(payload) ?? 'null') + .digest('hex') + .slice(0, 12); + return `${topic}:${digest}`; +} + +/** True when `id` names this topic — the cheap half of answer validation. */ +export function promptTopicOf(id: string): string { + const i = id.indexOf(':'); + return i === -1 ? id : id.slice(0, i); +} diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index ada86e39d..bc75de728 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -34,6 +34,44 @@ export interface CreateBoxLimits { * `~/` expanded against the in-box `$HOME` at copy time). Kept on `core` so the * Provider seam doesn't depend on apps/cli. */ +/** + * One entry from the host-side `carry:` block in `agentbox.yaml`. + * + * Paths are kept user-facing (still containing `~/` or `./`) — resolution to + * absolute paths, project-root anchoring, and safety checks happen in the + * apps/cli resolver, not here. This package is shipped inside the box and + * must stay free of host-only assumptions. + */ +export interface CarryItem { + src: string; + dest: string; + mode?: number; + /** + * Numeric uid that should own the carried file inside the box. When unset, + * the copy step resolves the `vscode` user every box runs as, so the carried + * files are always agent-readable — its uid is 1000 on some providers and + * provider-assigned on others, so leave this unset unless you mean a literal + * uid. Set 0 to keep root-owned. + */ + user?: number; + /** + * Extra paths to drop when carrying a directory (tar glob like `*​/cache` or a + * bare dir name). Additive on top of the host CLI's default heavy-dir excludes + * (`.git`, `node_modules`, ...). Ignored for file entries. + */ + exclude?: string[]; + optional: boolean; + /** + * Substitute `{{AGENTBOX_*}}` whitelist placeholders in the file content + * host-side before copying. File entries only. + */ + replaceEnvs?: boolean; + /** Inline replacement rules applied (in order) before copying. File only. */ + replace?: ReplaceRule[]; + /** Names of top-level `replacements:` rule-sets to apply. File only. */ + rules?: string[]; +} + export interface ResolvedCarryEntry { rawSrc: string; rawDest: string; @@ -104,6 +142,14 @@ export interface CreateBoxRequest { * left out is not lost: `ensureAgentInstalled` adds it to a live box. */ agents?: string[]; + /** + * Other agents' host-held logins to seed into this box for the agent to + * consume as model-provider auth (`AgentSyncSpec.modelAuth.borrows`). Each + * lands at the borrowed agent's own credential path, 0600, before the first + * supervisor task runs. Validated against the agent's `modelAuth` by + * `resolveBorrowedCredentials`; an id the agent does not declare is refused. + */ + borrowCredentials?: string[]; /** Override the base image / snapshot. */ image?: string; /** diff --git a/packages/core/src/sync/agent-spec.ts b/packages/core/src/sync/agent-spec.ts index bc60d272f..b467e73be 100644 --- a/packages/core/src/sync/agent-spec.ts +++ b/packages/core/src/sync/agent-spec.ts @@ -632,6 +632,42 @@ export interface AgentSettingSpec { affectsBake?: boolean; } +/** + * One host-held login of ANOTHER agent that this agent may consume as + * model-provider auth — the Codex (ChatGPT OAuth) login an OpenClaw box runs + * its model calls on. + */ +export interface AgentBorrowSpec { + /** The agent whose `credential` is borrowed. Must declare one. */ + agent: AgentId; + /** Shown by the opt-in prompt and `--model-auth`'s help. */ + label: string; + /** Printed beside the prompt when consuming this login carries a caveat. */ + caveat?: string; +} + +/** + * How a service agent gets a model-provider login from the host. + * + * AgentBox learns only WHICH host credential a box may consume and moves it + * there over the machinery it already has: the borrowed agent's file lands at + * that agent's own `credential.boxAbsPath`, 0600, exactly where a runtime + * install would put it. It never learns the consuming agent's auth format — + * `ingestTask` names the `service.tasks` entry that reads the file and writes + * the agent's own store, the same way `openclaw-agentbox-env` asserts config + * through the tool's own patch command. + * + * Borrowing is ONE-WAY. The box is a consumer of that credential, never a + * source: `box.agents` still gates box->host extraction, the resume reconcile + * and the credential watch, so a copy that the consuming daemon has since + * refreshed in its own store is never read back over the host's. + */ +export interface AgentModelAuthSpec { + borrows: readonly AgentBorrowSpec[]; + /** The `service.tasks` entry that ingests whatever borrowed files are present. */ + ingestTask: string; +} + export interface AgentSyncSpec { id: AgentId; /** Alternate spellings that resolve to this spec (reconciles the wire `'claude-code'`). */ @@ -804,6 +840,12 @@ export interface AgentSyncSpec { * online-backup API — a byte copy of a live WAL triple is a torn read. */ stateBackup?: AgentStateBackupSpec; + /** + * Other agents' host-held logins this agent may consume as model-provider + * auth, and the in-box task that ingests them. See {@link AgentModelAuthSpec}. + * Absent means the agent authenticates to its model providers by itself. + */ + modelAuth?: AgentModelAuthSpec; /** * Extra in-box files ctl should watch, beyond `credential` (which is always * watched). This is the hook a custom agent uses to say "sync these back". diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 769cd9831..f84ac6349 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -12,11 +12,13 @@ export type { } from './transport.js'; export type { AgentId, AgentMode, QueueAgentKind } from './agent-kind.js'; export type { + AgentBorrowSpec, AgentCapabilities, AgentConfigRenderSpec, AgentCredential, AgentInstall, AgentInstallRecipe, + AgentModelAuthSpec, AgentCloneSpec, AgentPathMap, AgentPerBoxCarry, diff --git a/packages/ctl/src/carry.ts b/packages/ctl/src/carry.ts index 854b7d18a..b0cce35d8 100644 --- a/packages/ctl/src/carry.ts +++ b/packages/ctl/src/carry.ts @@ -1,44 +1,14 @@ import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; -import { parseReplaceRules, type ReplaceRule } from './replace.js'; +import type { CarryItem } from '@agentbox/core'; +import { parseReplacementsSection, parseReplaceRules, type ReplaceRule } from './replace.js'; -/** - * One entry from the host-side `carry:` block in `agentbox.yaml`. - * - * Paths are kept user-facing (still containing `~/` or `./`) — resolution to - * absolute paths, project-root anchoring, and safety checks happen in the - * apps/cli resolver, not here. This package is shipped inside the box and - * must stay free of host-only assumptions. - */ -export interface CarryItem { - src: string; - dest: string; - mode?: number; - /** - * Numeric uid that should own the carried file inside the box. When unset, - * the copy step resolves the `vscode` user every box runs as, so the carried - * files are always agent-readable — its uid is 1000 on some providers and - * provider-assigned on others, so leave this unset unless you mean a literal - * uid. Set 0 to keep root-owned. - */ - user?: number; - /** - * Extra paths to drop when carrying a directory (tar glob like `*​/cache` or a - * bare dir name). Additive on top of the host CLI's default heavy-dir excludes - * (`.git`, `node_modules`, ...). Ignored for file entries. - */ - exclude?: string[]; - optional: boolean; - /** - * Substitute `{{AGENTBOX_*}}` whitelist placeholders in the file content - * host-side before copying. File entries only. - */ - replaceEnvs?: boolean; - /** Inline replacement rules applied (in order) before copying. File only. */ - replace?: ReplaceRule[]; - /** Names of top-level `replacements:` rule-sets to apply. File only. */ - rules?: string[]; -} +// Re-exported so `@agentbox/ctl`'s existing importers are unchanged; the type +// itself moved to @agentbox/core because the host-side carry resolver lives in +// @agentbox/sandbox-core, which this package cannot be imported by (ctl -> +// relay -> sandbox-core would cycle). +export type { CarryItem }; export class CarryConfigError extends Error { constructor(message: string) { @@ -305,3 +275,22 @@ export async function loadCarrySection(path: string): Promise { } return parseCarrySection(text); } + +/** + * Both halves the carry gate needs, from one read of `/agentbox.yaml`. + * + * The gate itself lives in `@agentbox/sandbox-core`, which cannot import this + * package (ctl -> relay -> sandbox-core would cycle), so parsing stays here and + * the gate takes the result. A missing file is not an error: it means no carry. + */ +export async function loadCarrySpec( + projectRoot: string, +): Promise<{ items: CarryItem[]; replacements: Record }> { + let text = ''; + try { + text = await readFile(join(projectRoot, 'agentbox.yaml'), 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + return { items: parseCarrySection(text), replacements: parseReplacementsSection(text) }; +} diff --git a/packages/ctl/src/index.ts b/packages/ctl/src/index.ts index fa197127c..559336097 100644 --- a/packages/ctl/src/index.ts +++ b/packages/ctl/src/index.ts @@ -71,9 +71,12 @@ export { parseCarryRaw, parseCarrySection, loadCarrySection, + loadCarrySpec, CarryConfigError, - type CarryItem, } from './carry.js'; +// The type itself lives in @agentbox/core now; re-exported here so existing +// `@agentbox/ctl` importers are unchanged. +export type { CarryItem } from '@agentbox/core'; export { agentUnitsFromWire, mergeAgentUnits, type AgentUnits } from './agent-units.js'; export { deepEqual, diff --git a/packages/relay/src/queue.ts b/packages/relay/src/queue.ts index 6e860a800..b1d0dd25c 100644 --- a/packages/relay/src/queue.ts +++ b/packages/relay/src/queue.ts @@ -224,6 +224,8 @@ export interface QueueJobCreateOpts { vnc?: boolean; /** `--persistent` / `--no-persistent`: always-on box (config `box.persistent`). */ persistent?: boolean; + /** Other agents' host logins to seed as model auth (`--model-auth`; service agents). */ + borrowCredentials?: string[]; /** `--no-resync` → false; resync the box with the host on (checkpoint) create. */ resync?: boolean; sharedDockerCache?: boolean; diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts index d4969ce47..158a17c25 100644 --- a/packages/relay/src/types.ts +++ b/packages/relay/src/types.ts @@ -1,7 +1,7 @@ // DownloadKind's canonical decision home is `@agentbox/core`'s sync/files.ts; // imported here so DownloadRpcParams below can reference it, re-exported below // so existing `./types.js` importers stay unchanged. -import type { DownloadKind } from '@agentbox/core'; +import type { DownloadKind, PromptDetail, PromptKind as CorePromptKind } from '@agentbox/core'; export const DEFAULT_RELAY_PORT = 8787; /** @@ -304,13 +304,14 @@ export interface CheckpointRpcParams { } /** - * First-cut prompt UX is a y/N confirmation in the host wrapper's footer. - * `select` / `text` are reserved for a follow-up that grows the footer to - * two rows; keeping the kind in the wire from day one means the host - * wrapper can ignore unknown kinds gracefully when an older box hits a - * newer relay (and vice-versa). + * Host-action prompt UX is a y/N confirmation in the host wrapper's footer. + * `select` / `text` exist because {@link PromptRequest} — the create-time + * prompt schema every front-end renders — shares this union; no host-action + * gate emits them today. A wrapper that meets a kind it cannot render falls + * back to the confirm shape, so an older box against a newer relay degrades + * rather than breaks (and vice-versa). */ -export type PromptKind = 'confirm'; +export type PromptKind = CorePromptKind; export interface PromptContext { /** Short label, e.g. "git push" or "cp toHost: /workspace/x -> ~/dl/x". */ @@ -338,6 +339,14 @@ export interface PromptAskEvent { /** Default when the user just hits Enter; default 'n' so y/N is the safe shape. */ defaultAnswer?: 'y' | 'n'; context?: PromptContext; + /** + * Structured content a client MAY render richly (a file table, a credential + * card) instead of the flat `detail` line. Shared with the create-time prompt + * schema so the hub web and the tray need ONE renderer for both surfaces. No + * host-action gate sets it yet; a client that meets an unknown variant renders + * its `summary`. + */ + richDetail?: PromptDetail; } /** Body of `POST /admin/prompts/answer`. */ diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index 7aab50b22..345ab9b11 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -56,6 +56,7 @@ import { recordBox, removeBoxRecord, withPerBoxCarry, + borrowedCredentialCarry, } from '@agentbox/sandbox-core'; import { makeCloudSync } from './sync/cloud-sync.js'; import { createCloudSyncTransport } from './sync/sync-transport.js'; @@ -1057,10 +1058,15 @@ export function createCloudProvider( { boxName: syncCtx.boxName }, log, ); + // A borrowed model login lands the same way, 0600 at the lending + // agent's own path, before the in-box bootstrap starts the supervisor. + carryEntries.push(...(await borrowedCredentialCarry(req.borrowCredentials ?? [], log))); if (carryEntries.length > 0) { log(`carry: copying ${String(carryEntries.length)} host path(s) into the box`); const result = await sync.applyCarry(syncCtx, carryEntries); - log(`carry: copied ${String(result.copied)}/${String(carryEntries.length)} entry/entries`); + log( + `carry: copied ${String(result.copied)}/${String(carryEntries.length)} entry/entries`, + ); for (const err of result.errors) log(`carry: ${err}`); if (result.applied.length > 0) { carrySummary = { count: result.applied.length, entries: result.applied }; @@ -1403,6 +1409,9 @@ export function createCloudProvider( // start and needs to know which agents this box is for, or it would // re-acquire the others and undo the isolation. ...(req.agents ? { agents: req.agents } : {}), + ...(req.borrowCredentials && req.borrowCredentials.length > 0 + ? { borrowedCredentials: [...req.borrowCredentials] } + : {}), relayToken, withPlaywright: req.withPlaywright, withEnv: req.withEnv, diff --git a/packages/sandbox-cloud/src/vnc-browser.ts b/packages/sandbox-cloud/src/vnc-browser.ts index ca0709568..869ff1414 100644 --- a/packages/sandbox-cloud/src/vnc-browser.ts +++ b/packages/sandbox-cloud/src/vnc-browser.ts @@ -1,4 +1,5 @@ import type { BoxRecord, Provider } from '@agentbox/core'; +import { withServiceSignIn } from '@agentbox/sandbox-core'; import { desktopOpenCommand, readBoxStatus } from '@agentbox/sandbox-docker'; export interface CloudVncBrowserResult { @@ -29,7 +30,13 @@ export async function openWebAppOnVncScreen( const exposed = persisted?.services.find((s) => s.expose); if (!exposed) return { opened: false, reason: 'no web service' }; try { - const target = inBoxReachable(await provider.resolveUrl(box, { kind: 'web' }), exposed); + // Sign-in fragment LAST: `inBoxReachable` rebuilds the URL from the + // service's own port, which would drop a fragment resolved before it. + const target = await withServiceSignIn( + provider, + box, + inBoxReachable(await provider.resolveUrl(box, { kind: 'web' }), exposed), + ); const br = await provider.exec(box, ['bash', '-lc', desktopOpenCommand(target)], { user: 'vscode', }); diff --git a/packages/sandbox-core/src/borrowed-credentials.ts b/packages/sandbox-core/src/borrowed-credentials.ts new file mode 100644 index 000000000..a54027c03 --- /dev/null +++ b/packages/sandbox-core/src/borrowed-credentials.ts @@ -0,0 +1,95 @@ +/** + * Borrowed credentials: another agent's host-held login, seeded into a box for + * a SERVICE agent to consume as model-provider auth + * (`AgentSyncSpec.modelAuth`). + * + * The host's whole job is to decide WHICH login may enter the box and to land + * it at that login's own canonical path, 0600, before the first supervisor + * task runs. It rides the carry step for that reason: carry is the one step + * every provider already runs at exactly that moment, with the same ownership + * and mode rules a `perBoxCarry` channel token gets. What happens to the file + * afterwards is the consuming agent's `ingestTask`, declared on its own row. + * + * One-way by construction. The entry is a host->box copy; `box.agents` still + * gates the credential watch, extraction and the resume reconcile, so a copy + * the daemon has since refreshed in its own store never flows back. + */ + +import { stat } from 'node:fs/promises'; +import type { AgentSyncSpec, ResolvedCarryEntry } from '@agentbox/core'; +import { findAgentSpec, resolveAgentSpec } from './sync/registry.js'; +import { resolveHostCredentialFile } from './sync/concerns/credentials.js'; + +/** + * Validate a create's `borrowCredentials` against the agent's declaration. + * + * Refuses, rather than ignoring, an id the agent does not declare or one that + * names an agent with no host-side `credential`: a silently dropped entry + * would produce a box with no model auth and no message, which is the failure + * this feature exists to end. + */ +export function resolveBorrowedCredentials( + spec: Pick, + requested: readonly string[] | undefined, +): string[] { + const wanted = [...new Set((requested ?? []).map((s) => s.trim()).filter(Boolean))]; + if (wanted.length === 0) return []; + const declared = new Set((spec.modelAuth?.borrows ?? []).map((b) => b.agent)); + for (const id of wanted) { + if (!declared.has(id)) { + const options = declared.size > 0 ? [...declared].join(', ') : 'nothing'; + throw new Error(`${spec.id} cannot borrow "${id}" as model auth — it declares ${options}`); + } + if (!findAgentSpec(id)?.credential) { + throw new Error(`${spec.id} declares a borrow of "${id}", which has no host-side credential`); + } + } + return wanted; +} + +/** + * The carry entries that seed each borrowed login into the box. + * + * A login the host does not hold is reported and skipped, never fatal: the + * box still comes up, its ingest task finds nothing and says so, and the user + * can log in on the host and create again. Failing the create would leave no + * box behind for a condition the message already explains. + */ +export async function borrowedCredentialCarry( + agents: readonly string[], + onLog?: (line: string) => void, + /** Injectable for tests: where the host's login for an agent lives. */ + resolveFile: ( + agent: string, + ) => Promise<{ path: string; text: string } | null> = resolveHostCredentialFile, +): Promise { + const out: ResolvedCarryEntry[] = []; + for (const agent of agents) { + const spec = resolveAgentSpec(agent); + const credential = spec.credential; + if (!credential) continue; + const source = await resolveFile(spec.id); + if (!source) { + onLog?.(`model auth: no ${spec.id} login on this host — the box starts without it`); + continue; + } + const st = await stat(source.path); + onLog?.(`model auth: ${source.path} -> ${credential.boxAbsPath} (borrowed ${spec.id} login)`); + out.push({ + rawSrc: source.path, + rawDest: credential.boxAbsPath, + absSrc: source.path, + absDest: credential.boxAbsPath, + kind: 'file', + bytes: st.size, + mode: 0o600, + optional: true, + }); + } + return out; +} + +/** The `service.tasks` entry that ingests borrowed logins, when the agent has one. */ +export function borrowIngestTask(spec: Pick): string | undefined { + return spec.modelAuth?.ingestTask; +} diff --git a/packages/sandbox-core/src/index.ts b/packages/sandbox-core/src/index.ts index 4d77488f8..30a78cc1c 100644 --- a/packages/sandbox-core/src/index.ts +++ b/packages/sandbox-core/src/index.ts @@ -192,6 +192,11 @@ export { type PerBoxCarryContext, type PerBoxCarryResolution, } from './per-box-carry.js'; +export { + borrowedCredentialCarry, + borrowIngestTask, + resolveBorrowedCredentials, +} from './borrowed-credentials.js'; export * from './sync/index.js'; export * from './sync/agent-pull-module.js'; export { @@ -236,3 +241,38 @@ export { ownerRepoFromOriginUrl, projectSlugFromOriginUrl, } from './project-slug.js'; + +// ── Create-time host-boundary gates ── +// Each takes a `PromptAsker` rather than a prompt library, so the CLI, the hub's +// preflight and a replayed answer map all drive the same gate. See +// `packages/core/src/prompt.ts` for the schema every front-end renders. +export { + buildCarryPrompt, + CARRY_NON_INTERACTIVE_HINT, + CARRY_TOPIC, + renderCarryTable, + runCarryGate, + toFileRow, + type CarryDecision, + type CarryGateArgs, + type CarryGateResult, +} from './prompts/carry-gate.js'; +export { resolveCarry, type ResolveOptions, type ResolveResult } from './prompts/carry-resolve.js'; +export { + DEFAULT_CP_EXCLUDES, + effectiveExcludes, + fmtBytes, + isPathExcluded, + measureCopy, + toTarExcludes, +} from './prompts/dir-breakdown.js'; +export { + buildModelAuthPrompt, + listAvailableBorrows, + MODEL_AUTH_NONE, + MODEL_AUTH_SETTING, + MODEL_AUTH_TOPIC, + resolveModelAuth, + type AvailableBorrow, + type ModelAuthGateArgs, +} from './prompts/model-auth-gate.js'; diff --git a/packages/sandbox-core/src/prompts/carry-gate.ts b/packages/sandbox-core/src/prompts/carry-gate.ts new file mode 100644 index 000000000..703684215 --- /dev/null +++ b/packages/sandbox-core/src/prompts/carry-gate.ts @@ -0,0 +1,212 @@ +/** + * The host-boundary gate for a project's `carry:` block: resolve every declared + * entry, safety-check it, and ask a human before any of it reaches a box. + * + * The gate does not know how the question is asked. It builds one + * {@link PromptRequest} and hands it to a {@link PromptAsker} — clack in the + * CLI, an HTTP preflight in the hub, a recorded answer map when a client posts + * its answers back. That is what lets a box created from the tray or the web UI + * carry the same files a `agentbox create` box does. + * + * Parsing stays with the caller (`loadCarrySpec` in `@agentbox/ctl`): this + * package cannot import ctl, since ctl -> relay -> sandbox-core would cycle. + */ + +import { + promptId, + type CarryItem, + type PromptAsker, + type PromptFileRow, + type PromptRequest, + type ReplaceRule, + type ResolvedCarryEntry, +} from '@agentbox/core'; +import { resolveCarry } from './carry-resolve.js'; + +export const CARRY_TOPIC = 'carry'; + +/** The three things a user can say about a carry block. */ +export type CarryDecision = 'approve' | 'skip-this-run' | 'cancel'; + +/** + * The sentence printed when there is no way to ask. Owned here because only the + * gate knows its own escape hatches. + */ +export const CARRY_NON_INTERACTIVE_HINT = + 'Set AGENTBOX_CARRY_YES=1 to allow the copy, or AGENTBOX_CARRY=skip to skip it.'; + +export interface CarryGateArgs { + /** Absolute project root (the dir holding `agentbox.yaml`). */ + projectRoot: string; + /** Parsed `carry:` entries — see `loadCarrySpec` in `@agentbox/ctl`. */ + items: CarryItem[]; + /** Parsed top-level `replacements:` rule-sets, for expanding `rules:` refs. */ + replacements?: Record; + /** Per-entry size cap; callers pass the effective `box.cpMaxBytes`. */ + maxBytes?: number; + /** How to ask. */ + ask: PromptAsker; + /** `--carry-yes` / `AGENTBOX_CARRY_YES=1` — approves without asking. */ + carryYes?: boolean; + /** `--carry skip` / `AGENTBOX_CARRY=skip` — proceeds with carry disabled. */ + carrySkip?: boolean; + onLog?: (line: string) => void; +} + +export type CarryGateResult = + | { decision: 'approve'; entries: ResolvedCarryEntry[] } + | { decision: 'skip'; entries: [] } + | { decision: 'cancel' }; + +/** + * Build the question for an already-resolved carry table. + * + * Exported so a caller can show the same prompt without re-running the gate — + * and so the id is derived in exactly one place. The id is content-addressed + * over what is being asked (src/dest/size/flags per row), so an answer collected + * in a preflight cannot be replayed onto a `carry:` block that changed in + * between: the id no longer matches and the create refuses. + */ +export function buildCarryPrompt(entries: ResolvedCarryEntry[]): PromptRequest { + const rows = entries.map(toFileRow); + const totalBytes = rows.reduce((n, r) => n + (r.bytes ?? 0), 0); + const n = rows.length; + return { + id: promptId(CARRY_TOPIC, rows), + topic: CARRY_TOPIC, + kind: 'select', + heading: 'Copy files', + title: n === 1 ? 'Copy this file into the box?' : `Copy these ${String(n)} files into the box?`, + body: 'They leave this machine, so check the list before you say yes.', + choices: [ + { value: 'approve', label: 'Copy' }, + { value: 'skip-this-run', label: 'Skip' }, + { value: 'cancel', label: 'Cancel', danger: true }, + ], + defaultValue: 'approve', + detail: { + type: 'file-table', + summary: renderCarryTable(rows), + rows, + totalBytes, + }, + // Silently answering this moves host secrets, so there is no safe default. + required: true, + fallback: { value: 'cancel', reason: 'nobody was there to approve the copy' }, + nonInteractiveHint: CARRY_NON_INTERACTIVE_HINT, + }; +} + +/** + * Run the gate: resolve, safety-check, ask, and return the approved entries. + * + * Throws on a hard resolver error (a missing non-optional src, a denylisted + * dest, an over-cap entry) so the caller aborts *before* a box exists. + */ +export async function runCarryGate(args: CarryGateArgs): Promise { + const emit = args.onLog ?? (() => {}); + if (args.items.length === 0) return { decision: 'approve', entries: [] }; + + const resolved = await resolveCarry(args.items, { + projectRoot: args.projectRoot, + ...(args.maxBytes !== undefined ? { maxBytes: args.maxBytes } : {}), + ...(args.replacements ? { replacements: args.replacements } : {}), + }); + if (resolved.errors.length > 0) { + throw new Error( + ["carry: these files can't be copied:", ...resolved.errors.map((e) => ` - ${e}`)].join('\n'), + ); + } + + // Flags decide the question before anyone is asked, so a scripted create never + // surfaces a prompt it has already been told the answer to. + if (args.carrySkip) return skip(resolved.entries.length, emit); + if (args.carryYes) return { decision: 'approve', entries: resolved.entries }; + + const req = buildCarryPrompt(resolved.entries); + const answer = await args.ask(req); + const decision: CarryDecision = answer.cancelled + ? 'cancel' + : isCarryDecision(answer.value) + ? answer.value + : 'cancel'; + + if (decision === 'cancel') return { decision: 'cancel' }; + if (decision === 'skip-this-run') return skip(resolved.entries.length, emit); + return { decision: 'approve', entries: resolved.entries }; +} + +function skip(count: number, emit: (line: string) => void): { decision: 'skip'; entries: [] } { + emit(`carry: skipped (${String(count)} ${count === 1 ? 'file' : 'files'} not copied)`); + return { decision: 'skip', entries: [] }; +} + +function isCarryDecision(v: string): v is CarryDecision { + return v === 'approve' || v === 'skip-this-run' || v === 'cancel'; +} + +/** Project one resolved entry into the wire row every client renders. */ +export function toFileRow(e: ResolvedCarryEntry): PromptFileRow { + const flags: string[] = []; + // Plain words, not the resolver's vocabulary — these are read by whoever is + // deciding, not by whoever wrote the `carry:` block. + if (e.kind === 'missing') flags.push('not on this machine'); + else if (e.optional) flags.push('optional'); + if (e.kind === 'dir') flags.push('folder'); + if (e.symlinkInfo === 'outside-home') flags.push('shortcut to somewhere else'); + return { + src: e.rawSrc, + dest: e.rawDest, + ...(e.kind === 'missing' ? {} : { bytes: e.bytes ?? 0 }), + kind: e.kind, + ...(e.mode !== undefined ? { mode: e.mode.toString(8).padStart(4, '0') } : {}), + // Unset means "the box user", the calm common case. Any explicit `user:` is + // an override worth seeing at the gate — including a literal 1000. + ...(e.user !== undefined ? { user: e.user } : {}), + flags, + // The only flag that says "this may not be what you think it is". + ...(e.symlinkInfo === 'outside-home' ? { warn: true } : {}), + }; +} + +/** + * The plain-text table, for a client that renders no typed detail (and for the + * CLI, which renders exactly this). + * + * Unlike the GUI tables, this one DOES show `mode` and `user`. A permission bit + * is noise on a card someone is glancing at, but this is the terminal gate — the + * one surface where an entry landing 0644 or root-owned is worth seeing before + * you approve it. + */ +export function renderCarryTable(rows: PromptFileRow[]): string { + if (rows.length === 0) return ''; + const srcW = Math.max(3, ...rows.map((r) => r.src.length)); + const destW = Math.max(4, ...rows.map((r) => r.dest.length)); + const out = [`${pad('from', srcW)} -> ${pad('to', destW)} size notes`]; + for (const r of rows) { + const flags = [...r.flags]; + if (r.mode !== undefined) flags.push(`mode ${r.mode}`); + if (r.user !== undefined) flags.push(`user ${String(r.user)}`); + const size = r.kind === 'missing' ? '-' : formatBytes(r.bytes ?? 0); + out.push( + `${pad(r.src, srcW)} -> ${pad(r.dest, destW)} ${pad(size, 9)} ${flags.join(', ')}`, + ); + } + return out.join('\n'); +} + +function pad(s: string, w: number): string { + return s.length >= w ? s : s + ' '.repeat(w - s.length); +} + +function formatBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v < 10 ? v.toFixed(1) : String(Math.round(v))} ${units[i]!}`; +} diff --git a/apps/cli/src/lib/carry-resolve.ts b/packages/sandbox-core/src/prompts/carry-resolve.ts similarity index 86% rename from apps/cli/src/lib/carry-resolve.ts rename to packages/sandbox-core/src/prompts/carry-resolve.ts index 0abf88bef..55a6cc86d 100644 --- a/apps/cli/src/lib/carry-resolve.ts +++ b/packages/sandbox-core/src/prompts/carry-resolve.ts @@ -2,41 +2,19 @@ import { realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { isAbsolute, join, normalize, relative, resolve } from 'node:path'; import { BUILT_IN_DEFAULTS } from '@agentbox/config'; -import { isInside, realpathSafe } from '@agentbox/core'; -import { resolveRuleRefs, type CarryItem, type ReplaceRule } from '@agentbox/ctl'; +import { + isInside, + realpathSafe, + resolveRuleRefs, + type CarryItem, + type ReplaceRule, + type ResolvedCarryEntry, +} from '@agentbox/core'; + import { effectiveExcludes, isPathExcluded, toTarExcludes } from './dir-breakdown.js'; -/** - * One fully resolved carry entry, ready for the prompt and the per-provider - * copy step. `rawSrc` / `rawDest` preserve what the user typed (for the prompt - * to display); `absSrc` is the host-resolved path, `absDest` is the box-side - * path with `~/` left intact (expanded inside the container at execute time - * against the in-box `$HOME`). - */ -export interface ResolvedCarryEntry { - rawSrc: string; - rawDest: string; - absSrc: string; - absDest: string; - kind: 'file' | 'dir' | 'missing'; - bytes?: number; - mode?: number; - /** - * Numeric uid that should own the carried file inside the box. Mirrors - * the field on `@agentbox/core`'s `ResolvedCarryEntry`. `resolveOne()` - * below already forwards `item.user` into the result; this field made - * the contract explicit so `carry-prompt.ts` can render the flag. - */ - user?: number; - optional: boolean; - symlinkInfo?: 'safe' | 'outside-home'; - /** tar `--exclude` patterns applied when packing a dir entry. */ - exclude?: string[]; - /** Substitute `{{AGENTBOX_*}}` placeholders host-side before copy (file only). */ - replaceEnvs?: boolean; - /** Final replacement rules (named refs already expanded). File only. */ - replace?: ReplaceRule[]; -} +// `ResolvedCarryEntry` is @agentbox/core's — this module used to declare a +// structurally identical copy, which was one drift away from a silent bug. export interface ResolveOptions { /** Absolute path to the dir holding `agentbox.yaml`. `./` srcs anchor here. */ diff --git a/apps/cli/src/lib/dir-breakdown.ts b/packages/sandbox-core/src/prompts/dir-breakdown.ts similarity index 100% rename from apps/cli/src/lib/dir-breakdown.ts rename to packages/sandbox-core/src/prompts/dir-breakdown.ts diff --git a/packages/sandbox-core/src/prompts/model-auth-gate.ts b/packages/sandbox-core/src/prompts/model-auth-gate.ts new file mode 100644 index 000000000..d175c1cd5 --- /dev/null +++ b/packages/sandbox-core/src/prompts/model-auth-gate.ts @@ -0,0 +1,180 @@ +/** + * The create-time decision "which host login does this service box borrow as its + * model provider?" — the host-boundary gate for `AgentSyncSpec.modelAuth`. + * + * Precedence: `--model-auth` > the agent's `.modelAuth` config key > a + * prompt. The prompt only appears when nothing chose and the host actually holds + * one of the declared logins, so a scripted create never hands a subscription + * token to a daemon by default. Declining resolves to "none", which is also the + * config default: a box that comes up without model auth says so in its ingest + * task's log, it does not fail. + * + * Like the carry gate, it asks through a {@link PromptAsker} rather than a + * prompt library, so the hub can ask the same question of a tray or web client. + */ + +import { + promptId, + type AgentSettings, + type AgentSyncSpec, + type PromptAsker, + type PromptChoice, + type PromptRequest, +} from '@agentbox/core'; +import { resolveBorrowedCredentials } from '../borrowed-credentials.js'; +import { resolveHostCredentialFile } from '../sync/concerns/credentials.js'; + +export const MODEL_AUTH_TOPIC = 'model-auth'; +export const MODEL_AUTH_SETTING = 'modelAuth'; +export const MODEL_AUTH_NONE = 'none'; + +/** A borrow the host can actually satisfy, with the paths to show the user. */ +export interface AvailableBorrow { + agent: string; + label: string; + caveat?: string; + /** Where the login is on this host. */ + hostPath: string; + /** Where it lands inside the box. */ + boxPath: string; + bytes?: number; +} + +export interface ModelAuthGateArgs { + spec: Pick; + /** `--model-auth ` as typed, if passed. */ + flag?: string; + /** The agent's settings block, defaults applied. */ + settings: AgentSettings; + /** True when `.modelAuth` was set by the user rather than defaulted. */ + configuredExplicitly: boolean; + ask: PromptAsker; + /** Injectable for tests: which borrows this host can satisfy. */ + listAvailable?: (spec: ModelAuthGateArgs['spec']) => Promise; +} + +/** + * Build the question for a set of satisfiable borrows. + * + * One `select` rather than a confirm per borrow: `.modelAuth` is itself a + * single-valued enum, so offering more than one choice at a time would let the + * prompt express something the config cannot. + */ +export function buildModelAuthPrompt(agentId: string, available: AvailableBorrow[]): PromptRequest { + const first = available[0]; + // One borrow is the only shape that exists today, and it reads as a plain + // yes/no. Naming the login on the button would repeat the card right below it. + const choices: PromptChoice[] = + available.length === 1 && first + ? [ + { value: first.agent, label: 'Yes' }, + { value: MODEL_AUTH_NONE, label: 'No' }, + ] + : [ + ...available.map((b) => ({ + value: b.agent, + label: titleCase(b.agent), + ...(b.caveat ? { hint: b.caveat } : {}), + })), + { value: MODEL_AUTH_NONE, label: 'No thanks' }, + ]; + return { + id: promptId(MODEL_AUTH_TOPIC, { + agent: agentId, + borrows: available.map((b) => ({ agent: b.agent, hostPath: b.hostPath })), + }), + topic: MODEL_AUTH_TOPIC, + kind: 'select', + heading: 'Copy credentials', + title: 'Copy your model provider logins?', + choices, + // Declining stays the default: copying a subscription login into a + // long-lived daemon should never be the answer you get by not reading. + defaultValue: MODEL_AUTH_NONE, + ...(first + ? { + detail: { + type: 'credential' as const, + summary: `${first.hostPath} -> ${first.boxPath}`, + agent: first.agent, + label: first.label, + ...(first.caveat ? { caveat: first.caveat } : {}), + hostPath: first.hostPath, + boxPath: first.boxPath, + ...(first.bytes !== undefined ? { bytes: first.bytes } : {}), + }, + } + : {}), + // Declining is the safe answer and the config default, so an asker that + // cannot reach a human takes it rather than refusing the create. + fallback: { value: MODEL_AUTH_NONE, reason: 'not asked - the box starts without it' }, + nonInteractiveHint: + `Use --model-auth , or \`agentbox config set ${agentId}.${MODEL_AUTH_SETTING} \` ` + + 'to decide this once.', + }; +} + +function titleCase(s: string): string { + return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1); +} + +/** The agents whose logins the create should seed, in declaration order. */ +export async function resolveModelAuth(args: ModelAuthGateArgs): Promise { + const { spec } = args; + const borrows = spec.modelAuth?.borrows ?? []; + if (borrows.length === 0) { + if (args.flag !== undefined && args.flag !== MODEL_AUTH_NONE) { + throw new Error(`${spec.id} borrows no host login — --model-auth does not apply to it`); + } + return []; + } + + const flag = args.flag?.trim(); + if (flag !== undefined) { + return flag === MODEL_AUTH_NONE ? [] : resolveBorrowedCredentials(spec, [flag]); + } + + const configured = args.settings[MODEL_AUTH_SETTING]; + if ( + args.configuredExplicitly || + (typeof configured === 'string' && configured !== MODEL_AUTH_NONE) + ) { + return typeof configured === 'string' && configured !== MODEL_AUTH_NONE + ? resolveBorrowedCredentials(spec, [configured]) + : []; + } + + const available = await (args.listAvailable ?? listAvailableBorrows)(spec); + // Nothing to offer: the host holds none of the declared logins. + if (available.length === 0) return []; + + const answer = await args.ask(buildModelAuthPrompt(spec.id, available)); + if (answer.cancelled) return []; + const chosen = answer.value; + if (chosen === MODEL_AUTH_NONE || chosen.length === 0) return []; + // An answer naming a borrow this host cannot satisfy is a stale answer, not a + // silent "none" — resolveBorrowedCredentials throws with the valid values. + return resolveBorrowedCredentials(spec, [chosen]); +} + +/** Which declared borrows this host can actually satisfy right now. */ +export async function listAvailableBorrows( + spec: Pick, +): Promise { + const { findAgentSpec } = await import('@agentbox/agent-registry'); + const out: AvailableBorrow[] = []; + for (const b of spec.modelAuth?.borrows ?? []) { + const hit = await resolveHostCredentialFile(b.agent); + if (!hit) continue; + const boxPath = findAgentSpec(b.agent)?.credential?.boxAbsPath ?? ''; + out.push({ + agent: b.agent, + label: b.label, + ...(b.caveat ? { caveat: b.caveat } : {}), + hostPath: hit.path, + boxPath, + bytes: Buffer.byteLength(hit.text, 'utf8'), + }); + } + return out; +} diff --git a/packages/sandbox-core/src/sync/agent-propagate.ts b/packages/sandbox-core/src/sync/agent-propagate.ts index f0f5ade17..3cd8b02ee 100644 --- a/packages/sandbox-core/src/sync/agent-propagate.ts +++ b/packages/sandbox-core/src/sync/agent-propagate.ts @@ -233,6 +233,8 @@ export interface PropagateBoxLike { agentConfigVolumes?: Record; /** Agents the box was created for. Absent = created before per-agent selection. */ agents?: string[]; + /** Other agents' logins the box consumes as model auth (`borrowCredentials`). */ + borrowedCredentials?: string[]; } export interface PropagatePlan { @@ -244,6 +246,14 @@ export interface PropagatePlan { dockerVolumes: Array<{ volume: string; boxNames: string[]; shared: boolean }>; /** Cloud boxes to push to (caller checks each is running / resumable). */ cloudBoxes: B[]; + /** + * Boxes that BORROW this agent's login as model auth (any provider). They + * mount no config volume for it, so the push goes over the box's transport + * to the agent's canonical path, and the consuming agent's ingest task is + * re-run afterwards. A box that both runs and borrows the agent is listed + * once, under the running set. + */ + borrowingBoxes: B[]; } /** @@ -283,13 +293,19 @@ export function planPropagateTargets( ); const volumes = new Map(); const cloudBoxes: B[] = []; + const borrowingBoxes: B[] = []; for (const box of inScope) { // A box created for a specific agent set must not be handed another agent's // credential. This is the fan-out's own gate: it pushes STRAIGHT INTO a // cloud box, so without it a resume re-seeds every agent's token and // silently undoes the create-time isolation. Docker is unaffected — its // push targets a volume the box doesn't mount. - if (box.agents && !box.agents.includes(opts.agent)) continue; + if (box.agents && !box.agents.includes(opts.agent)) { + // Unless the box consumes that login as model auth, in which case the + // fresh blob goes to the canonical path its ingest task reads. + if (box.borrowedCredentials?.includes(opts.agent)) borrowingBoxes.push(box); + continue; + } if ((box.provider ?? 'docker') !== 'docker') { cloudBoxes.push(box); continue; @@ -303,5 +319,6 @@ export function planPropagateTargets( return { dockerVolumes: [...volumes.entries()].map(([volume, v]) => ({ volume, ...v })), cloudBoxes, + borrowingBoxes, }; } diff --git a/packages/sandbox-core/src/sync/concerns/credentials.ts b/packages/sandbox-core/src/sync/concerns/credentials.ts index 5dd25ae25..0320caefb 100644 --- a/packages/sandbox-core/src/sync/concerns/credentials.ts +++ b/packages/sandbox-core/src/sync/concerns/credentials.ts @@ -25,7 +25,7 @@ * same call carry's apply mechanism and skills' box→host pull already made. */ -import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import type { SyncTransport } from '@agentbox/core'; @@ -321,6 +321,53 @@ export async function resolveHostCredential(agent: CredentialAgentKind): Promise } } +/** + * The host FILE an agent's credential should be read from, with its text. + * + * `resolveHostCredential` prefers the backup unconditionally, which is right + * for the fan-out (a rotating agent keeps the backup newest) but wrong for a + * seed of a codex-shaped login: the host's own `codex` refreshes its real file + * and nothing copies that into the backup, so the backup can be days older. + * Candidates that pass the shape gate are ordered by the agent's `freshness` + * rule when it declares one, and by mtime otherwise — the closest generic proxy + * for "most recently refreshed". + */ +export async function resolveHostCredentialFile( + agent: CredentialAgentKind, + /** Injectable for tests: the files to consider, in place of backup + real path. */ + opts: { candidates?: readonly string[] } = {}, +): Promise<{ path: string; text: string } | null> { + const spec = resolveAgentSpec(agent); + if (!spec.credential) return null; + const source = spec.staticPaths[0]; + const candidates = opts.candidates ?? [ + spec.credential.hostBackup, + ...(source ? [join(homedir(), ...source.hostHomeRel, spec.credential.boxRelPath)] : []), + ]; + const valid: { path: string; text: string; mtimeMs: number }[] = []; + for (const path of candidates) { + try { + const text = await readFile(path, 'utf8'); + if (!isRealAgentCredential(agent, text)) continue; + valid.push({ path, text, mtimeMs: (await stat(path)).mtimeMs }); + } catch { + // absent or unreadable: not a candidate + } + } + if (valid.length === 0) return null; + const freshness = spec.credential.freshness?.jsonPath; + valid.sort((a, b) => { + if (freshness) { + const fa = jsonNumberAt(a.text, freshness) ?? -Infinity; + const fb = jsonNumberAt(b.text, freshness) ?? -Infinity; + if (fa !== fb) return fb - fa; + } + return b.mtimeMs - a.mtimeMs; + }); + const best = valid[0]!; + return { path: best.path, text: best.text }; +} + export async function pushCredentialToBox( transport: SyncTransport, agent: CredentialAgentKind, diff --git a/packages/sandbox-core/src/sync/concerns/service-url.ts b/packages/sandbox-core/src/sync/concerns/service-url.ts index fd9e56363..5d83cd997 100644 --- a/packages/sandbox-core/src/sync/concerns/service-url.ts +++ b/packages/sandbox-core/src/sync/concerns/service-url.ts @@ -147,3 +147,32 @@ export function serviceSignInUrl( const base = url.endsWith('/') ? url : `${url}/`; return `${base}#${parts.join('&')}`; } + +/** + * `baseUrl` carrying the box's service agent's sign-in fields, or `baseUrl` + * unchanged when the box hosts no service agent and when the daemon has no + * token to give yet. + * + * The browser INSIDE the box needs this for exactly the reason the host's does: + * point it at the bare web URL and openclaw's Control UI opens on its token + * prompt, so the VNC desktop shows a login screen instead of the dashboard. The + * VNC path resolves its own target (it must — the host's `127.0.0.1:` + * is nothing inside the box), which is what left it as the one surface still + * handing out an unsigned URL. + * + * Best-effort by contract, like every other producer here: a daemon mid-onboard + * has no token, and the plain URL plus its own prompt beats opening nothing. + */ +export async function withServiceSignIn( + provider: Provider, + box: BoxRecord, + baseUrl: string, +): Promise { + try { + const fields = serviceAgentForBox(box)?.service?.urlFields ?? []; + if (fields.length === 0) return baseUrl; + return serviceSignInUrl(baseUrl, await readServiceUrlFields(provider, box, fields)) ?? baseUrl; + } catch { + return baseUrl; + } +} diff --git a/packages/sandbox-core/src/sync/host-stage.ts b/packages/sandbox-core/src/sync/host-stage.ts index f9a0c1d98..332c523b3 100644 --- a/packages/sandbox-core/src/sync/host-stage.ts +++ b/packages/sandbox-core/src/sync/host-stage.ts @@ -103,6 +103,16 @@ export async function mkStageDir(prefix: string): Promise { // hit the read-only-source case in practice), so it's a safe no-op there. export const STAGE_WRITABLE_CHMOD = '--chmod=Du+rwx,Fu+rw'; +// `rsync -a` implies `-D` (devices + specials), so a unix socket in the source +// tree — codex's `~/.codex/ipc/ipc.sock`, live whenever the desktop app has run +// — makes rsync try to recreate it in the stage dir. On macOS that bind() fails +// with EINVAL once the destination path exceeds sockaddr_un's 104-byte limit +// (`/var/folders/.../agentbox--static-stage-XXXXXX/ipc/.ipc.sock.XXXXXX` +// clears it easily), aborting the whole stage with exit 23. A socket is dead +// weight in a snapshot anyway — nothing in the box can connect to the host's +// end — so skip specials and devices outright. +export const STAGE_NO_SPECIALS = '--no-D'; + export function emptyResult(warnings: string[] = []): StageResult { return { tarballPath: null, cleanup: async () => {}, warnings }; } @@ -200,6 +210,7 @@ export async function stageAgentStaticForUpload( await execa('rsync', [ '-a', STAGE_WRITABLE_CHMOD, + STAGE_NO_SPECIALS, '-L', ...broken.map((r) => `--exclude=/${r}`), ...(path.include ?? []).map((pat) => `--include=${pat}`), @@ -240,6 +251,7 @@ export async function stageAgentsStaticForUpload( await execa('rsync', [ '-a', STAGE_WRITABLE_CHMOD, + STAGE_NO_SPECIALS, '-L', ...broken.map((r) => `--exclude=/${r}`), `${hostAgents}/`, diff --git a/packages/sandbox-core/src/sync/index.ts b/packages/sandbox-core/src/sync/index.ts index 989fb9d6f..b5cf48acd 100644 --- a/packages/sandbox-core/src/sync/index.ts +++ b/packages/sandbox-core/src/sync/index.ts @@ -150,6 +150,7 @@ export { readCredentialBackup, pushCredentialToBox, resolveHostCredential, + resolveHostCredentialFile, SEED_MARKER, type CredentialAgentKind, type CredentialsUpdate, @@ -186,6 +187,7 @@ export { readServiceUrlFields, serviceAgentForBox, serviceSignInUrl, + withServiceSignIn, type ServiceUrlFieldValue, } from './concerns/service-url.js'; export { @@ -276,6 +278,7 @@ export { tarballFromDir, makeCleanup, stageSingleFileTarball, + STAGE_NO_SPECIALS, STAGE_WRITABLE_CHMOD, type AgentStaticStage, type StageResult, diff --git a/packages/sandbox-core/test/borrowed-credentials.test.ts b/packages/sandbox-core/test/borrowed-credentials.test.ts new file mode 100644 index 000000000..92169e65e --- /dev/null +++ b/packages/sandbox-core/test/borrowed-credentials.test.ts @@ -0,0 +1,189 @@ +import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + borrowIngestTask, + borrowedCredentialCarry, + resolveBorrowedCredentials, + resolveHostCredentialFile, + resolveAgentSpec, + planPropagateTargets, +} from '../src/index.js'; + +const openclaw = resolveAgentSpec('openclaw'); +const codex = resolveAgentSpec('codex'); + +describe('resolveBorrowedCredentials', () => { + it('accepts a declared borrow, deduplicated and trimmed', () => { + expect(resolveBorrowedCredentials(openclaw, [' codex ', 'codex'])).toEqual(['codex']); + }); + + it('is empty for nothing requested', () => { + expect(resolveBorrowedCredentials(openclaw, undefined)).toEqual([]); + expect(resolveBorrowedCredentials(openclaw, [''])).toEqual([]); + }); + + it('refuses an agent the row does not declare, naming what it does', () => { + // A silently dropped entry is a box with no model auth and no message — + // the exact failure this feature exists to end. + expect(() => resolveBorrowedCredentials(openclaw, ['claude'])).toThrow(/declares codex/); + }); + + it('refuses on an agent with no modelAuth at all', () => { + expect(() => resolveBorrowedCredentials(codex, ['codex'])).toThrow(/declares nothing/); + }); + + it('refuses a declared borrow whose lender has no host credential', () => { + const spec = { + id: 'x', + modelAuth: { borrows: [{ agent: 'openclaw', label: 'l' }], ingestTask: 't' }, + }; + expect(() => resolveBorrowedCredentials(spec, ['openclaw'])).toThrow(/no host-side credential/); + }); +}); + +describe('resolveHostCredentialFile', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'abx-borrow-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const login = (tag: string) => + JSON.stringify({ auth_mode: 'chatgpt', tokens: { refresh_token: `rt.${tag}` } }); + + it('picks the most recently written valid candidate for an agent with no freshness rule', async () => { + // The host's own `codex` refreshes its real file; nothing copies that into + // the backup, so "backup first" would seed a days-older refresh token. + const backup = join(dir, 'backup.json'); + const real = join(dir, 'auth.json'); + await writeFile(backup, login('old')); + await writeFile(real, login('new')); + const t = Date.now() / 1000; + await utimes(backup, t - 86400, t - 86400); + await utimes(real, t, t); + const got = await resolveHostCredentialFile('codex', { candidates: [backup, real] }); + expect(got?.path).toBe(real); + expect(got?.text).toContain('rt.new'); + }); + + it('skips a candidate that fails the shape gate', async () => { + const backup = join(dir, 'backup.json'); + const real = join(dir, 'auth.json'); + await writeFile(backup, login('ok')); + await writeFile(real, ''); + const t = Date.now() / 1000; + await utimes(backup, t - 86400, t - 86400); + // The newer file is empty, which fails codex's `nonempty-json` shape, so + // the older valid one is chosen. Content (is it a ChatGPT login?) is the + // in-box ingest task's check, not the host's. + const got = await resolveHostCredentialFile('codex', { candidates: [backup, real] }); + expect(got?.path).toBe(backup); + const none = await resolveHostCredentialFile('codex', { + candidates: [join(dir, 'missing.json'), join(dir, 'empty.json')], + }); + expect(none).toBeNull(); + }); + + it('orders by the declared freshness field when the agent has one', async () => { + const older = join(dir, 'a.json'); + const newer = join(dir, 'b.json'); + const blob = (exp: number) => + JSON.stringify({ claudeAiOauth: { refreshToken: 'r', expiresAt: exp } }); + await writeFile(older, blob(1000)); + await writeFile(newer, blob(2000)); + // mtime says the opposite of the freshness field; the field wins. + const t = Date.now() / 1000; + await utimes(older, t, t); + await utimes(newer, t - 86400, t - 86400); + const got = await resolveHostCredentialFile('claude', { candidates: [older, newer] }); + expect(got?.path).toBe(newer); + }); +}); + +describe('borrowedCredentialCarry', () => { + it('reports and skips an agent the host has no login for', async () => { + const lines: string[] = []; + const entries = await borrowedCredentialCarry( + ['codex'], + (l) => lines.push(l), + async () => null, + ); + expect(entries).toEqual([]); + expect(lines.join('\n')).toMatch(/no codex login on this host/); + }); +}); + +describe('borrowedCredentialCarry with a real file', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'abx-borrow-carry-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('produces one 0600 file entry per borrowed agent', async () => { + const path = join(dir, 'auth.json'); + await writeFile(path, '{"tokens":{"refresh_token":"x"}}'); + const entries = await borrowedCredentialCarry(['codex'], undefined, async () => ({ + path, + text: 'unused', + })); + expect(entries).toHaveLength(1); + const e = entries[0]!; + expect(e.absSrc).toBe(path); + expect(e.absDest).toBe(codex.credential!.boxAbsPath); + expect(e.kind).toBe('file'); + expect(e.mode).toBe(0o600); + expect(e.optional).toBe(true); + }); +}); + +describe('borrowIngestTask', () => { + it('names the task the row declares, and nothing for an agent without one', () => { + expect(borrowIngestTask(openclaw)).toBe('openclaw-model-auth'); + expect(borrowIngestTask(codex)).toBeUndefined(); + }); +}); + +describe('planPropagateTargets and borrowing boxes', () => { + const boxes = [ + { id: 's', name: 'source', provider: 'docker', projectRoot: '/p', agents: ['codex'] }, + { + id: 'b', + name: 'bot', + provider: 'docker', + projectRoot: '/p', + agents: ['openclaw'], + borrowedCredentials: ['codex'], + }, + { id: 'c', name: 'claude-only', provider: 'docker', projectRoot: '/p', agents: ['claude'] }, + { + id: 'e', + name: 'cloud-bot', + provider: 'e2b', + projectRoot: '/p', + agents: ['openclaw'], + borrowedCredentials: ['codex'], + }, + ]; + + it('lists a borrowing box separately, on any provider, and never as a volume write', () => { + const plan = planPropagateTargets(boxes, { agent: 'codex', sourceBoxId: 's', scope: 'all' }); + expect(plan.borrowingBoxes.map((b) => b.id)).toEqual(['b', 'e']); + // The bot mounts no codex volume, so writing the shared one would reach + // nothing it reads. + expect(plan.dockerVolumes).toEqual([]); + expect(plan.cloudBoxes).toEqual([]); + }); + + it('still excludes a box that neither runs nor borrows the agent', () => { + const plan = planPropagateTargets(boxes, { agent: 'claude', sourceBoxId: 's', scope: 'all' }); + expect(plan.borrowingBoxes).toEqual([]); + expect(plan.dockerVolumes.flatMap((v) => v.boxNames)).toEqual(['claude-only']); + }); +}); diff --git a/packages/sandbox-core/test/carry-gate.test.ts b/packages/sandbox-core/test/carry-gate.test.ts new file mode 100644 index 000000000..1b6277a29 --- /dev/null +++ b/packages/sandbox-core/test/carry-gate.test.ts @@ -0,0 +1,133 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import type { CarryItem, PromptRequest } from '@agentbox/core'; +import { buildCarryPrompt, runCarryGate, toFileRow } from '../src/prompts/carry-gate.js'; +import { resolveCarry } from '../src/prompts/carry-resolve.js'; + +/** + * The carry gate with an injected asker — the same decision the CLI, the hub's + * preflight, and a replayed answer map all run. Touches a temp dir only. + */ +let root: string; +let src: string; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'agentbox-carry-gate-')); + src = join(root, 'secret.env'); + await writeFile(src, 'TOKEN=abc\n'); +}); + +const item = (over: Partial = {}): CarryItem => ({ + src, + dest: '~/.secret.env', + optional: false, + ...over, +}); + +/** An asker that always answers `value`, recording what it was shown. */ +function picks(value: string, seen?: PromptRequest[]) { + return async (req: PromptRequest) => { + seen?.push(req); + return { id: req.id, value }; + }; +} + +const refuses = () => { + throw new Error('prompt must not be shown'); +}; + +describe('runCarryGate', () => { + it('approves an empty block without asking', async () => { + const r = await runCarryGate({ projectRoot: root, items: [], ask: refuses }); + expect(r).toEqual({ decision: 'approve', entries: [] }); + }); + + it('maps each answer to its decision', async () => { + const base = { projectRoot: root, items: [item()] }; + const approved = await runCarryGate({ ...base, ask: picks('approve') }); + expect(approved.decision).toBe('approve'); + expect(approved.decision === 'approve' && approved.entries).toHaveLength(1); + + expect((await runCarryGate({ ...base, ask: picks('skip-this-run') })).decision).toBe('skip'); + expect((await runCarryGate({ ...base, ask: picks('cancel') })).decision).toBe('cancel'); + }); + + it('treats a dismissal, and an answer it does not understand, as cancel', async () => { + const base = { projectRoot: root, items: [item()] }; + const dismissed = await runCarryGate({ + ...base, + ask: async (req) => ({ id: req.id, value: 'approve', cancelled: true }), + }); + expect(dismissed.decision).toBe('cancel'); + expect((await runCarryGate({ ...base, ask: picks('who-knows') })).decision).toBe('cancel'); + }); + + it('lets the flags decide before anyone is asked', async () => { + const base = { projectRoot: root, items: [item()], ask: refuses }; + expect((await runCarryGate({ ...base, carryYes: true })).decision).toBe('approve'); + expect((await runCarryGate({ ...base, carrySkip: true })).decision).toBe('skip'); + }); + + it('throws on a resolver error before asking anything', async () => { + await expect( + runCarryGate({ + projectRoot: root, + items: [item({ src: join(root, 'nope.env') })], + ask: refuses, + }), + ).rejects.toThrow(/these files can't be copied/); + }); +}); + +describe('buildCarryPrompt', () => { + it('is required, cancels by default, and carries the file table', async () => { + const { entries } = await resolveCarry([item()], { projectRoot: root }); + const req = buildCarryPrompt(entries); + expect(req.topic).toBe('carry'); + expect(req.kind).toBe('select'); + // A silent answer would move host secrets, so there is no safe fallback. + expect(req.required).toBe(true); + expect(req.fallback.value).toBe('cancel'); + expect(req.nonInteractiveHint).toMatch(/AGENTBOX_CARRY_YES=1/); + expect(req.detail).toMatchObject({ type: 'file-table', totalBytes: 10 }); + expect(req.detail?.type === 'file-table' && req.detail.rows).toHaveLength(1); + }); + + it('is content-addressed over the question, not the run', async () => { + const { entries } = await resolveCarry([item()], { projectRoot: root }); + expect(buildCarryPrompt(entries).id).toBe(buildCarryPrompt(entries).id); + + // A changed destination is a different question, so a preflight answer for + // the old one cannot be replayed onto it. + const other = await resolveCarry([item({ dest: '~/.elsewhere.env' })], { projectRoot: root }); + expect(buildCarryPrompt(entries).id).not.toBe(buildCarryPrompt(other.entries).id); + }); +}); + +describe('toFileRow', () => { + it('describes a missing entry and a folder in plain words', async () => { + const dir = join(root, 'tree'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'a'), 'x'); + const { entries } = await resolveCarry( + [item({ src: join(root, 'absent'), optional: true }), item({ src: dir, dest: '~/tree' })], + { projectRoot: root }, + ); + const rows = entries.map(toFileRow); + expect(rows[0]!.flags).toContain('not on this machine'); + expect(rows[0]!.kind).toBe('missing'); + expect(rows[0]!.bytes).toBeUndefined(); + expect(rows[1]!.flags).toContain('folder'); + }); + + it('formats mode as octal and keeps an explicit uid', async () => { + const { entries } = await resolveCarry([item({ mode: 0o600, user: 0 })], { + projectRoot: root, + }); + const row = toFileRow(entries[0]!); + expect(row.mode).toBe('0600'); + expect(row.user).toBe(0); + }); +}); diff --git a/apps/cli/test/carry-resolve.test.ts b/packages/sandbox-core/test/carry-resolve.test.ts similarity index 75% rename from apps/cli/test/carry-resolve.test.ts rename to packages/sandbox-core/test/carry-resolve.test.ts index 67da4b640..8b29a2ddd 100644 --- a/apps/cli/test/carry-resolve.test.ts +++ b/packages/sandbox-core/test/carry-resolve.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; -import type { CarryItem } from '@agentbox/ctl'; -import { resolveCarry } from '../src/lib/carry-resolve.js'; +import type { CarryItem } from '@agentbox/core'; +import { resolveCarry } from '../src/prompts/carry-resolve.js'; let workspace: string; let home: string; @@ -33,10 +33,10 @@ describe('resolveCarry', () => { it('anchors ./relative to projectRoot, not process.cwd()', async () => { await writeFile(join(workspace, 'rel.txt'), 'hello'); - const res = await resolveCarry( - [item('./rel.txt', '/workspace/rel.txt')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./rel.txt', '/workspace/rel.txt')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.errors).toEqual([]); expect(res.entries[0]?.absSrc).toBe(join(workspace, 'rel.txt')); }); @@ -51,10 +51,10 @@ describe('resolveCarry', () => { }); it('missing src + optional:true → entry with kind=missing, no error', async () => { - const res = await resolveCarry( - [item('/no/such/file', '/dest', { optional: true })], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('/no/such/file', '/dest', { optional: true })], { + projectRoot: workspace, + homeDir: home, + }); expect(res.errors).toEqual([]); expect(res.entries[0]?.kind).toBe('missing'); expect(res.entries[0]?.optional).toBe(true); @@ -63,20 +63,17 @@ describe('resolveCarry', () => { it('rejects dest under /proc, /sys, /dev, and the exact /etc/passwd', async () => { await writeFile(join(workspace, 'a'), 'x'); for (const bad of ['/proc/1/maps', '/sys/x', '/dev/null', '/etc/passwd', '/etc/shadow']) { - const res = await resolveCarry( - [item('./a', bad)], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./a', bad)], { projectRoot: workspace, homeDir: home }); expect(res.errors[0]).toMatch(/denylist/); } }); it('rejects dest containing ..', async () => { await writeFile(join(workspace, 'a'), 'x'); - const res = await resolveCarry( - [item('./a', '/home/vscode/../etc/passwd')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./a', '/home/vscode/../etc/passwd')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.errors[0]).toMatch(/contains \.\./); }); @@ -84,10 +81,10 @@ describe('resolveCarry', () => { const outside = await mkdtemp(join(tmpdir(), 'carry-outside-')); await writeFile(join(outside, 'target'), 'x'); await symlink(join(outside, 'target'), join(workspace, 'link')); - const res = await resolveCarry( - [item('./link', '/workspace/link')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./link', '/workspace/link')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.errors).toEqual([]); expect(res.entries[0]?.symlinkInfo).toBe('outside-home'); }); @@ -95,19 +92,20 @@ describe('resolveCarry', () => { it('safe symlinks (target inside projectRoot) are not flagged outside-home', async () => { await writeFile(join(workspace, 'target'), 'x'); await symlink(join(workspace, 'target'), join(workspace, 'safe-link')); - const res = await resolveCarry( - [item('./safe-link', '/workspace/x')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./safe-link', '/workspace/x')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.entries[0]?.symlinkInfo).toBe('safe'); }); it('respects per-entry size cap (file too large)', async () => { await writeFile(join(workspace, 'big'), Buffer.alloc(100)); - const res = await resolveCarry( - [item('./big', '/dst')], - { projectRoot: workspace, homeDir: home, maxBytes: 50 }, - ); + const res = await resolveCarry([item('./big', '/dst')], { + projectRoot: workspace, + homeDir: home, + maxBytes: 50, + }); expect(res.errors[0]).toMatch(/exceeds/); }); @@ -115,10 +113,11 @@ describe('resolveCarry', () => { await mkdir(join(workspace, 'd')); await writeFile(join(workspace, 'd', 'a'), Buffer.alloc(60)); await writeFile(join(workspace, 'd', 'b'), Buffer.alloc(60)); - const res = await resolveCarry( - [item('./d', '/dst')], - { projectRoot: workspace, homeDir: home, maxBytes: 100 }, - ); + const res = await resolveCarry([item('./d', '/dst')], { + projectRoot: workspace, + homeDir: home, + maxBytes: 100, + }); expect(res.errors[0]).toMatch(/exceeds/); }); @@ -126,29 +125,30 @@ describe('resolveCarry', () => { await mkdir(join(workspace, 'small')); await writeFile(join(workspace, 'small', 'a'), 'aa'); await writeFile(join(workspace, 'small', 'b'), 'bbb'); - const res = await resolveCarry( - [item('./small', '/dst')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('./small', '/dst')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.entries[0]?.kind).toBe('dir'); expect(res.entries[0]?.bytes).toBe(5); }); it('maxBytes (box.cpMaxBytes) cap applies', async () => { await writeFile(join(workspace, 'big'), Buffer.alloc(200)); - const res = await resolveCarry( - [item('./big', '/dst')], - { projectRoot: workspace, homeDir: home, maxBytes: 100 }, - ); + const res = await resolveCarry([item('./big', '/dst')], { + projectRoot: workspace, + homeDir: home, + maxBytes: 100, + }); expect(res.errors[0]).toMatch(/exceeds/); }); it('preserves user-typed rawSrc / rawDest for the prompt', async () => { await writeFile(join(home, 'x.env'), 'X'); - const res = await resolveCarry( - [item('~/x.env', '~/x.env')], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('~/x.env', '~/x.env')], { + projectRoot: workspace, + homeDir: home, + }); expect(res.entries[0]?.rawSrc).toBe('~/x.env'); expect(res.entries[0]?.rawDest).toBe('~/x.env'); expect(res.entries[0]?.absDest).toBe('~/x.env'); @@ -156,17 +156,23 @@ describe('resolveCarry', () => { it('carries the mode through to the resolved entry', async () => { await writeFile(join(home, 'k'), 'x'); - const res = await resolveCarry( - [item('~/k', '~/k', { mode: 0o600 })], - { projectRoot: workspace, homeDir: home }, - ); + const res = await resolveCarry([item('~/k', '~/k', { mode: 0o600 })], { + projectRoot: workspace, + homeDir: home, + }); expect(res.entries[0]?.mode).toBe(0o600); }); it('expands named rule-set refs + inline rules onto a file entry', async () => { await writeFile(join(home, 'e'), 'x'); const res = await resolveCarry( - [item('~/e', '/workspace/e', { replaceEnvs: true, rules: ['host'], replace: [{ from: 'a', to: 'b' }] })], + [ + item('~/e', '/workspace/e', { + replaceEnvs: true, + rules: ['host'], + replace: [{ from: 'a', to: 'b' }], + }), + ], { projectRoot: workspace, homeDir: home, diff --git a/apps/cli/test/dir-breakdown.test.ts b/packages/sandbox-core/test/dir-breakdown.test.ts similarity index 98% rename from apps/cli/test/dir-breakdown.test.ts rename to packages/sandbox-core/test/dir-breakdown.test.ts index 178950104..1c19ab7e2 100644 --- a/apps/cli/test/dir-breakdown.test.ts +++ b/packages/sandbox-core/test/dir-breakdown.test.ts @@ -9,7 +9,7 @@ import { isPathExcluded, measureCopy, toTarExcludes, -} from '../src/lib/dir-breakdown.js'; +} from '../src/prompts/dir-breakdown.js'; describe('effectiveExcludes', () => { it('prepends defaults then user tokens, de-duped', () => { diff --git a/packages/sandbox-core/test/host-stage.test.ts b/packages/sandbox-core/test/host-stage.test.ts index f705cc6a1..5c0abb8fc 100644 --- a/packages/sandbox-core/test/host-stage.test.ts +++ b/packages/sandbox-core/test/host-stage.test.ts @@ -3,6 +3,8 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execa } from 'execa'; +import { createServer } from 'node:net'; +import { existsSync } from 'node:fs'; import { stageAgentStaticForUpload } from '../src/sync/host-stage.js'; /** Every path inside a staged tarball, relative and without the leading `./`. */ @@ -21,11 +23,25 @@ async function tarEntries(tarball: string): Promise { */ const SUBPROCESS_TIMEOUT_MS = 30_000; +/** sockaddr_un caps a unix socket path at 104 bytes on macOS. */ +const SHORT_TMP = existsSync('/tmp') ? '/tmp' : tmpdir(); + async function writeFileAt(path: string, body: string): Promise { await mkdir(join(path, '..'), { recursive: true }); await writeFile(path, body); } +/** A live unix socket at `path`, closed when the returned callback runs. */ +async function unixSocketAt(path: string): Promise<() => Promise> { + await mkdir(join(path, '..'), { recursive: true }); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, resolve); + }); + return () => new Promise((resolve) => server.close(() => resolve())); +} + describe('stageAgentStaticForUpload', () => { it( "reproduces opencode's two-source layout from the registry row alone", @@ -98,4 +114,36 @@ describe('stageAgentStaticForUpload', () => { }, SUBPROCESS_TIMEOUT_MS, ); + + it( + 'skips a unix socket in the source tree instead of aborting the stage', + async () => { + // Codex's desktop app leaves a live `~/.codex/ipc/ipc.sock` behind. `rsync + // -a` implies `-D`, so it tries to recreate the socket in the stage dir — + // and on macOS that bind() fails with EINVAL once the stage path passes + // sockaddr_un's 104-byte limit, taking the whole bake down with exit 23. + // Under a short root: macOS's own tmpdir() is already long enough that + // *binding* the fixture socket would fail before rsync ever sees it. The + // stage dir rsync writes into still comes from tmpdir(), so the failure + // this guards against is reproduced, not sidestepped. + const home = await mkdtemp(join(SHORT_TMP, 'ab-stage-')); + let closeSocket: (() => Promise) | null = null; + try { + const data = join(home, '.local', 'share', 'opencode'); + await writeFileAt(join(data, 'model.json'), '{}'); + closeSocket = await unixSocketAt(join(data, 'ipc', 'ipc.sock')); + + const res = await stageAgentStaticForUpload('opencode', { hostHome: home }); + expect(res.tarballPath).not.toBeNull(); + const entries = await tarEntries(res.tarballPath as string); + await res.cleanup(); + expect(entries).toContain('model.json'); + expect(entries).not.toContain('ipc/ipc.sock'); + } finally { + if (closeSocket) await closeSocket(); + await rm(home, { recursive: true, force: true }); + } + }, + SUBPROCESS_TIMEOUT_MS, + ); }); diff --git a/packages/sandbox-core/test/model-auth-gate.test.ts b/packages/sandbox-core/test/model-auth-gate.test.ts new file mode 100644 index 000000000..5ce520cb4 --- /dev/null +++ b/packages/sandbox-core/test/model-auth-gate.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import type { PromptRequest } from '@agentbox/core'; +import { resolveAgentSpec } from '../src/index.js'; +import { + buildModelAuthPrompt, + resolveModelAuth, + type AvailableBorrow, +} from '../src/prompts/model-auth-gate.js'; + +/** + * The host-boundary decision for a borrowed model login. Pure: the host-login + * probe and the asker are injected, so nothing here touches ~/.agentbox. + */ +const openclaw = resolveAgentSpec('openclaw'); +const codex = resolveAgentSpec('codex'); +const none = { modelAuth: 'none' } as const; + +const CODEX_BORROW: AvailableBorrow = { + agent: 'codex', + label: 'Your Codex login (ChatGPT subscription OAuth)', + hostPath: '/home/u/.codex/auth.json', + boxPath: '/home/vscode/.codex/auth.json', + bytes: 512, +}; + +function args(over: Partial[0]> = {}) { + return { + spec: openclaw, + settings: none, + configuredExplicitly: false, + listAvailable: async () => [CODEX_BORROW], + ask: () => { + throw new Error('prompt must not be shown'); + }, + ...over, + } as Parameters[0]; +} + +/** An asker that always picks `value`, recording what it was shown. */ +function picks(value: string, seen?: PromptRequest[]) { + return async (req: PromptRequest) => { + seen?.push(req); + return { id: req.id, value }; + }; +} + +describe('resolveModelAuth', () => { + it('--model-auth wins outright, and none means none', async () => { + expect(await resolveModelAuth(args({ flag: 'codex' }))).toEqual(['codex']); + expect(await resolveModelAuth(args({ flag: 'none' }))).toEqual([]); + }); + + it('refuses a flag value the row does not declare', async () => { + await expect(resolveModelAuth(args({ flag: 'claude' }))).rejects.toThrow(/declares codex/); + }); + + it('refuses the flag on an agent that borrows nothing', async () => { + await expect(resolveModelAuth(args({ spec: codex, flag: 'codex' }))).rejects.toThrow( + /borrows no host login/, + ); + expect(await resolveModelAuth(args({ spec: codex }))).toEqual([]); + }); + + it('reads the config key when the user set it', async () => { + expect( + await resolveModelAuth( + args({ settings: { modelAuth: 'codex' }, configuredExplicitly: true }), + ), + ).toEqual(['codex']); + }); + + it('an explicit `none` in config silences the prompt', async () => { + expect(await resolveModelAuth(args({ configuredExplicitly: true }))).toEqual([]); + }); + + it('asks when nothing chose, and honours either answer', async () => { + const seen: PromptRequest[] = []; + expect(await resolveModelAuth(args({ ask: picks('none', seen) }))).toEqual([]); + expect(seen).toHaveLength(1); + expect(seen[0]!.topic).toBe('model-auth'); + expect(seen[0]!.kind).toBe('select'); + // Declining must be the pre-selected answer: copying a subscription login + // into a daemon is never the calm default. + expect(seen[0]!.defaultValue).toBe('none'); + expect(await resolveModelAuth(args({ ask: picks('codex') }))).toEqual(['codex']); + }); + + it('treats a dismissal as none', async () => { + expect( + await resolveModelAuth( + args({ ask: async (req) => ({ id: req.id, value: 'codex', cancelled: true }) }), + ), + ).toEqual([]); + }); + + it('never asks when the host holds none of the declared logins', async () => { + expect(await resolveModelAuth(args({ listAvailable: async () => [] }))).toEqual([]); + }); + + it('refuses an answer naming a borrow the row does not declare', async () => { + await expect(resolveModelAuth(args({ ask: picks('claude') }))).rejects.toThrow( + /declares codex/, + ); + }); +}); + +describe('buildModelAuthPrompt', () => { + it('is a plain yes/no for the single-borrow case, with the credential detail', () => { + const req = buildModelAuthPrompt('openclaw', [CODEX_BORROW]); + // Yes carries the borrow; the card below already names which login it is, + // so repeating it on the button would only overflow the row. + expect(req.choices).toEqual([ + { value: 'codex', label: 'Yes' }, + { value: 'none', label: 'No' }, + ]); + expect(req.detail).toMatchObject({ + type: 'credential', + agent: 'codex', + hostPath: '/home/u/.codex/auth.json', + boxPath: '/home/vscode/.codex/auth.json', + }); + // Declining is safe, so an asker that cannot reach a human takes it. + expect(req.required).toBeUndefined(); + expect(req.fallback.value).toBe('none'); + }); + + it('is content-addressed: the same offer is the same id, a different one is not', () => { + const a = buildModelAuthPrompt('openclaw', [CODEX_BORROW]); + const b = buildModelAuthPrompt('openclaw', [CODEX_BORROW]); + const c = buildModelAuthPrompt('openclaw', [{ ...CODEX_BORROW, hostPath: '/elsewhere' }]); + expect(a.id).toBe(b.id); + expect(a.id).not.toBe(c.id); + }); +}); diff --git a/packages/sandbox-core/test/service-url.test.ts b/packages/sandbox-core/test/service-url.test.ts index be4ff9809..73fc37742 100644 --- a/packages/sandbox-core/test/service-url.test.ts +++ b/packages/sandbox-core/test/service-url.test.ts @@ -4,6 +4,7 @@ import { readServiceUrlFields, serviceAgentForBox, serviceSignInUrl, + withServiceSignIn, } from '../src/sync/concerns/service-url.js'; const box = { id: 'b1', name: 'ada' } as unknown as BoxRecord; @@ -133,3 +134,43 @@ describe('serviceAgentForBox', () => { expect(serviceAgentForBox({ lastAgent: 'ghost', agents: ['openclaw'] })?.id).toBe('openclaw'); }); }); + +describe('withServiceSignIn', () => { + const bot = { id: 'b1', name: 'ada', lastAgent: 'openclaw' } as unknown as BoxRecord; + + it("signs the URL the VNC desktop's own browser is pointed at", async () => { + // The in-box browser resolves its own target (the host's forwarded port is + // nothing inside the box), which is how it ended up as the one surface + // still opening openclaw on its token prompt. + const { provider } = fakeProvider(() => ({ stdout: DASHBOARD_JSON })); + expect(await withServiceSignIn(provider, bot, 'http://localhost:18789')).toBe( + 'http://localhost:18789/#token=09c33b1dcf0bdcde', + ); + }); + + it("leaves an ordinary box's URL alone, without exec'ing into it", async () => { + const claudeBox = { id: 'b2', name: 'bob', lastAgent: 'claude' } as unknown as BoxRecord; + const { provider, calls } = fakeProvider(() => ({ stdout: DASHBOARD_JSON })); + expect(await withServiceSignIn(provider, claudeBox, 'http://localhost:3000')).toBe( + 'http://localhost:3000', + ); + expect(calls).toEqual([]); + }); + + it('falls back to the bare URL when the daemon has no token yet', async () => { + // Mid-onboard: a sign-in prompt still beats opening nothing. + const { provider } = fakeProvider(() => ({ stdout: '', exitCode: 1 })); + expect(await withServiceSignIn(provider, bot, 'http://localhost:18789')).toBe( + 'http://localhost:18789', + ); + }); + + it('falls back to the bare URL when the exec itself throws', async () => { + const provider = { + exec: () => Promise.reject(new Error('box is paused')), + } as unknown as Provider; + expect(await withServiceSignIn(provider, bot, 'http://localhost:18789')).toBe( + 'http://localhost:18789', + ); + }); +}); diff --git a/packages/sandbox-docker/src/browser.ts b/packages/sandbox-docker/src/browser.ts index 1eae6e72c..d849d0869 100644 --- a/packages/sandbox-docker/src/browser.ts +++ b/packages/sandbox-docker/src/browser.ts @@ -1,3 +1,5 @@ +import type { BoxRecord, Provider } from '@agentbox/core'; +import { withServiceSignIn } from '@agentbox/sandbox-core'; import { execInBox } from './docker.js'; import { readBoxStatus } from './sync/host-export.js'; @@ -117,19 +119,22 @@ export interface BoxBrowserAppResult extends BoxBrowserResult { * `agentbox screen`, the dashboard, and the hub's open-VNC action so every * surface that opens the VNC viewer gets a populated desktop. */ -export async function ensureBoxBrowserShowingApp(box: { - container: string; - id: string; - name: string; - projectIndex?: number; - portlessUrl?: string; -}): Promise { +export async function ensureBoxBrowserShowingApp( + box: BoxRecord & { portlessUrl?: string }, + provider?: Provider, +): Promise { const persisted = await readBoxStatus(box); const exposePort = persisted?.services.find((s) => s.expose)?.expose?.port; - const target = + const base = exposePort !== undefined ? (box.portlessUrl ?? `http://localhost:${String(exposePort)}`) : 'about:blank'; + // A service agent's UI wants its token in the fragment, and the in-box + // browser is as much a client of that as the host's is. Needs the provider to + // read the token out of the box, so a caller that has none still gets the old + // bare URL rather than an error. + const target = + provider && base !== 'about:blank' ? await withServiceSignIn(provider, box, base) : base; const res = await ensureBoxBrowser(box.container, undefined, target); return { ...res, target }; } diff --git a/packages/sandbox-docker/src/create.ts b/packages/sandbox-docker/src/create.ts index 5be8002c0..2388bf31e 100644 --- a/packages/sandbox-docker/src/create.ts +++ b/packages/sandbox-docker/src/create.ts @@ -6,6 +6,7 @@ import { ConfigError, loadConfig } from '@agentbox/ctl'; import { skipWebProxyAlias } from './direct-web-url.js'; import { AGENT_SYNC_SPECS, + borrowedCredentialCarry, findAgentSpec, makeSyncContext, relayPort, @@ -169,6 +170,12 @@ export interface CreateBoxOptions { * a live box on demand. */ agents?: string[]; + /** + * Other agents' host logins seeded as model auth for a service agent, at + * each lender's own credential path (`AgentSyncSpec.modelAuth`). Already + * validated against the agent's declaration by the caller. + */ + borrowCredentials?: string[]; /** * Claude Code config volume. When omitted, defaults to `{ isolate: false }` — * every box mounts the shared `agentbox-claude-config` volume at @@ -965,6 +972,9 @@ export async function createBox(opts: CreateBoxOptions): Promise { // create` record which agent the checkpoint carries. Distinct from // `lastAgent`, which tracks whichever agent most recently ran. ...(opts.agents && opts.agents.length > 0 ? { agents: [...opts.agents] } : {}), + ...(opts.borrowCredentials && opts.borrowCredentials.length > 0 + ? { borrowedCredentials: [...opts.borrowCredentials] } + : {}), withPlaywright: opts.withPlaywright ? true : undefined, withEnv: opts.withEnv ? true : undefined, autoApproveHostActions: autoApproveHostActions ? true : undefined, @@ -1122,6 +1132,58 @@ export async function createBox(opts: CreateBoxOptions): Promise { await repairIdeOwnership(containerName); log('.vscode-server + .cursor-server ownership verified'); + if (opts.withEnv) { + log('copying host env/config files into /workspace (--with-env)'); + const { copied } = await sync.seedEnvFiles(syncCtx, DEFAULT_ENV_PATTERNS); + log(copied > 0 ? `copied ${String(copied)} env/config file(s)` : 'no env/config files found'); + } + + if (opts.envFilesToImport && opts.envFilesToImport.length > 0) { + log( + `copying ${String(opts.envFilesToImport.length)} selected env/config file(s) into /workspace`, + ); + const { copied } = await copyHostFilesToBox({ + container: containerName, + workspaceDir: workspace, + files: opts.envFilesToImport, + onLog: log, + }); + if (copied !== opts.envFilesToImport.length) { + log( + `copied ${String(copied)}/${String(opts.envFilesToImport.length)} selected env/config file(s)`, + ); + } + } + + // carry: from agentbox.yaml — resolved and approved by the host CLI, then + // threaded in here. Runs after the env-file copies and BEFORE the ctl daemon + // launches, so the first supervisor task can already see e.g. + // ~/.agentbox/secrets.env or a borrowed model login. It used to run after + // the daemon, which raced a task that reads a carried file on first boot. + let carrySummary: BoxRecord['carry'] | undefined; + // The agent's own per-box files (a bot's channel tokens) join the user's + // approved entries here, where the final box name exists — the source path is + // keyed by it, which is what keeps two bots from sharing one token. + const carryEntries = await withPerBoxCarry( + opts.carry, + (opts.agents ?? []).map((a) => findAgentSpec(a)), + { boxName: syncCtx.boxName }, + log, + ); + // A borrowed model login (`borrowCredentials`) is the same kind of thing as + // a per-box channel token — a host file the agent asked for, landing 0600 at + // a path it named — so it rides the same step. Validated by the caller. + carryEntries.push(...(await borrowedCredentialCarry(opts.borrowCredentials ?? [], log))); + if (carryEntries.length > 0) { + log(`carry: copying ${String(carryEntries.length)} host path(s) into the box`); + const result = await sync.applyCarry(syncCtx, carryEntries); + log(`carry: copied ${String(result.copied)}/${String(carryEntries.length)} entry/entries`); + for (const err of result.errors) log(`carry: ${err}`); + if (result.applied.length > 0) { + carrySummary = { count: result.applied.length, entries: result.applied }; + } + } + // dockerd: always-on, mirrors launchVncDaemon. Launched (and awaited ready) // BEFORE the ctl supervisor: the supervisor starts agentbox.yaml services as // soon as it's up, so a `docker run`/`docker compose` service must not race a @@ -1168,52 +1230,6 @@ export async function createBox(opts: CreateBoxOptions): Promise { log('@playwright/cli installed'); } - if (opts.withEnv) { - log('copying host env/config files into /workspace (--with-env)'); - const { copied } = await sync.seedEnvFiles(syncCtx, DEFAULT_ENV_PATTERNS); - log(copied > 0 ? `copied ${String(copied)} env/config file(s)` : 'no env/config files found'); - } - - if (opts.envFilesToImport && opts.envFilesToImport.length > 0) { - log( - `copying ${String(opts.envFilesToImport.length)} selected env/config file(s) into /workspace`, - ); - const { copied } = await copyHostFilesToBox({ - container: containerName, - workspaceDir: workspace, - files: opts.envFilesToImport, - onLog: log, - }); - if (copied !== opts.envFilesToImport.length) { - log( - `copied ${String(copied)}/${String(opts.envFilesToImport.length)} selected env/config file(s)`, - ); - } - } - - // carry: from agentbox.yaml — resolved and approved by the host CLI, then - // threaded in here. Runs after the env-file copies and before the supervisor - // launches so the first task can already see e.g. ~/.agentbox/secrets.env. - let carrySummary: BoxRecord['carry'] | undefined; - // The agent's own per-box files (a bot's channel tokens) join the user's - // approved entries here, where the final box name exists — the source path is - // keyed by it, which is what keeps two bots from sharing one token. - const carryEntries = await withPerBoxCarry( - opts.carry, - (opts.agents ?? []).map((a) => findAgentSpec(a)), - { boxName: syncCtx.boxName }, - log, - ); - if (carryEntries.length > 0) { - log(`carry: copying ${String(carryEntries.length)} host path(s) into the box`); - const result = await sync.applyCarry(syncCtx, carryEntries); - log(`carry: copied ${String(result.copied)}/${String(carryEntries.length)} entry/entries`); - for (const err of result.errors) log(`carry: ${err}`); - if (result.applied.length > 0) { - carrySummary = { count: result.applied.length, entries: result.applied }; - } - } - // VNC daemon (Xvnc + websockify). Best-effort, like launchCtlDaemon. The // host port mapping was wired into runBox above (hostPort=0 → random); we // resolve the assigned port here for storage. If the daemon fails to come diff --git a/packages/sandbox-docker/src/docker-provider.ts b/packages/sandbox-docker/src/docker-provider.ts index c126074f3..5a75394dc 100644 --- a/packages/sandbox-docker/src/docker-provider.ts +++ b/packages/sandbox-docker/src/docker-provider.ts @@ -78,6 +78,7 @@ export const dockerProvider: Provider = { // hub / control-plane create only ever arrives as a CreateBoxRequest — // so without this those boxes silently keep the all-agents behaviour. ...(req.agents ? { agents: req.agents } : {}), + ...(req.borrowCredentials ? { borrowCredentials: req.borrowCredentials } : {}), claudeConfig: po.claudeConfig, claudeEnv: po.claudeEnv, codexConfig: po.codexConfig,