Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/cli/src/agents/command/service-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
* host involvement at all. This command only waits for it.
*/

import { findProjectRoot, loadEffectiveConfig, resolveBoxImage } from '@agentbox/config';
import {
agentSettings,
findProjectRoot,
loadEffectiveConfig,
resolveBoxImage,
} from '@agentbox/config';
import type {
AgentServiceUrlField,
AgentSyncSpec,
Expand All @@ -39,6 +44,7 @@ import {
import { portlessUnalias, readBoxStatus, recordLastAgent } from '@agentbox/sandbox-docker';
import { webProxyWarning } from '../../lib/web-proxy-warning.js';
import { runCarryGate } from '../../lib/carry-gate.js';
import { resolveModelAuth } from '../../lib/model-auth-gate.js';
import { handleLifecycleError } from '../../commands/_errors.js';
import { providerForBox, providerForCreate } from '../../provider/registry.js';
import {
Expand Down Expand Up @@ -74,6 +80,8 @@ export interface ServiceAgentOptions {
persistent?: boolean;
/** Seconds to wait for the service to report ready. */
timeout?: string;
/** `--model-auth <source>`: which host login to seed as the model provider. */
modelAuth?: string;
/** `--restore <bot>`: recreate that bot from its backup, identity included. */
restore?: string;
/** `--stamp <s>`: which backup (default: the `latest` link). */
Expand Down Expand Up @@ -402,6 +410,19 @@ export async function runServiceAgent(
// derived from the registry row's `caps.surface`, exactly like the
// config-volume isolation below, never from an agent id. `--no-persistent`
// is the opt-out; `undefined` leaves the call to `box.persistent`.
// Which host login, if any, the box borrows as its model provider.
// Decided here, at the host boundary, before anything is created: a
// refused value must not cost a box, and a prompt must not appear
// under a spinner.
const borrowCredentials = await resolveModelAuth({
spec,
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 —
Expand All @@ -424,6 +445,7 @@ export async function runServiceAgent(
// This box is FOR this agent: only its credentials and config are
// wired in.
agents: [spec.id],
...(borrowCredentials.length > 0 ? { borrowCredentials } : {}),
image: resolveBoxImage(cfg, provider.name),
checkpointRef: opts.snapshot,
withPlaywright: cfg.box.withPlaywright,
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/agents/command/service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -102,6 +117,7 @@ export function buildServiceAgentCommand(spec: AgentSyncSpec): Command {
)
.option('--timeout <seconds>', 'how long to wait for the service to report ready', '180')
.option('--verbose', 'stream create progress instead of a spinner')
.option('--model-auth <source>', modelAuthHelp(spec))
.option(
'--restore <bot>',
`recreate a bot from its backup under <project>/.agentbox/bots/<bot>/: the box runs on a copy of the backed-up workspace AND gets the captured ${spec.id} state dir back, identity included. Always creates a new box`,
Expand Down
101 changes: 0 additions & 101 deletions apps/cli/src/carry-prompt.ts

This file was deleted.

2 changes: 2 additions & 0 deletions apps/cli/src/commands/_run-queued-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.isolate<Agent>Config` 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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/src/commands/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Command } from 'commander';
import {
borrowIngestTask,
findAgentSpec,
isRealAgentCredential,
planPropagateTargets,
pushCredentialToBox,
Expand Down Expand Up @@ -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` : '') +
Expand Down
18 changes: 11 additions & 7 deletions apps/cli/src/commands/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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'
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/control-plane/hub-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface HubApiBox {
webPort?: number;
previewUrls?: Record<number, string>;
lastAgent?: AgentId;
/** Other agents' logins this box borrows as model auth. */
borrowedCredentials?: string[];
topology?: string;
}

Expand Down
Loading
Loading