@phnx-labs/agents-cli 1.20.31 → 1.20.33

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 (74) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/commands/commands.js +3 -3
  3. package/dist/commands/computer-actions.js +1 -0
  4. package/dist/commands/cost.js +2 -2
  5. package/dist/commands/doctor.js +2 -2
  6. package/dist/commands/exec.js +56 -1
  7. package/dist/commands/hooks.js +3 -3
  8. package/dist/commands/inspect.js +13 -17
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/permissions.js +3 -3
  11. package/dist/commands/rules.js +2 -2
  12. package/dist/commands/sessions.js +18 -1
  13. package/dist/commands/skills.js +3 -3
  14. package/dist/commands/ssh.js +23 -0
  15. package/dist/commands/sync.js +2 -2
  16. package/dist/commands/teams.js +7 -12
  17. package/dist/commands/usage.js +2 -2
  18. package/dist/commands/utils.d.ts +8 -0
  19. package/dist/commands/utils.js +20 -0
  20. package/dist/commands/versions.js +2 -2
  21. package/dist/commands/view.js +33 -9
  22. package/dist/commands/workflows.js +3 -3
  23. package/dist/index.js +12 -0
  24. package/dist/lib/agent-spec/index.d.ts +18 -0
  25. package/dist/lib/agent-spec/index.js +35 -0
  26. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  27. package/dist/lib/agent-spec/primitives.js +57 -0
  28. package/dist/lib/agent-spec/provider.d.ts +2 -0
  29. package/dist/lib/agent-spec/provider.js +9 -0
  30. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  31. package/dist/lib/agent-spec/resolve.js +174 -0
  32. package/dist/lib/agent-spec/types.d.ts +57 -0
  33. package/dist/lib/agent-spec/types.js +18 -0
  34. package/dist/lib/crabbox/cli.d.ts +98 -0
  35. package/dist/lib/crabbox/cli.js +218 -0
  36. package/dist/lib/crabbox/lease.d.ts +41 -0
  37. package/dist/lib/crabbox/lease.js +73 -0
  38. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  39. package/dist/lib/crabbox/runtimes.js +109 -0
  40. package/dist/lib/daemon.js +32 -0
  41. package/dist/lib/devices/pending.d.ts +18 -0
  42. package/dist/lib/devices/pending.js +103 -0
  43. package/dist/lib/devices/sync.d.ts +21 -2
  44. package/dist/lib/devices/sync.js +26 -10
  45. package/dist/lib/hosts/dispatch.d.ts +27 -10
  46. package/dist/lib/hosts/dispatch.js +55 -19
  47. package/dist/lib/hosts/option.d.ts +14 -0
  48. package/dist/lib/hosts/option.js +19 -0
  49. package/dist/lib/hosts/passthrough.d.ts +30 -0
  50. package/dist/lib/hosts/passthrough.js +141 -0
  51. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  52. package/dist/lib/hosts/remote-cmd.js +56 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  54. package/dist/lib/secrets/bundles.js +29 -20
  55. package/dist/lib/secrets/index.d.ts +11 -0
  56. package/dist/lib/secrets/index.js +18 -1
  57. package/dist/lib/secrets/linux.d.ts +14 -0
  58. package/dist/lib/secrets/linux.js +21 -0
  59. package/dist/lib/session/active.d.ts +8 -0
  60. package/dist/lib/session/active.js +18 -1
  61. package/dist/lib/session/provenance.d.ts +56 -0
  62. package/dist/lib/session/provenance.js +157 -0
  63. package/dist/lib/ssh-exec.d.ts +22 -0
  64. package/dist/lib/ssh-exec.js +59 -2
  65. package/dist/lib/ssh-tunnel.d.ts +0 -5
  66. package/dist/lib/ssh-tunnel.js +65 -8
  67. package/dist/lib/state.d.ts +2 -0
  68. package/dist/lib/state.js +2 -0
  69. package/dist/lib/sync-umbrella.js +10 -6
  70. package/dist/lib/versions.d.ts +13 -4
  71. package/dist/lib/versions.js +27 -20
  72. package/package.json +2 -1
  73. package/dist/lib/agent-spec.d.ts +0 -36
  74. package/dist/lib/agent-spec.js +0 -157
