@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/commands.js +3 -3
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +2 -2
  8. package/dist/commands/doctor.d.ts +10 -0
  9. package/dist/commands/doctor.js +51 -2
  10. package/dist/commands/exec.js +56 -1
  11. package/dist/commands/hooks.js +3 -3
  12. package/dist/commands/import.js +1 -1
  13. package/dist/commands/inspect.js +13 -17
  14. package/dist/commands/mcp.js +3 -3
  15. package/dist/commands/permissions.js +3 -3
  16. package/dist/commands/rules.js +3 -3
  17. package/dist/commands/secrets-migrate.js +23 -11
  18. package/dist/commands/secrets.d.ts +20 -0
  19. package/dist/commands/secrets.js +53 -1
  20. package/dist/commands/sessions.js +18 -1
  21. package/dist/commands/skills.js +3 -3
  22. package/dist/commands/status.d.ts +12 -0
  23. package/dist/commands/status.js +81 -0
  24. package/dist/commands/sync.js +2 -2
  25. package/dist/commands/teams.js +77 -18
  26. package/dist/commands/usage.js +2 -2
  27. package/dist/commands/utils.d.ts +8 -0
  28. package/dist/commands/utils.js +20 -0
  29. package/dist/commands/versions.js +4 -3
  30. package/dist/commands/view.d.ts +39 -0
  31. package/dist/commands/view.js +217 -84
  32. package/dist/commands/workflows.js +3 -3
  33. package/dist/index.js +16 -2
  34. package/dist/lib/acp/harnesses.d.ts +1 -1
  35. package/dist/lib/acp/harnesses.js +2 -2
  36. package/dist/lib/agent-spec/index.d.ts +18 -0
  37. package/dist/lib/agent-spec/index.js +35 -0
  38. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  39. package/dist/lib/agent-spec/primitives.js +57 -0
  40. package/dist/lib/agent-spec/provider.d.ts +2 -0
  41. package/dist/lib/agent-spec/provider.js +9 -0
  42. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  43. package/dist/lib/agent-spec/resolve.js +174 -0
  44. package/dist/lib/agent-spec/types.d.ts +57 -0
  45. package/dist/lib/agent-spec/types.js +18 -0
  46. package/dist/lib/agents.d.ts +12 -0
  47. package/dist/lib/agents.js +115 -32
  48. package/dist/lib/browser/chrome.js +20 -0
  49. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  50. package/dist/lib/browser/drivers/ssh.js +18 -3
  51. package/dist/lib/crabbox/cli.d.ts +98 -0
  52. package/dist/lib/crabbox/cli.js +218 -0
  53. package/dist/lib/crabbox/lease.d.ts +41 -0
  54. package/dist/lib/crabbox/lease.js +73 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  56. package/dist/lib/crabbox/runtimes.js +109 -0
  57. package/dist/lib/doctor-diff.js +29 -2
  58. package/dist/lib/drift-sync.d.ts +43 -0
  59. package/dist/lib/drift-sync.js +179 -0
  60. package/dist/lib/exec.d.ts +15 -0
  61. package/dist/lib/exec.js +21 -11
  62. package/dist/lib/hosts/dispatch.d.ts +27 -10
  63. package/dist/lib/hosts/dispatch.js +55 -19
  64. package/dist/lib/hosts/option.d.ts +14 -0
  65. package/dist/lib/hosts/option.js +19 -0
  66. package/dist/lib/hosts/passthrough.d.ts +30 -0
  67. package/dist/lib/hosts/passthrough.js +141 -0
  68. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  69. package/dist/lib/hosts/remote-cmd.js +56 -0
  70. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  71. package/dist/lib/platform/winpath.d.ts +31 -2
  72. package/dist/lib/platform/winpath.js +133 -24
  73. package/dist/lib/pwsh.d.ts +11 -0
  74. package/dist/lib/pwsh.js +13 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/agent.d.ts +42 -1
  78. package/dist/lib/secrets/agent.js +89 -11
  79. package/dist/lib/secrets/bundles.js +61 -21
  80. package/dist/lib/secrets/filestore.js +31 -1
  81. package/dist/lib/secrets/index.d.ts +44 -1
  82. package/dist/lib/secrets/index.js +108 -10
  83. package/dist/lib/secrets/linux.d.ts +14 -0
  84. package/dist/lib/secrets/linux.js +21 -0
  85. package/dist/lib/secrets/windows.d.ts +74 -0
  86. package/dist/lib/secrets/windows.js +440 -0
  87. package/dist/lib/session/active.d.ts +8 -0
  88. package/dist/lib/session/active.js +18 -1
  89. package/dist/lib/session/provenance.d.ts +56 -0
  90. package/dist/lib/session/provenance.js +157 -0
  91. package/dist/lib/shims.d.ts +20 -0
  92. package/dist/lib/shims.js +53 -20
  93. package/dist/lib/ssh-exec.d.ts +22 -0
  94. package/dist/lib/ssh-exec.js +59 -2
  95. package/dist/lib/ssh-tunnel.d.ts +0 -5
  96. package/dist/lib/ssh-tunnel.js +65 -8
  97. package/dist/lib/startup/command-registry.d.ts +1 -0
  98. package/dist/lib/startup/command-registry.js +2 -0
  99. package/dist/lib/sync-status.d.ts +102 -0
  100. package/dist/lib/sync-status.js +135 -0
  101. package/dist/lib/teams/agents.d.ts +24 -0
  102. package/dist/lib/teams/agents.js +30 -1
  103. package/dist/lib/types.d.ts +20 -1
  104. package/dist/lib/usage.d.ts +30 -0
  105. package/dist/lib/usage.js +159 -2
  106. package/dist/lib/versions.d.ts +2 -4
  107. package/dist/lib/versions.js +7 -20
  108. package/package.json +2 -1
  109. package/dist/lib/agent-spec.d.ts +0 -36
  110. 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
- const b64 = Buffer.from(script, 'utf16le').toString('base64');
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 {};