@phnx-labs/agents-cli 1.20.32 → 1.20.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +28 -2
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +51 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/import.js +1 -1
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +3 -3
- package/dist/commands/secrets-migrate.js +23 -11
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +77 -18
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +4 -3
- package/dist/commands/view.d.ts +39 -0
- package/dist/commands/view.js +217 -84
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +16 -2
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/agents.d.ts +12 -0
- package/dist/lib/agents.js +115 -32
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +19 -0
- package/dist/lib/browser/drivers/ssh.js +18 -3
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/doctor-diff.js +29 -2
- package/dist/lib/drift-sync.d.ts +43 -0
- package/dist/lib/drift-sync.js +179 -0
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +21 -11
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/pwsh.d.ts +11 -0
- package/dist/lib/pwsh.js +13 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +42 -1
- package/dist/lib/secrets/agent.js +89 -11
- package/dist/lib/secrets/bundles.js +61 -21
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +44 -1
- package/dist/lib/secrets/index.js +108 -10
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/shims.d.ts +20 -0
- package/dist/lib/shims.js +53 -20
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-status.d.ts +102 -0
- package/dist/lib/sync-status.js +135 -0
- package/dist/lib/teams/agents.d.ts +24 -0
- package/dist/lib/teams/agents.js +30 -1
- package/dist/lib/types.d.ts +20 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- package/dist/lib/versions.d.ts +2 -4
- package/dist/lib/versions.js +7 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- package/dist/lib/agent-spec.js +0 -157
|
@@ -51,6 +51,7 @@ const BROWSER_PATHS = {
|
|
|
51
51
|
edge: [
|
|
52
52
|
`${WIN_PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
53
53
|
`${WIN_PROGRAMFILES_X86}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
54
|
+
`${WIN_LOCALAPPDATA}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
54
55
|
],
|
|
55
56
|
custom: [],
|
|
56
57
|
},
|
|
@@ -282,6 +283,25 @@ export async function attachToChrome(port) {
|
|
|
282
283
|
return wsUrl;
|
|
283
284
|
}
|
|
284
285
|
export function killChrome(pid) {
|
|
286
|
+
if (process.platform === 'win32') {
|
|
287
|
+
// On Windows `process.kill(pid, 'SIGINT')` maps to TerminateProcess — a
|
|
288
|
+
// hard kill that skips Chromium's on-exit profile flush (leaving a dirty
|
|
289
|
+
// "Chrome didn't shut down correctly" state). Ask taskkill for a graceful
|
|
290
|
+
// stop first (no /F → posts WM_CLOSE), and only force-kill the tree if that
|
|
291
|
+
// fails or the process is still around.
|
|
292
|
+
try {
|
|
293
|
+
execFileSync('taskkill', ['/pid', String(pid)], { stdio: 'ignore' });
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
try {
|
|
297
|
+
execFileSync('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Process already dead
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
285
305
|
try {
|
|
286
306
|
process.kill(pid, 'SIGINT');
|
|
287
307
|
}
|
|
@@ -54,3 +54,22 @@ export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string,
|
|
|
54
54
|
*/
|
|
55
55
|
export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
|
|
56
56
|
export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Identify whether a pid listening on our target local port is an SSH
|
|
59
|
+
* tunnel WE would have spawned for `host:remotePort`. Used so that two
|
|
60
|
+
* agents-browser invocations of the same SSH profile share a tunnel
|
|
61
|
+
* rather than failing the second one with "port in use".
|
|
62
|
+
*
|
|
63
|
+
* Best-effort match against the ssh -L command line via `ps`. If we
|
|
64
|
+
* can't read the cmd or the args don't look like ours, treat as not-ours.
|
|
65
|
+
*
|
|
66
|
+
* Windows has no `ps`, so the POSIX branch below always throws there and every
|
|
67
|
+
* reuse check returned false — the second invocation to the same host:port then
|
|
68
|
+
* failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
|
|
69
|
+
* occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
|
|
70
|
+
* (localPort === remotePort in the caller), so the pid holding that port is our
|
|
71
|
+
* ssh.exe. tasklist only exposes the image name, not the full command line, so
|
|
72
|
+
* we can't match host/remotePort as tightly as the POSIX branch — an ssh
|
|
73
|
+
* process on our exact local port is our tunnel in practice.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isOwnTunnel(pid: number, host: string, remotePort: number): boolean;
|
|
@@ -12,6 +12,7 @@ export { shellQuote };
|
|
|
12
12
|
// the single ssh-tunnel helper. Calling it with no options preserves this
|
|
13
13
|
// driver's original foreground, stderr-captured behavior exactly.
|
|
14
14
|
import { startSSHTunnel } from '../../ssh-tunnel.js';
|
|
15
|
+
import { encodePwshBase64 } from '../../pwsh.js';
|
|
15
16
|
export async function connectSSH(endpoint, profile) {
|
|
16
17
|
const url = new URL(endpoint);
|
|
17
18
|
if (url.protocol !== 'ssh:') {
|
|
@@ -159,8 +160,7 @@ function psSingleQuote(s) {
|
|
|
159
160
|
* `powershell -Command "…"` is fragile the moment a path or URL is involved).
|
|
160
161
|
*/
|
|
161
162
|
export function encodePowerShell(script) {
|
|
162
|
-
|
|
163
|
-
return `powershell -NoProfile -EncodedCommand ${b64}`;
|
|
163
|
+
return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
|
|
164
164
|
}
|
|
165
165
|
/**
|
|
166
166
|
* The PowerShell that launches the browser on a Windows remote. Two hard
|
|
@@ -290,8 +290,23 @@ function sleep(ms) {
|
|
|
290
290
|
*
|
|
291
291
|
* Best-effort match against the ssh -L command line via `ps`. If we
|
|
292
292
|
* can't read the cmd or the args don't look like ours, treat as not-ours.
|
|
293
|
+
*
|
|
294
|
+
* Windows has no `ps`, so the POSIX branch below always throws there and every
|
|
295
|
+
* reuse check returned false — the second invocation to the same host:port then
|
|
296
|
+
* failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
|
|
297
|
+
* occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
|
|
298
|
+
* (localPort === remotePort in the caller), so the pid holding that port is our
|
|
299
|
+
* ssh.exe. tasklist only exposes the image name, not the full command line, so
|
|
300
|
+
* we can't match host/remotePort as tightly as the POSIX branch — an ssh
|
|
301
|
+
* process on our exact local port is our tunnel in practice.
|
|
293
302
|
*/
|
|
294
|
-
function isOwnTunnel(pid, host, remotePort) {
|
|
303
|
+
export function isOwnTunnel(pid, host, remotePort) {
|
|
304
|
+
if (process.platform === 'win32') {
|
|
305
|
+
const occupant = getPortOccupant(remotePort);
|
|
306
|
+
if (!occupant || occupant.pid !== pid)
|
|
307
|
+
return false;
|
|
308
|
+
return occupant.command.toLowerCase().startsWith('ssh');
|
|
309
|
+
}
|
|
295
310
|
try {
|
|
296
311
|
const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
297
312
|
encoding: 'utf-8',
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed wrapper over the external `crabbox` binary (github.com/openclaw/crabbox).
|
|
3
|
+
*
|
|
4
|
+
* crabbox leases ephemeral cloud boxes (Hetzner/DO/EC2/…), syncs the dirty
|
|
5
|
+
* checkout, and runs commands on them. We use it as the transport for
|
|
6
|
+
* `agents run --lease`: warm a box → run the agent on it via `crabbox run` →
|
|
7
|
+
* stop it. crabbox owns the SSH connection, so agents-cli never needs a direct
|
|
8
|
+
* ssh target (unlike the `agents hosts` model).
|
|
9
|
+
*
|
|
10
|
+
* crabbox talks to its cloud provider's API for list/status/warmup/stop, which
|
|
11
|
+
* needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
|
|
12
|
+
* from a secrets bundle when one is configured (see `crabboxEnv`).
|
|
13
|
+
*/
|
|
14
|
+
/** A crabbox machine as reported by `crabbox list --json`. */
|
|
15
|
+
export interface CrabboxBox {
|
|
16
|
+
/** Provider machine name, e.g. `crabbox-blue-hermit-1039689b`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Provider run state, e.g. `running`. */
|
|
19
|
+
status: string;
|
|
20
|
+
/** Friendly slug used with `--id`, e.g. `blue-hermit`. */
|
|
21
|
+
slug: string;
|
|
22
|
+
/** Lease id, e.g. `cbx_9968746bb15c`. */
|
|
23
|
+
lease: string;
|
|
24
|
+
/** crabbox bootstrap state; `ready` once the box is usable. */
|
|
25
|
+
state: string;
|
|
26
|
+
/** Public IPv4, when the provider exposes one. */
|
|
27
|
+
ip?: string;
|
|
28
|
+
profile?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
/** True when running + bootstrap-complete. */
|
|
31
|
+
ready: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface CrabboxOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Name of a secrets bundle whose env (e.g. `HCLOUD_TOKEN`) crabbox needs to
|
|
36
|
+
* reach its cloud provider. Resolved via agents-cli's own keychain-backed
|
|
37
|
+
* secrets. When unset, crabbox runs with the ambient environment / its own
|
|
38
|
+
* `crabbox login` credentials.
|
|
39
|
+
*/
|
|
40
|
+
secretsBundle?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Locate the crabbox binary, or throw an actionable error. */
|
|
43
|
+
export declare function findCrabbox(): string;
|
|
44
|
+
/** Build the child env for crabbox, injecting a secrets bundle when configured. */
|
|
45
|
+
export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
|
|
46
|
+
/** All crabbox machines the broker knows about. */
|
|
47
|
+
export declare function crabboxList(opts?: CrabboxOptions): CrabboxBox[];
|
|
48
|
+
/** Find one box by slug, or null. */
|
|
49
|
+
export declare function crabboxFind(slug: string, opts?: CrabboxOptions): CrabboxBox | null;
|
|
50
|
+
export interface WarmupOptions extends CrabboxOptions {
|
|
51
|
+
class?: string;
|
|
52
|
+
profile?: string;
|
|
53
|
+
/** Provision web code-server capability on the box. */
|
|
54
|
+
code?: boolean;
|
|
55
|
+
/** Cloud backend override (crabbox provider id, e.g. hetzner/aws/do). */
|
|
56
|
+
provider?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Lease a box and block until it is ready. Returns the leased box.
|
|
60
|
+
*
|
|
61
|
+
* We diff `crabbox list` before/after so we reliably identify the box this call
|
|
62
|
+
* created even if warmup's stdout format changes — the new lease id is the one
|
|
63
|
+
* that wasn't present before.
|
|
64
|
+
*/
|
|
65
|
+
export declare function crabboxWarmup(opts?: WarmupOptions): CrabboxBox;
|
|
66
|
+
/**
|
|
67
|
+
* Poll until the box reports ready, or throw after timeoutMs.
|
|
68
|
+
* `sleep` is injectable so tests don't wall-clock wait.
|
|
69
|
+
*/
|
|
70
|
+
export declare function crabboxWaitReady(slug: string, opts?: CrabboxOptions & {
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
intervalMs?: number;
|
|
73
|
+
sleep?: (ms: number) => Promise<void>;
|
|
74
|
+
}): Promise<CrabboxBox>;
|
|
75
|
+
export interface CrabboxRunOptions extends CrabboxOptions {
|
|
76
|
+
/** Called with each chunk of combined stdout/stderr as it streams. */
|
|
77
|
+
onData?: (chunk: string) => void;
|
|
78
|
+
/** Force a full remote resync before running. */
|
|
79
|
+
fullResync?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
|
|
83
|
+
* checkout and owns the SSH). Streams combined output; resolves with the remote
|
|
84
|
+
* exit code (or null if crabbox itself failed to dispatch).
|
|
85
|
+
*/
|
|
86
|
+
export declare function crabboxRun(slug: string, remoteCmd: string, opts?: CrabboxRunOptions): Promise<number | null>;
|
|
87
|
+
/**
|
|
88
|
+
* Upload `script` to the box via `crabbox run --script-stdin` and run it.
|
|
89
|
+
*
|
|
90
|
+
* The script body travels over stdin and is written to a file on the box before
|
|
91
|
+
* execution — it never appears in argv / `ps` / shell history, which is why this
|
|
92
|
+
* is the transport for credential provisioning (the token contents live only in
|
|
93
|
+
* the uploaded script, then the file is removed by the script itself).
|
|
94
|
+
* Streams combined output; resolves with the remote exit code (null on dispatch failure).
|
|
95
|
+
*/
|
|
96
|
+
export declare function crabboxRunScript(slug: string, script: string, opts?: CrabboxRunOptions): Promise<number | null>;
|
|
97
|
+
/** Release the lease / delete the box. Best-effort; never throws. */
|
|
98
|
+
export declare function crabboxStop(slug: string, opts?: CrabboxOptions): boolean;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed wrapper over the external `crabbox` binary (github.com/openclaw/crabbox).
|
|
3
|
+
*
|
|
4
|
+
* crabbox leases ephemeral cloud boxes (Hetzner/DO/EC2/…), syncs the dirty
|
|
5
|
+
* checkout, and runs commands on them. We use it as the transport for
|
|
6
|
+
* `agents run --lease`: warm a box → run the agent on it via `crabbox run` →
|
|
7
|
+
* stop it. crabbox owns the SSH connection, so agents-cli never needs a direct
|
|
8
|
+
* ssh target (unlike the `agents hosts` model).
|
|
9
|
+
*
|
|
10
|
+
* crabbox talks to its cloud provider's API for list/status/warmup/stop, which
|
|
11
|
+
* needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
|
|
12
|
+
* from a secrets bundle when one is configured (see `crabboxEnv`).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn, spawnSync } from 'child_process';
|
|
15
|
+
import { readAndResolveBundleEnv } from '../secrets/bundles.js';
|
|
16
|
+
/** Locate the crabbox binary, or throw an actionable error. */
|
|
17
|
+
export function findCrabbox() {
|
|
18
|
+
const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
|
|
19
|
+
if (r.error) {
|
|
20
|
+
throw new Error('crabbox is not installed or not on PATH. Install it and run `crabbox login`, then `crabbox doctor` to verify provider access.');
|
|
21
|
+
}
|
|
22
|
+
return 'crabbox';
|
|
23
|
+
}
|
|
24
|
+
/** Build the child env for crabbox, injecting a secrets bundle when configured. */
|
|
25
|
+
export function crabboxEnv(opts) {
|
|
26
|
+
const bundle = opts.secretsBundle ?? process.env.AGENTS_LEASE_SECRETS_BUNDLE;
|
|
27
|
+
if (!bundle)
|
|
28
|
+
return process.env;
|
|
29
|
+
try {
|
|
30
|
+
// Reuse the same resolver `agents secrets exec` uses so a keychain-backed
|
|
31
|
+
// bundle (e.g. hetzner.com → HCLOUD_TOKEN) reaches crabbox without ever
|
|
32
|
+
// touching disk.
|
|
33
|
+
const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents run --lease (crabbox)' });
|
|
34
|
+
return { ...process.env, ...env };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
throw new Error(`Could not load secrets bundle "${bundle}" for crabbox: ${e.message}. ` +
|
|
38
|
+
`Fix the bundle (agents secrets view ${bundle}) or unset lease.secretsBundle to use crabbox's own login.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function normalizeBox(raw) {
|
|
42
|
+
const labels = (raw.labels ?? {});
|
|
43
|
+
const slug = labels.slug ?? '';
|
|
44
|
+
if (!slug)
|
|
45
|
+
return null;
|
|
46
|
+
const status = String(raw.status ?? '');
|
|
47
|
+
const state = String(labels.state ?? '');
|
|
48
|
+
const publicNet = (raw.public_net ?? {});
|
|
49
|
+
return {
|
|
50
|
+
name: String(raw.name ?? ''),
|
|
51
|
+
status,
|
|
52
|
+
slug,
|
|
53
|
+
lease: labels.lease ?? '',
|
|
54
|
+
state,
|
|
55
|
+
ip: publicNet.ipv4?.ip || undefined,
|
|
56
|
+
profile: labels.profile,
|
|
57
|
+
class: labels.class,
|
|
58
|
+
ready: status === 'running' && state === 'ready',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** All crabbox machines the broker knows about. */
|
|
62
|
+
export function crabboxList(opts = {}) {
|
|
63
|
+
findCrabbox();
|
|
64
|
+
const r = spawnSync('crabbox', ['list', '--json'], { encoding: 'utf-8', env: crabboxEnv(opts) });
|
|
65
|
+
if (r.status !== 0) {
|
|
66
|
+
throw new Error(`crabbox list failed: ${(r.stderr || r.stdout || '').trim() || 'unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(r.stdout || '[]');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
return [];
|
|
77
|
+
return parsed.map((b) => normalizeBox(b)).filter((b) => b !== null);
|
|
78
|
+
}
|
|
79
|
+
/** Find one box by slug, or null. */
|
|
80
|
+
export function crabboxFind(slug, opts = {}) {
|
|
81
|
+
return crabboxList(opts).find((b) => b.slug === slug) ?? null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Lease a box and block until it is ready. Returns the leased box.
|
|
85
|
+
*
|
|
86
|
+
* We diff `crabbox list` before/after so we reliably identify the box this call
|
|
87
|
+
* created even if warmup's stdout format changes — the new lease id is the one
|
|
88
|
+
* that wasn't present before.
|
|
89
|
+
*/
|
|
90
|
+
export function crabboxWarmup(opts = {}) {
|
|
91
|
+
findCrabbox();
|
|
92
|
+
const env = crabboxEnv(opts);
|
|
93
|
+
const before = new Set(crabboxList(opts).map((b) => b.lease));
|
|
94
|
+
const args = ['warmup'];
|
|
95
|
+
if (opts.class)
|
|
96
|
+
args.push('--class', opts.class);
|
|
97
|
+
if (opts.profile)
|
|
98
|
+
args.push('--profile', opts.profile);
|
|
99
|
+
if (opts.provider)
|
|
100
|
+
args.push('--provider', opts.provider);
|
|
101
|
+
if (opts.code)
|
|
102
|
+
args.push('--code');
|
|
103
|
+
const r = spawnSync('crabbox', args, { encoding: 'utf-8', env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
104
|
+
if (r.status !== 0) {
|
|
105
|
+
const detail = (r.stderr || r.stdout || '').trim();
|
|
106
|
+
throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
|
|
107
|
+
`Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
|
|
108
|
+
}
|
|
109
|
+
// Prefer the freshly-created box (lease absent from the pre-warmup snapshot).
|
|
110
|
+
const after = crabboxList(opts);
|
|
111
|
+
const fresh = after.filter((b) => !before.has(b.lease));
|
|
112
|
+
if (fresh.length === 1)
|
|
113
|
+
return fresh[0];
|
|
114
|
+
// Fallback: parse the cbx_ lease id crabbox prints and match it.
|
|
115
|
+
const m = (r.stdout || '').match(/cbx_[0-9a-f]+/i);
|
|
116
|
+
if (m) {
|
|
117
|
+
const byLease = after.find((b) => b.lease === m[0]);
|
|
118
|
+
if (byLease)
|
|
119
|
+
return byLease;
|
|
120
|
+
}
|
|
121
|
+
if (fresh.length > 1) {
|
|
122
|
+
// Multiple new boxes (concurrent warmups) — pick the newest ready one.
|
|
123
|
+
const ready = fresh.filter((b) => b.ready);
|
|
124
|
+
if (ready.length)
|
|
125
|
+
return ready[ready.length - 1];
|
|
126
|
+
return fresh[fresh.length - 1];
|
|
127
|
+
}
|
|
128
|
+
throw new Error('crabbox warmup succeeded but the new box could not be located in `crabbox list`.');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Poll until the box reports ready, or throw after timeoutMs.
|
|
132
|
+
* `sleep` is injectable so tests don't wall-clock wait.
|
|
133
|
+
*/
|
|
134
|
+
export async function crabboxWaitReady(slug, opts = {}) {
|
|
135
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
136
|
+
const intervalMs = opts.intervalMs ?? 5_000;
|
|
137
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
let last = null;
|
|
140
|
+
// First check is immediate (warmup usually returns an already-ready box).
|
|
141
|
+
for (;;) {
|
|
142
|
+
last = crabboxFind(slug, opts);
|
|
143
|
+
if (last?.ready)
|
|
144
|
+
return last;
|
|
145
|
+
if (Date.now() >= deadline)
|
|
146
|
+
break;
|
|
147
|
+
await sleep(intervalMs);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`crabbox box "${slug}" did not become ready within ${Math.round(timeoutMs / 1000)}s (state: ${last?.state ?? 'gone'}).`);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
|
|
153
|
+
* checkout and owns the SSH). Streams combined output; resolves with the remote
|
|
154
|
+
* exit code (or null if crabbox itself failed to dispatch).
|
|
155
|
+
*/
|
|
156
|
+
export function crabboxRun(slug, remoteCmd, opts = {}) {
|
|
157
|
+
findCrabbox();
|
|
158
|
+
const args = ['run', '--id', slug, '--reclaim'];
|
|
159
|
+
if (opts.fullResync)
|
|
160
|
+
args.push('--full-resync');
|
|
161
|
+
args.push('--', 'bash', '-lc', remoteCmd);
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const proc = spawn('crabbox', args, { env: crabboxEnv(opts), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
164
|
+
const pump = (chunk) => {
|
|
165
|
+
const s = chunk.toString('utf-8');
|
|
166
|
+
if (opts.onData)
|
|
167
|
+
opts.onData(s);
|
|
168
|
+
else
|
|
169
|
+
process.stdout.write(s);
|
|
170
|
+
};
|
|
171
|
+
proc.stdout.on('data', pump);
|
|
172
|
+
proc.stderr.on('data', pump);
|
|
173
|
+
proc.on('error', () => resolve(null));
|
|
174
|
+
proc.on('close', (code) => resolve(code));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Upload `script` to the box via `crabbox run --script-stdin` and run it.
|
|
179
|
+
*
|
|
180
|
+
* The script body travels over stdin and is written to a file on the box before
|
|
181
|
+
* execution — it never appears in argv / `ps` / shell history, which is why this
|
|
182
|
+
* is the transport for credential provisioning (the token contents live only in
|
|
183
|
+
* the uploaded script, then the file is removed by the script itself).
|
|
184
|
+
* Streams combined output; resolves with the remote exit code (null on dispatch failure).
|
|
185
|
+
*/
|
|
186
|
+
export function crabboxRunScript(slug, script, opts = {}) {
|
|
187
|
+
findCrabbox();
|
|
188
|
+
const args = ['run', '--id', slug, '--reclaim'];
|
|
189
|
+
if (opts.fullResync)
|
|
190
|
+
args.push('--full-resync');
|
|
191
|
+
args.push('--script-stdin');
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const proc = spawn('crabbox', args, { env: crabboxEnv(opts), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
194
|
+
const pump = (chunk) => {
|
|
195
|
+
const s = chunk.toString('utf-8');
|
|
196
|
+
if (opts.onData)
|
|
197
|
+
opts.onData(s);
|
|
198
|
+
else
|
|
199
|
+
process.stdout.write(s);
|
|
200
|
+
};
|
|
201
|
+
proc.stdout.on('data', pump);
|
|
202
|
+
proc.stderr.on('data', pump);
|
|
203
|
+
proc.on('error', () => resolve(null));
|
|
204
|
+
proc.on('close', (code) => resolve(code));
|
|
205
|
+
proc.stdin.write(script);
|
|
206
|
+
proc.stdin.end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Release the lease / delete the box. Best-effort; never throws. */
|
|
210
|
+
export function crabboxStop(slug, opts = {}) {
|
|
211
|
+
try {
|
|
212
|
+
const r = spawnSync('crabbox', ['stop', '--id', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
|
|
213
|
+
return r.status === 0;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents run --lease` orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Lease an ephemeral crabbox → provision the picked runtime(s) + their
|
|
5
|
+
* credentials → run the agent on the box (via `crabbox run`, which owns the
|
|
6
|
+
* SSH) → tear the box down. The whole box-side sequence rides a single
|
|
7
|
+
* `--script-stdin` body so the token contents never touch argv.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from '../types.js';
|
|
10
|
+
import { type CrabboxBox } from './cli.js';
|
|
11
|
+
import { type DetectedRuntime } from './runtimes.js';
|
|
12
|
+
export interface LeaseRunOptions {
|
|
13
|
+
agent: string;
|
|
14
|
+
prompt: string;
|
|
15
|
+
mode?: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
/** Cloud backend crabbox provisions on (hetzner/aws/do/…). */
|
|
18
|
+
backend?: string;
|
|
19
|
+
boxClass?: string;
|
|
20
|
+
profile?: string;
|
|
21
|
+
/** Runtimes to install + authenticate on the box (from the picker). */
|
|
22
|
+
runtimes: AgentId[];
|
|
23
|
+
detected: DetectedRuntime[];
|
|
24
|
+
/** Secrets bundle providing crabbox's provider token. */
|
|
25
|
+
secretsBundle?: string;
|
|
26
|
+
onData?: (s: string) => void;
|
|
27
|
+
/** Keep the box after the run instead of stopping it. */
|
|
28
|
+
keep?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface LeaseRunResult {
|
|
31
|
+
box: CrabboxBox;
|
|
32
|
+
exitCode: number | null;
|
|
33
|
+
toreDown: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the single bootstrap script run on the box: ensure agents-cli, install
|
|
37
|
+
* the picked runtime CLIs, write their credentials, run the agent, then shred
|
|
38
|
+
* the credential files. Best-effort install steps never abort the run.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildBootstrapScript(opts: LeaseRunOptions): string;
|
|
41
|
+
export declare function leaseAndRun(opts: LeaseRunOptions): Promise<LeaseRunResult>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents run --lease` orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Lease an ephemeral crabbox → provision the picked runtime(s) + their
|
|
5
|
+
* credentials → run the agent on the box (via `crabbox run`, which owns the
|
|
6
|
+
* SSH) → tear the box down. The whole box-side sequence rides a single
|
|
7
|
+
* `--script-stdin` body so the token contents never touch argv.
|
|
8
|
+
*/
|
|
9
|
+
import { crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
|
|
10
|
+
import { buildCredentialScript } from './runtimes.js';
|
|
11
|
+
/** POSIX single-quote for safe embedding in the generated bootstrap script. */
|
|
12
|
+
function q(s) {
|
|
13
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the single bootstrap script run on the box: ensure agents-cli, install
|
|
17
|
+
* the picked runtime CLIs, write their credentials, run the agent, then shred
|
|
18
|
+
* the credential files. Best-effort install steps never abort the run.
|
|
19
|
+
*/
|
|
20
|
+
export function buildBootstrapScript(opts) {
|
|
21
|
+
const credScript = buildCredentialScript(opts.runtimes, opts.detected);
|
|
22
|
+
const runParts = ['agents', 'run', q(opts.agent), q(opts.prompt), '--quiet'];
|
|
23
|
+
if (opts.mode)
|
|
24
|
+
runParts.push('--mode', q(opts.mode));
|
|
25
|
+
if (opts.model)
|
|
26
|
+
runParts.push('--model', q(opts.model));
|
|
27
|
+
// Credential files to shred after the run (home-level paths written above).
|
|
28
|
+
const shred = opts.runtimes
|
|
29
|
+
.map((id) => {
|
|
30
|
+
const cred = { claude: '.claude.json', codex: '.codex/auth.json', gemini: '.gemini/google_accounts.json', grok: '.grok/auth.json' }[id];
|
|
31
|
+
return cred ? `rm -f "$HOME/${cred}" 2>/dev/null || true` : '';
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join('\n');
|
|
35
|
+
const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
|
|
36
|
+
return [
|
|
37
|
+
'set -uo pipefail',
|
|
38
|
+
'if ! command -v agents >/dev/null 2>&1; then npm install -g @phnx-labs/agents-cli >/dev/null 2>&1 || true; fi',
|
|
39
|
+
installRuntimes,
|
|
40
|
+
credScript,
|
|
41
|
+
`${runParts.join(' ')}`,
|
|
42
|
+
'rc=$?',
|
|
43
|
+
shred,
|
|
44
|
+
'exit $rc',
|
|
45
|
+
]
|
|
46
|
+
.filter((l) => l.length > 0)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export async function leaseAndRun(opts) {
|
|
50
|
+
const box = crabboxWarmup({
|
|
51
|
+
class: opts.boxClass,
|
|
52
|
+
profile: opts.profile,
|
|
53
|
+
provider: opts.backend,
|
|
54
|
+
secretsBundle: opts.secretsBundle,
|
|
55
|
+
});
|
|
56
|
+
await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
|
|
57
|
+
const script = buildBootstrapScript(opts);
|
|
58
|
+
let exitCode = null;
|
|
59
|
+
let toreDown = false;
|
|
60
|
+
try {
|
|
61
|
+
exitCode = await crabboxRunScript(box.slug, script, {
|
|
62
|
+
secretsBundle: opts.secretsBundle,
|
|
63
|
+
onData: opts.onData,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
// Always attempt teardown (bounds credential lifetime to the run) unless the
|
|
68
|
+
// caller explicitly asked to keep the box.
|
|
69
|
+
if (!opts.keep)
|
|
70
|
+
toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
|
|
71
|
+
}
|
|
72
|
+
return { box, exitCode, toreDown };
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection + picker + credential-script builder for `agents run --lease`.
|
|
3
|
+
*
|
|
4
|
+
* The picker asks which coding-agent runtime(s) to provision on a leased box.
|
|
5
|
+
* The default selection is whatever the user is currently signed into locally
|
|
6
|
+
* (via `getAccountInfo`, the same source `agents view` uses). The chosen runtimes
|
|
7
|
+
* drive both what gets installed on the box and which auth token file is copied
|
|
8
|
+
* over — the token contents ride the uploaded `--script-stdin` body, never argv.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: copying a runtime's auth token to an ephemeral cloud box is a
|
|
11
|
+
* credential transfer. It is strictly opt-in (a confirm prompt in the command
|
|
12
|
+
* layer), the token never appears in argv/`ps`, and `--lease` one-shot runs tear
|
|
13
|
+
* the box down afterward so the credential's lifetime is bounded by the run.
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentId } from '../types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Credential file locations per runtime. `localCandidates` are read in order
|
|
18
|
+
* (first existing wins); `remote` is where the box's CLI reads it by default
|
|
19
|
+
* (home-level — no per-version shim). Source of truth for these paths is
|
|
20
|
+
* `getAccountInfo` in src/lib/agents.ts; keep them in sync.
|
|
21
|
+
*/
|
|
22
|
+
interface RuntimeCred {
|
|
23
|
+
id: AgentId;
|
|
24
|
+
label: string;
|
|
25
|
+
localCandidates: string[];
|
|
26
|
+
remote: string;
|
|
27
|
+
}
|
|
28
|
+
export declare const LEASE_RUNTIMES: RuntimeCred[];
|
|
29
|
+
export interface DetectedRuntime {
|
|
30
|
+
id: AgentId;
|
|
31
|
+
label: string;
|
|
32
|
+
email: string | null;
|
|
33
|
+
signedIn: boolean;
|
|
34
|
+
/** Absolute local path of the credential file, if found. */
|
|
35
|
+
credPath: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** Which lease-capable runtimes the user is signed into on this machine. */
|
|
38
|
+
export declare function detectSignedInRuntimes(): Promise<DetectedRuntime[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Interactive checkbox: which runtimes to provision on the box. Defaults to the
|
|
41
|
+
* signed-in ones. Runtimes with no local credential are shown disabled.
|
|
42
|
+
* `prompt` is injected so tests don't require a TTY.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choices: {
|
|
45
|
+
name: string;
|
|
46
|
+
value: AgentId;
|
|
47
|
+
checked: boolean;
|
|
48
|
+
disabled: boolean | string;
|
|
49
|
+
}[]) => Promise<AgentId[]>): Promise<AgentId[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Build a bash snippet that writes each picked runtime's token file to the box's
|
|
52
|
+
* home-level config path (0600), from the token contents read locally. Returns
|
|
53
|
+
* `''` when no runtimes were selected. The snippet is meant to be embedded in
|
|
54
|
+
* the `--script-stdin` body (never argv).
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildCredentialScript(picked: AgentId[], detected: DetectedRuntime[]): string;
|
|
57
|
+
export {};
|