@@ -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 {};
@@ -0,0 +1,109 @@
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 * as os from 'os';
16
+ import * as path from 'path';
17
+ import * as fs from 'fs';
18
+ import { getAccountInfo } from '../agents.js';
19
+ export const LEASE_RUNTIMES = [
20
+ { id: 'claude', label: 'Claude Code', localCandidates: ['.claude/.claude.json', '.claude.json'], remote: '.claude.json' },
21
+ { id: 'codex', label: 'Codex CLI', localCandidates: ['.codex/auth.json'], remote: '.codex/auth.json' },
22
+ { id: 'gemini', label: 'Gemini CLI', localCandidates: ['.gemini/google_accounts.json'], remote: '.gemini/google_accounts.json' },
23
+ { id: 'grok', label: 'Grok CLI', localCandidates: ['.grok/auth.json'], remote: '.grok/auth.json' },
24
+ ];
25
+ /** First existing candidate path under the real home, or null. */
26
+ function findLocalCred(cred) {
27
+ const home = process.env.AGENTS_REAL_HOME || os.homedir();
28
+ for (const rel of cred.localCandidates) {
29
+ const p = path.join(home, rel);
30
+ try {
31
+ if (fs.existsSync(p))
32
+ return p;
33
+ }
34
+ catch {
35
+ /* unreadable — skip */
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** Which lease-capable runtimes the user is signed into on this machine. */
41
+ export async function detectSignedInRuntimes() {
42
+ const out = [];
43
+ for (const cred of LEASE_RUNTIMES) {
44
+ let info;
45
+ try {
46
+ info = await getAccountInfo(cred.id);
47
+ }
48
+ catch {
49
+ info = null;
50
+ }
51
+ out.push({
52
+ id: cred.id,
53
+ label: cred.label,
54
+ email: info?.email ?? null,
55
+ signedIn: !!info?.signedIn,
56
+ credPath: findLocalCred(cred),
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Interactive checkbox: which runtimes to provision on the box. Defaults to the
63
+ * signed-in ones. Runtimes with no local credential are shown disabled.
64
+ * `prompt` is injected so tests don't require a TTY.
65
+ */
66
+ export async function pickRuntimes(detected, prompt) {
67
+ const choices = detected.map((d) => ({
68
+ name: `${d.label}${d.email ? ` (${d.email})` : d.signedIn ? ' (signed in)' : ''}`,
69
+ value: d.id,
70
+ checked: d.signedIn && !!d.credPath,
71
+ disabled: d.credPath ? false : 'no local credential — sign in first',
72
+ }));
73
+ if (prompt)
74
+ return prompt(choices);
75
+ const { checkbox } = await import('@inquirer/prompts');
76
+ return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
77
+ }
78
+ // A long random sentinel makes an accidental (or malicious) collision with a
79
+ // token's contents effectively impossible, so the quoted heredoc can never be
80
+ // closed early by the credential body.
81
+ const CRED_EOF = 'AGENTS_LEASE_CRED_EOF_9f3c1a7b5e2d4068';
82
+ /**
83
+ * Build a bash snippet that writes each picked runtime's token file to the box's
84
+ * home-level config path (0600), from the token contents read locally. Returns
85
+ * `''` when no runtimes were selected. The snippet is meant to be embedded in
86
+ * the `--script-stdin` body (never argv).
87
+ */
88
+ export function buildCredentialScript(picked, detected) {
89
+ const byId = new Map(detected.map((d) => [d.id, d]));
90
+ const parts = [];
91
+ for (const id of picked) {
92
+ const d = byId.get(id);
93
+ const cred = LEASE_RUNTIMES.find((c) => c.id === id);
94
+ if (!d?.credPath || !cred)
95
+ continue;
96
+ let contents;
97
+ try {
98
+ contents = fs.readFileSync(d.credPath, 'utf-8');
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ const dir = path.posix.dirname(cred.remote);
104
+ const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
105
+ parts.push(`${mkdir}cat > "$HOME/${cred.remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
106
+ `chmod 600 "$HOME/${cred.remote}"`);
107
+ }
108
+ return parts.join('\n');
109
+ }
@@ -290,6 +290,36 @@ export async function runDaemon() {
290
290
  };
291
291
  const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
292
292
  const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
293
+ // Device probe: refresh registered devices' reachability and detect newly
294
+ // appeared tailnet nodes, dropping a sentinel per pending device so the
295
+ // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
296
+ // never auto-registers a newcomer. Soft + overlap-guarded like session sync;
297
+ // a machine without tailscale is a clean no-op. ~every 3 min.
298
+ let probingDevices = false;
299
+ const runDeviceProbe = async () => {
300
+ if (probingDevices)
301
+ return;
302
+ probingDevices = true;
303
+ try {
304
+ const { runDeviceSync } = await import('./devices/sync.js');
305
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
306
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
307
+ if (dev.ok) {
308
+ reconcilePendingSentinels(dev.pending);
309
+ if (dev.pending.length) {
310
+ log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
311
+ }
312
+ }
313
+ }
314
+ catch (err) {
315
+ log('ERROR', `device probe failed: ${err.message}`);
316
+ }
317
+ finally {
318
+ probingDevices = false;
319
+ }
320
+ };
321
+ const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
322
+ const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
293
323
  const handleReload = () => {
294
324
  log('INFO', 'Reloading jobs (SIGHUP)');
295
325
  scheduler.reloadAll();
@@ -307,6 +337,8 @@ export async function runDaemon() {
307
337
  clearInterval(syncInterval);
308
338
  clearInterval(healInterval);
309
339
  clearTimeout(healKickoff);
340
+ clearInterval(deviceProbeInterval);
341
+ clearTimeout(deviceProbeKickoff);
310
342
  removeDaemonPid();
311
343
  process.exit(0);
312
344
  };
@@ -0,0 +1,18 @@
1
+ export interface PendingDevice {
2
+ name: string;
3
+ platform: string;
4
+ }
5
+ /**
6
+ * Make the sentinel dir exactly match `pending`: create a file per pending
7
+ * device (content = platform), and delete any leftover sentinel whose device is
8
+ * no longer pending (it got registered, ignored, or left the tailnet). Best-
9
+ * effort — a filesystem error here must never crash the daemon, so callers pass
10
+ * this through their existing try/catch.
11
+ */
12
+ export declare function reconcilePendingSentinels(pending: PendingDevice[]): void;
13
+ /** Remove one device's pending sentinel (after the user registers or ignores it).
14
+ * No-op if it doesn't exist. */
15
+ export declare function clearPendingSentinel(name: string): void;
16
+ /** Read the current pending sentinels (name + platform). Used by tests and any
17
+ * TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
18
+ export declare function readPendingSentinels(): PendingDevice[];