@phnx-labs/agents-cli 1.20.63 → 1.20.64

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 (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. package/package.json +1 -1
@@ -3,8 +3,13 @@ import chalk from 'chalk';
3
3
  import { ALL_AGENT_IDS, AGENTS, getAccountInfo, agentLabel, resolveAgentName, formatAgentError, } from '../lib/agents.js';
4
4
  import { listInstalledVersions, getGlobalDefault, getVersionHomePath } from '../lib/versions.js';
5
5
  import { formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
6
- /** Agents whose CLI surfaces usage data we can read today. */
7
- const USAGE_SUPPORTED = new Set(['claude', 'codex']);
6
+ /**
7
+ * Agents whose CLI surfaces usage data we can read today. Kept in sync with the
8
+ * live/last-seen sources `getUsageInfo` dispatches on in `../lib/usage.js`
9
+ * (claude, codex, kimi, droid) — an agent with a usage source but missing here
10
+ * would wrongly print "does not publish usage data" for a signed-in account.
11
+ */
12
+ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
8
13
  export function registerUsageCommand(program) {
9
14
  addHostOption(program.command('usage [agent]'))
10
15
  .description('Show rate-limit / quota usage per agent')
@@ -39,7 +39,7 @@ export interface ViewJsonVersion {
39
39
  currency: string;
40
40
  } | null;
41
41
  windows: Array<{
42
- key: 'session' | 'week' | 'sonnet_week';
42
+ key: 'session' | 'week' | 'sonnet_week' | 'month';
43
43
  usedPercent: number;
44
44
  resetsAt: string | null;
45
45
  }>;
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -707,6 +707,7 @@ async function registerAllEagerCommands() {
707
707
  await reg(loadWebhook);
708
708
  await reg(loadFunnel);
709
709
  await reg(loadFeed);
710
+ await reg(loadMailboxes);
710
711
  await reg(loadSsh);
711
712
  registerJobsCronAliasCommand(program, 'jobs');
712
713
  registerJobsCronAliasCommand(program, 'cron');
@@ -130,6 +130,22 @@ export interface AccountInfo {
130
130
  }
131
131
  /** Return the email address associated with the agent's auth config, or null. */
132
132
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
133
+ /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
134
+ export interface DroidAuthPayload {
135
+ access_token?: string;
136
+ active_organization_id?: string | null;
137
+ }
138
+ /**
139
+ * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
140
+ * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
141
+ * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
142
+ * device binding — the key is on disk — so we can decrypt locally with no
143
+ * network call. Every failure (missing key file — e.g. a keyring-v2/legacy
144
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
145
+ * Never throws. Shared by account identity below and the Droid usage fetcher
146
+ * in usage.ts.
147
+ */
148
+ export declare function decryptDroidAuthPayload(base: string): DroidAuthPayload | null;
133
149
  /**
134
150
  * Antigravity (`agy`) stores its OAuth token via the Go keyring library
135
151
  * (zalando/go-keyring), which is platform-split:
@@ -173,6 +189,8 @@ export declare function resolveLastActive(agentId: AgentId, base: string, config
173
189
  * Used during init to show approximate session count to user.
174
190
  */
175
191
  export declare function countSessionFiles(agentId: AgentId): number;
192
+ /** Decode the payload section of a JWT token without verifying its signature. */
193
+ export declare function decodeJwtPayload(token: string): Record<string, any> | null;
176
194
  /** Register an MCP server with an agent's CLI via `mcp add`. */
177
195
  export declare function registerMcp(agentId: AgentId, name: string, command: string, scope?: 'user' | 'project', transport?: string, options?: {
178
196
  home?: string;
@@ -984,15 +984,12 @@ function resolveAccountCredentialPath(base, ...segments) {
984
984
  * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
985
985
  * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
986
986
  * device binding — the key is on disk — so we can decrypt locally with no
987
- * network call. The decrypted JSON credential's `access_token` is a WorkOS JWT
988
- * carrying an `email` claim (plus org_id / role). We decode the claim WITHOUT
989
- * verifying `exp`: the email is stable identity for display, not an
990
- * authorization decision, so an expired token still yields the right address.
991
- * Every failure (missing key file — e.g. a keyring-v2/legacy login with no
992
- * on-disk key, a bad GCM tag, malformed JSON, or no email claim) returns null so
993
- * the caller falls back to the file-presence signed-in signal. Never throws.
987
+ * network call. Every failure (missing key file e.g. a keyring-v2/legacy
988
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
989
+ * Never throws. Shared by account identity below and the Droid usage fetcher
990
+ * in usage.ts.
994
991
  */
995
- function decryptDroidCredential(base) {
992
+ export function decryptDroidAuthPayload(base) {
996
993
  const filePath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
997
994
  const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
998
995
  if (!filePath || !keyPath)
@@ -1012,19 +1009,32 @@ function decryptDroidCredential(base) {
1012
1009
  decipher.final(),
1013
1010
  ]).toString('utf-8');
1014
1011
  const cred = JSON.parse(plaintext);
1015
- const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1016
- if (!claims)
1017
- return null;
1018
- return {
1019
- email: typeof claims.email === 'string' ? claims.email : null,
1020
- orgId: normalizeIdentityPart(claims.org_id ?? cred.active_organization_id),
1021
- role: typeof claims.role === 'string' ? claims.role : null,
1022
- };
1012
+ return cred && typeof cred === 'object' ? cred : null;
1023
1013
  }
1024
1014
  catch {
1025
1015
  return null;
1026
1016
  }
1027
1017
  }
1018
+ /**
1019
+ * Derive Droid account identity from the decrypted credential. The
1020
+ * `access_token` is a WorkOS JWT carrying an `email` claim (plus org_id /
1021
+ * role). We decode the claim WITHOUT verifying `exp`: the email is stable
1022
+ * identity for display, not an authorization decision, so an expired token
1023
+ * still yields the right address. Returns null when the credential can't be
1024
+ * decrypted or has no decodable claims, so the caller falls back to the
1025
+ * file-presence signed-in signal.
1026
+ */
1027
+ function decryptDroidCredential(base) {
1028
+ const cred = decryptDroidAuthPayload(base);
1029
+ const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1030
+ if (!claims)
1031
+ return null;
1032
+ return {
1033
+ email: typeof claims.email === 'string' ? claims.email : null,
1034
+ orgId: normalizeIdentityPart(claims.org_id ?? cred?.active_organization_id),
1035
+ role: typeof claims.role === 'string' ? claims.role : null,
1036
+ };
1037
+ }
1028
1038
  let cachedAgyKeychainSignedIn;
1029
1039
  /**
1030
1040
  * Antigravity (`agy`) stores its OAuth token via the Go keyring library
@@ -1564,7 +1574,7 @@ export function countSessionFiles(agentId) {
1564
1574
  return count;
1565
1575
  }
1566
1576
  /** Decode the payload section of a JWT token without verifying its signature. */
1567
- function decodeJwtPayload(token) {
1577
+ export function decodeJwtPayload(token) {
1568
1578
  const payload = token.split('.')[1];
1569
1579
  if (!payload)
1570
1580
  return null;
@@ -9,7 +9,7 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
9
9
  // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
10
  // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
11
  // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
- import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
12
+ import { shellQuote, SSH_OPTS, assertValidSshTarget } from '../../ssh-exec.js';
13
13
  export { shellQuote };
14
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
15
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -292,6 +292,12 @@ export function buildKillCmd(remoteOs, port) {
292
292
  return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
293
293
  }
294
294
  export async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
295
+ // `user`/`host` derive from an ssh:// profile endpoint (git-tracked user
296
+ // config). Validate the composed target before it reaches the raw `ssh` spawn:
297
+ // an endpoint like `ssh://-Fattack@victim` would otherwise place `-Fattack` at
298
+ // the target position, where OpenSSH parses it as `-F <file>` and an
299
+ // attacker-supplied ssh config's ProxyCommand yields local code execution.
300
+ assertValidSshTarget(`${user}@${host}`);
295
301
  const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
296
302
  return new Promise((resolve, reject) => {
297
303
  // Capture stderr so a real launch failure (missing exe, auth denied, bad
@@ -362,7 +368,18 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
362
368
  return runSSHCommand(user, host, buildKillCmd(remoteOs, port));
363
369
  }
364
370
  function runSSHCommand(user, host, cmd) {
365
- return new Promise((resolve) => {
371
+ return new Promise((resolve, reject) => {
372
+ // Reject an option-injecting target (e.g. a `-`-leading user from a crafted
373
+ // ssh:// profile) before it reaches the raw `ssh` spawn. Reject rather than
374
+ // throw synchronously so `killRemoteBrowser(...).catch()` stays best-effort.
375
+ // See ensureRemoteBrowser.
376
+ try {
377
+ assertValidSshTarget(`${user}@${host}`);
378
+ }
379
+ catch (err) {
380
+ reject(err);
381
+ return;
382
+ }
366
383
  // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
384
  // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
385
  // connected-but-slow command, not the connect itself). Options precede the
@@ -0,0 +1,37 @@
1
+ import type { CommsMsg } from './mailbox.js';
2
+ export type { CommsMsg } from './mailbox.js';
3
+ export declare const GLYPH: {
4
+ readonly live: "●";
5
+ readonly idle: "○";
6
+ readonly ask: "▲";
7
+ readonly delivered: "✓";
8
+ readonly pending: "⏳";
9
+ readonly route: "→";
10
+ readonly stream: "─→";
11
+ readonly thread: "⇄";
12
+ };
13
+ export type Accent = 'cyan' | 'amber';
14
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
15
+ export declare function masthead(o: {
16
+ title: string;
17
+ host: string;
18
+ accent: Accent;
19
+ right?: string;
20
+ stats?: string[];
21
+ }): string;
22
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
23
+ export declare function sparkline(counts: number[]): string;
24
+ /** Flatten mailbox histories into one newest-first communication stream. */
25
+ export declare function aggregate(boxes: {
26
+ id: string;
27
+ label: string;
28
+ messages: import('./mailbox.js').StoredMessage[];
29
+ }[]): CommsMsg[];
30
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
31
+ export declare function hourlyCounts(msgs: CommsMsg[], hours: number, now?: Date): number[];
32
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
33
+ export declare function graphEdges(msgs: CommsMsg[]): {
34
+ from: string;
35
+ to: string;
36
+ count: number;
37
+ }[];
@@ -0,0 +1,89 @@
1
+ import chalk from 'chalk';
2
+ import { stringWidth, terminalWidth } from './session/width.js';
3
+ export const GLYPH = {
4
+ live: '●',
5
+ idle: '○',
6
+ ask: '▲',
7
+ delivered: '✓',
8
+ pending: '⏳',
9
+ route: '→',
10
+ stream: '─→',
11
+ thread: '⇄',
12
+ };
13
+ /** Render the shared one-line comms header, with `right` aligned to the terminal edge. */
14
+ export function masthead(o) {
15
+ const accent = o.accent === 'cyan' ? chalk.cyan : chalk.yellow;
16
+ const title = `${accent('⌁')} ${chalk.bold(accent(o.title))}`;
17
+ const host = chalk.dim(` · ${o.host}`);
18
+ const stats = o.stats?.length ? ` ${o.stats.join(chalk.dim(' · '))}` : '';
19
+ const left = `${title}${host}${stats}`;
20
+ if (!o.right)
21
+ return left;
22
+ const right = chalk.dim(o.right);
23
+ const gap = Math.max(1, terminalWidth() - stringWidth(left) - stringWidth(right));
24
+ return `${left}${' '.repeat(gap)}${right}`;
25
+ }
26
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█';
27
+ /** Normalize non-negative counts across the eight Unicode sparkline levels. */
28
+ export function sparkline(counts) {
29
+ if (counts.length === 0)
30
+ return ' ';
31
+ const normalized = counts.map((count) => Number.isFinite(count) ? Math.max(0, count) : 0);
32
+ const max = Math.max(...normalized);
33
+ if (max === 0)
34
+ return ' '.repeat(counts.length);
35
+ return normalized
36
+ .map((count) => SPARK_LEVELS[Math.round((count / max) * (SPARK_LEVELS.length - 1))])
37
+ .join('');
38
+ }
39
+ /** Flatten mailbox histories into one newest-first communication stream. */
40
+ export function aggregate(boxes) {
41
+ return boxes
42
+ .flatMap((box) => {
43
+ const toLabel = box.label || box.id.slice(0, 8);
44
+ return box.messages.map((message) => ({
45
+ from: message.from || 'operator',
46
+ to: message.to,
47
+ toLabel,
48
+ ts: message.ts,
49
+ text: message.text,
50
+ state: message.state,
51
+ box: box.id,
52
+ }));
53
+ })
54
+ .sort((a, b) => compareText(b.ts, a.ts));
55
+ }
56
+ const HOUR_MS = 60 * 60 * 1_000;
57
+ /** Count messages in rolling one-hour buckets, ordered oldest to newest. */
58
+ export function hourlyCounts(msgs, hours, now = new Date()) {
59
+ const bucketCount = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
60
+ const counts = Array.from({ length: bucketCount }, () => 0);
61
+ const nowMs = now.getTime();
62
+ if (bucketCount === 0 || !Number.isFinite(nowMs))
63
+ return counts;
64
+ for (const message of msgs) {
65
+ const timestamp = Date.parse(message.ts);
66
+ const age = nowMs - timestamp;
67
+ if (!Number.isFinite(timestamp) || age < 0 || age >= bucketCount * HOUR_MS)
68
+ continue;
69
+ const bucket = bucketCount - 1 - Math.floor(age / HOUR_MS);
70
+ counts[bucket]++;
71
+ }
72
+ return counts;
73
+ }
74
+ /** Aggregate the human-readable sender-to-recipient routes, busiest first. */
75
+ export function graphEdges(msgs) {
76
+ const edges = new Map();
77
+ for (const message of msgs) {
78
+ const key = JSON.stringify([message.from, message.toLabel]);
79
+ const existing = edges.get(key);
80
+ if (existing)
81
+ existing.count++;
82
+ else
83
+ edges.set(key, { from: message.from, to: message.toLabel, count: 1 });
84
+ }
85
+ return [...edges.values()].sort((a, b) => b.count - a.count || compareText(a.from, b.from) || compareText(a.to, b.to));
86
+ }
87
+ function compareText(a, b) {
88
+ return a < b ? -1 : a > b ? 1 : 0;
89
+ }
@@ -11,6 +11,7 @@
11
11
  * needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
+ import { type SecretsBundle } from '../secrets/bundles.js';
14
15
  /** A crabbox machine as reported by `crabbox list --json`. */
15
16
  export interface CrabboxBox {
16
17
  /** Provider machine name, e.g. `crabbox-blue-hermit-1039689b`. */
@@ -29,6 +30,16 @@ export interface CrabboxBox {
29
30
  class?: string;
30
31
  /** True when running + bootstrap-complete. */
31
32
  ready: boolean;
33
+ /** crabbox `keep` label — a kept box survives `crabbox cleanup` past its TTL. */
34
+ keep: boolean;
35
+ /** Unix seconds the box was created, or null when the label is absent. */
36
+ createdAt: number | null;
37
+ /** Unix seconds the lease expires, or null. */
38
+ expiresAt: number | null;
39
+ /** Unix seconds the box was last touched (reused / run against), or null. */
40
+ lastTouchedAt: number | null;
41
+ /** Idle-timeout window in seconds, or null. */
42
+ idleTimeoutSecs: number | null;
32
43
  }
33
44
  export interface CrabboxOptions {
34
45
  /**
@@ -41,6 +52,38 @@ export interface CrabboxOptions {
41
52
  }
42
53
  /** Locate the crabbox binary, or throw an actionable error. */
43
54
  export declare function findCrabbox(): string;
55
+ /**
56
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
57
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
58
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
59
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
60
+ */
61
+ export declare const LEASE_PROVIDER_TOKEN_KEYS: string[];
62
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
63
+ export declare function pickLeaseBundleFromList(bundles: SecretsBundle[]): string | undefined;
64
+ /** A resolved lease bundle: its name, plus (auto-detect only) the exact keys to inject. */
65
+ export interface ResolvedLeaseBundle {
66
+ name: string;
67
+ /** When set (auto-detect), inject ONLY these keys — not the whole bundle. */
68
+ keys?: string[];
69
+ }
70
+ /**
71
+ * The secrets bundle to feed crabbox, resolved in priority order:
72
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
73
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
74
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
75
+ *
76
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
77
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
78
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
79
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
80
+ * process (it is called several times per lease, and we don't want a scan each
81
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
82
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
83
+ */
84
+ export declare function resolveLeaseBundle(): ResolvedLeaseBundle | undefined;
85
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
86
+ export declare function setLeaseSecretsBundle(name: string): void;
44
87
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
45
88
  export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
46
89
  /** All crabbox machines the broker knows about. */
@@ -96,3 +139,32 @@ export declare function crabboxRun(slug: string, remoteCmd: string, opts?: Crabb
96
139
  export declare function crabboxRunScript(slug: string, script: string, opts?: CrabboxRunOptions): Promise<number | null>;
97
140
  /** Release the lease / delete the box. Best-effort; never throws. */
98
141
  export declare function crabboxStop(slug: string, opts?: CrabboxOptions): boolean;
142
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
143
+ export declare const REAP_MIN_IDLE_SECS = 3600;
144
+ /**
145
+ * Whether a box is a genuine orphan that is safe to reap.
146
+ *
147
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
148
+ * past) AND the box has not been touched for a safety window of
149
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
150
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
151
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
152
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
153
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
154
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
155
+ */
156
+ export declare function isReapSafe(box: CrabboxBox, nowSecs: number): boolean;
157
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
158
+ export declare function reapSafeOrphans(boxes: CrabboxBox[], nowSecs: number): CrabboxBox[];
159
+ /**
160
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
161
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
162
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
163
+ */
164
+ export declare function reapOrphans(opts?: CrabboxOptions & {
165
+ nowSecs?: number;
166
+ dryRun?: boolean;
167
+ }): {
168
+ candidates: CrabboxBox[];
169
+ reaped: string[];
170
+ };
@@ -12,7 +12,8 @@
12
12
  * from a secrets bundle when one is configured (see `crabboxEnv`).
13
13
  */
14
14
  import { spawn, spawnSync } from 'child_process';
15
- import { readAndResolveBundleEnv } from '../secrets/bundles.js';
15
+ import { readAndResolveBundleEnv, listBundles, bundleExists } from '../secrets/bundles.js';
16
+ import { readMeta, writeMeta } from '../state.js';
16
17
  /** Locate the crabbox binary, or throw an actionable error. */
17
18
  export function findCrabbox() {
18
19
  const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
@@ -21,21 +22,95 @@ export function findCrabbox() {
21
22
  }
22
23
  return 'crabbox';
23
24
  }
25
+ /**
26
+ * Env keys that mark a secrets bundle as usable for `--lease` — the provider
27
+ * tokens crabbox reads to reach a cloud API. Matching a bundle needs only its
28
+ * declared key NAMES; only the matched key's VALUE is ever injected (see
29
+ * `crabboxEnv`), so an auto-detected bundle can't leak its other secrets.
30
+ */
31
+ export const LEASE_PROVIDER_TOKEN_KEYS = ['HCLOUD_TOKEN', 'AWS_ACCESS_KEY_ID', 'DIGITALOCEAN_TOKEN', 'DO_TOKEN'];
32
+ /** The first bundle that declares a provider token key, or undefined. Pure over `bundles`. */
33
+ export function pickLeaseBundleFromList(bundles) {
34
+ for (const b of bundles) {
35
+ if (Object.keys(b.vars ?? {}).some((k) => LEASE_PROVIDER_TOKEN_KEYS.includes(k)))
36
+ return b.name;
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * The secrets bundle to feed crabbox, resolved in priority order:
42
+ * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
43
+ * 2. `lease.secretsBundle` config (set by `lease setup`) — explicit, no keychain
44
+ * 3. auto-detect: the first keychain bundle DECLARING a provider token key
45
+ *
46
+ * Tiers 1–2 are the frictionless steady state (env + config are plain, no keychain
47
+ * read). Tier 3 is a fallback that DOES read bundle metadata via `listBundles()`
48
+ * (one batched keychain unlock, ~7-day broker cache) — so it only runs when
49
+ * neither env nor config is set, and `crabboxEnv` memoizes the result for the
50
+ * process (it is called several times per lease, and we don't want a scan each
51
+ * time). Once `lease setup` persists the choice (tier 2), tier 3 never runs.
52
+ * Returns undefined when nothing matches — crabbox then falls back to `crabbox login`.
53
+ */
54
+ export function resolveLeaseBundle() {
55
+ const env = process.env.AGENTS_LEASE_SECRETS_BUNDLE;
56
+ if (env)
57
+ return { name: env };
58
+ try {
59
+ const configured = readMeta().lease?.secretsBundle;
60
+ if (configured && bundleExists(configured))
61
+ return { name: configured };
62
+ }
63
+ catch {
64
+ /* config unreadable — fall through to auto-detect */
65
+ }
66
+ try {
67
+ const bundles = listBundles();
68
+ const name = pickLeaseBundleFromList(bundles);
69
+ if (name) {
70
+ const b = bundles.find((x) => x.name === name);
71
+ const keys = LEASE_PROVIDER_TOKEN_KEYS.filter((k) => !!b && k in (b.vars ?? {}));
72
+ return { name, keys };
73
+ }
74
+ }
75
+ catch {
76
+ /* secrets unreadable — no auto-detect */
77
+ }
78
+ return undefined;
79
+ }
80
+ /** Process-lifetime memo so the tier-3 `listBundles()` scan runs at most once. */
81
+ let leaseBundleMemo;
82
+ function resolveLeaseBundleMemo() {
83
+ if (!leaseBundleMemo)
84
+ leaseBundleMemo = { value: resolveLeaseBundle() };
85
+ return leaseBundleMemo.value;
86
+ }
87
+ /** Persist `lease.secretsBundle` in agents config so `--lease` needs no env var. */
88
+ export function setLeaseSecretsBundle(name) {
89
+ const meta = readMeta();
90
+ writeMeta({ ...meta, lease: { ...meta.lease, secretsBundle: name } });
91
+ leaseBundleMemo = undefined; // invalidate so the next resolve sees the new config
92
+ }
24
93
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
25
94
  export function crabboxEnv(opts) {
26
- const bundle = opts.secretsBundle ?? process.env.AGENTS_LEASE_SECRETS_BUNDLE;
27
- if (!bundle)
95
+ const resolved = opts.secretsBundle
96
+ ? { name: opts.secretsBundle }
97
+ : resolveLeaseBundleMemo();
98
+ if (!resolved)
28
99
  return process.env;
29
100
  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)' });
101
+ // Auto-detected bundle inject ONLY the provider token key(s) (least
102
+ // privilege; an unrelated bundle can't leak its other secrets into crabbox).
103
+ // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
104
+ // whole the user chose it. Same resolver `agents secrets exec` uses.
105
+ const { env } = readAndResolveBundleEnv(resolved.name, {
106
+ caller: 'agents run --lease (crabbox)',
107
+ keys: resolved.keys,
108
+ });
34
109
  return { ...process.env, ...env };
35
110
  }
36
111
  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.`);
112
+ throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
113
+ `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
39
114
  }
40
115
  }
41
116
  function normalizeBox(raw) {
@@ -46,6 +121,12 @@ function normalizeBox(raw) {
46
121
  const status = String(raw.status ?? '');
47
122
  const state = String(labels.state ?? '');
48
123
  const publicNet = (raw.public_net ?? {});
124
+ const num = (v) => {
125
+ if (v === undefined || v === '')
126
+ return null;
127
+ const n = Number(v);
128
+ return Number.isFinite(n) ? n : null;
129
+ };
49
130
  return {
50
131
  name: String(raw.name ?? ''),
51
132
  status,
@@ -56,6 +137,11 @@ function normalizeBox(raw) {
56
137
  profile: labels.profile,
57
138
  class: labels.class,
58
139
  ready: status === 'running' && state === 'ready',
140
+ keep: labels.keep === 'true',
141
+ createdAt: num(labels.created_at),
142
+ expiresAt: num(labels.expires_at),
143
+ lastTouchedAt: num(labels.last_touched_at),
144
+ idleTimeoutSecs: num(labels.idle_timeout_secs ?? labels.idle_timeout),
59
145
  };
60
146
  }
61
147
  /** All crabbox machines the broker knows about. */
@@ -113,6 +199,24 @@ export async function crabboxWarmup(opts = {}) {
113
199
  });
114
200
  if (r.status !== 0) {
115
201
  const detail = (r.stderr || r.stdout || '').trim();
202
+ // A provider `server_limit` / `resource_limit_exceeded` 403 means the account's
203
+ // box quota is full. Turn the raw 403 into an actionable message that names the
204
+ // reap-safe orphans + the one-command fix, instead of a generic failure.
205
+ if (/server_limit|resource_limit_exceeded/i.test(detail)) {
206
+ let hint = ' Stop unused boxes (`crabbox list`) or raise your provider server limit.';
207
+ try {
208
+ const orphans = reapSafeOrphans(crabboxList(opts), Math.floor(Date.now() / 1000));
209
+ if (orphans.length) {
210
+ hint =
211
+ ` ${orphans.length} expired, idle box(es) are holding the quota — free them with ` +
212
+ `\`agents lease gc\` (or \`crabbox stop ${orphans[0].slug}\`).`;
213
+ }
214
+ }
215
+ catch {
216
+ /* best-effort hint; fall back to the generic guidance above */
217
+ }
218
+ throw new Error(`crabbox warmup failed: provider server limit reached.${hint}`);
219
+ }
116
220
  throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
117
221
  `Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
118
222
  }
@@ -229,3 +333,48 @@ export function crabboxStop(slug, opts = {}) {
229
333
  return false;
230
334
  }
231
335
  }
336
+ /** Never reap a box touched within this many seconds, regardless of idle-timeout. */
337
+ export const REAP_MIN_IDLE_SECS = 3600;
338
+ /**
339
+ * Whether a box is a genuine orphan that is safe to reap.
340
+ *
341
+ * Reap-safe ONLY when BOTH hold: the lease has already expired (`expiresAt` in the
342
+ * past) AND the box has not been touched for a safety window of
343
+ * `max(2 × idleTimeout, 1h)`. The freshness guard is what makes this safe against
344
+ * a TOCTOU race: a box a concurrent run just reused (`cbx_acquire_box`) has a
345
+ * recent `lastTouchedAt` and is never eligible. Reaping by `profile`/`ready` alone
346
+ * — as `crabbox cleanup` cannot (it skips `keep=true`, which every real orphan is)
347
+ * — would kill in-use boxes. Boxes with unknown age (`expiresAt`/`lastTouchedAt`
348
+ * null) are never reaped. `nowSecs` is injected so tests don't wall-clock.
349
+ */
350
+ export function isReapSafe(box, nowSecs) {
351
+ if (box.expiresAt === null || box.lastTouchedAt === null)
352
+ return false;
353
+ if (box.expiresAt > nowSecs)
354
+ return false;
355
+ const window = Math.max((box.idleTimeoutSecs ?? 0) * 2, REAP_MIN_IDLE_SECS);
356
+ return nowSecs - box.lastTouchedAt >= window;
357
+ }
358
+ /** The reap-safe orphans among `boxes`, most-stale (oldest touch) first. */
359
+ export function reapSafeOrphans(boxes, nowSecs) {
360
+ return boxes
361
+ .filter((b) => isReapSafe(b, nowSecs))
362
+ .sort((a, b) => (a.lastTouchedAt ?? 0) - (b.lastTouchedAt ?? 0));
363
+ }
364
+ /**
365
+ * List reap-safe orphans and (unless `dryRun`) stop them. Returns the candidates
366
+ * considered and the slugs actually stopped. Best-effort per box — a stop failure
367
+ * is skipped, never thrown. Backs `agents lease gc` and the 403 auto-reap opt-in.
368
+ */
369
+ export function reapOrphans(opts = {}) {
370
+ const nowSecs = opts.nowSecs ?? Math.floor(Date.now() / 1000);
371
+ const candidates = reapSafeOrphans(crabboxList(opts), nowSecs);
372
+ if (opts.dryRun)
373
+ return { candidates, reaped: [] };
374
+ const reaped = [];
375
+ for (const b of candidates) {
376
+ if (crabboxStop(b.slug, opts))
377
+ reaped.push(b.slug);
378
+ }
379
+ return { candidates, reaped };
380
+ }
@@ -47,6 +47,19 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
47
47
  checked: boolean;
48
48
  disabled: boolean | string;
49
49
  }[]) => Promise<AgentId[]>): Promise<AgentId[]>;
50
+ /**
51
+ * The lease runtime to provision for a headless run of `agentName`.
52
+ *
53
+ * When the agent is itself a lease-capable runtime (claude/codex/gemini/grok)
54
+ * that IS the runtime to install. Otherwise fall back to the single signed-in
55
+ * lease runtime (preferring claude), or null when none is signed in. This is the
56
+ * non-interactive replacement for the runtime checkbox picker: `--lease` requires
57
+ * a prompt, so it is headless by contract and must never block on a TTY.
58
+ *
59
+ * Profile-dispatch agents (kimi/deepseek) and custom workflow agents that run
60
+ * under a non-obvious runtime are resolved separately — see RUSH-1725.
61
+ */
62
+ export declare function inferLeaseRuntime(agentName: string, detected: DetectedRuntime[]): AgentId | null;
50
63
  /**
51
64
  * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
52
65
  * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token