@phnx-labs/agents-cli 1.20.72 → 1.20.73

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 (219) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +12 -0
  5. package/dist/commands/activity.js +68 -0
  6. package/dist/commands/apply.d.ts +13 -0
  7. package/dist/commands/apply.js +28 -3
  8. package/dist/commands/browser.js +1 -1
  9. package/dist/commands/cloud.js +21 -11
  10. package/dist/commands/doctor.js +8 -4
  11. package/dist/commands/events.d.ts +11 -10
  12. package/dist/commands/events.js +30 -21
  13. package/dist/commands/exec.d.ts +34 -0
  14. package/dist/commands/exec.js +337 -39
  15. package/dist/commands/feed.d.ts +13 -0
  16. package/dist/commands/feed.js +130 -29
  17. package/dist/commands/hq.d.ts +2 -0
  18. package/dist/commands/hq.js +58 -0
  19. package/dist/commands/lease.d.ts +19 -0
  20. package/dist/commands/lease.js +138 -11
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.js +123 -0
  23. package/dist/commands/logs.js +1 -1
  24. package/dist/commands/mine.d.ts +27 -0
  25. package/dist/commands/mine.js +207 -0
  26. package/dist/commands/monitors.js +79 -49
  27. package/dist/commands/plugins.js +1 -0
  28. package/dist/commands/profiles.js +158 -0
  29. package/dist/commands/repo.js +2 -60
  30. package/dist/commands/resources.d.ts +5 -0
  31. package/dist/commands/resources.js +95 -0
  32. package/dist/commands/routines.d.ts +2 -0
  33. package/dist/commands/routines.js +139 -37
  34. package/dist/commands/secrets.js +234 -47
  35. package/dist/commands/send.d.ts +15 -0
  36. package/dist/commands/send.js +62 -0
  37. package/dist/commands/sessions-tail.d.ts +1 -0
  38. package/dist/commands/sessions-tail.js +20 -9
  39. package/dist/commands/sessions.js +18 -3
  40. package/dist/commands/setup-mine.d.ts +18 -0
  41. package/dist/commands/setup-mine.js +106 -0
  42. package/dist/commands/setup-share.js +2 -2
  43. package/dist/commands/setup.js +3 -1
  44. package/dist/commands/share.d.ts +8 -1
  45. package/dist/commands/share.js +122 -37
  46. package/dist/commands/ssh.d.ts +9 -0
  47. package/dist/commands/ssh.js +196 -26
  48. package/dist/commands/sync.js +1 -1
  49. package/dist/commands/teams.js +11 -2
  50. package/dist/index.js +79 -10
  51. package/dist/lib/activity.d.ts +124 -0
  52. package/dist/lib/activity.js +542 -0
  53. package/dist/lib/agents.js +16 -5
  54. package/dist/lib/artifact-actions.d.ts +1 -1
  55. package/dist/lib/artifact-actions.js +1 -1
  56. package/dist/lib/ask-classifier.d.ts +2 -1
  57. package/dist/lib/ask-classifier.js +3 -3
  58. package/dist/lib/auth-health.d.ts +32 -4
  59. package/dist/lib/auth-health.js +40 -1
  60. package/dist/lib/auto-pull.js +8 -1
  61. package/dist/lib/brand.d.ts +40 -0
  62. package/dist/lib/brand.js +122 -0
  63. package/dist/lib/browser/drivers/ssh.js +4 -7
  64. package/dist/lib/browser/service.js +6 -8
  65. package/dist/lib/channels/providers/index.d.ts +2 -0
  66. package/dist/lib/channels/providers/index.js +20 -0
  67. package/dist/lib/channels/providers/mailbox.d.ts +2 -0
  68. package/dist/lib/channels/providers/mailbox.js +26 -0
  69. package/dist/lib/channels/providers/openclaw-telegram.d.ts +2 -0
  70. package/dist/lib/channels/providers/openclaw-telegram.js +34 -0
  71. package/dist/lib/channels/providers/rush.d.ts +6 -0
  72. package/dist/lib/channels/providers/rush.js +55 -0
  73. package/dist/lib/channels/registry.d.ts +42 -0
  74. package/dist/lib/channels/registry.js +20 -0
  75. package/dist/lib/channels/resolve.d.ts +11 -0
  76. package/dist/lib/channels/resolve.js +12 -0
  77. package/dist/lib/cli-resources.d.ts +18 -0
  78. package/dist/lib/cli-resources.js +53 -4
  79. package/dist/lib/cloud/codex.js +6 -3
  80. package/dist/lib/cloud/factory.d.ts +1 -0
  81. package/dist/lib/cloud/factory.js +10 -4
  82. package/dist/lib/cloud/rush.d.ts +3 -1
  83. package/dist/lib/cloud/rush.js +6 -1
  84. package/dist/lib/cloud/types.d.ts +2 -0
  85. package/dist/lib/codex-home.d.ts +35 -0
  86. package/dist/lib/codex-home.js +136 -0
  87. package/dist/lib/crabbox/cli.d.ts +44 -0
  88. package/dist/lib/crabbox/cli.js +120 -20
  89. package/dist/lib/crabbox/lease.d.ts +52 -1
  90. package/dist/lib/crabbox/lease.js +117 -16
  91. package/dist/lib/crabbox/progress.d.ts +31 -0
  92. package/dist/lib/crabbox/progress.js +76 -0
  93. package/dist/lib/crabbox/runtimes.d.ts +4 -0
  94. package/dist/lib/crabbox/runtimes.js +33 -8
  95. package/dist/lib/crabbox/setup-copy.d.ts +87 -0
  96. package/dist/lib/crabbox/setup-copy.js +127 -0
  97. package/dist/lib/daemon.d.ts +5 -5
  98. package/dist/lib/daemon.js +9 -9
  99. package/dist/lib/drive-sync.js +6 -3
  100. package/dist/lib/event-stream.d.ts +36 -0
  101. package/dist/lib/event-stream.js +68 -0
  102. package/dist/lib/events.d.ts +1 -1
  103. package/dist/lib/exec.d.ts +11 -0
  104. package/dist/lib/exec.js +23 -2
  105. package/dist/lib/feed-policy.d.ts +1 -1
  106. package/dist/lib/feed-policy.js +14 -9
  107. package/dist/lib/feed-ranking.d.ts +32 -0
  108. package/dist/lib/feed-ranking.js +224 -0
  109. package/dist/lib/feed.d.ts +35 -2
  110. package/dist/lib/feed.js +66 -3
  111. package/dist/lib/fleet/auth-sync.d.ts +53 -0
  112. package/dist/lib/fleet/auth-sync.js +92 -0
  113. package/dist/lib/fleet/remote-login.d.ts +187 -0
  114. package/dist/lib/fleet/remote-login.js +556 -0
  115. package/dist/lib/format.d.ts +30 -3
  116. package/dist/lib/format.js +34 -5
  117. package/dist/lib/fs-atomic.d.ts +7 -2
  118. package/dist/lib/fs-atomic.js +8 -12
  119. package/dist/lib/git.d.ts +16 -0
  120. package/dist/lib/git.js +79 -2
  121. package/dist/lib/heal.js +11 -3
  122. package/dist/lib/hosts/progress.d.ts +28 -10
  123. package/dist/lib/hosts/progress.js +79 -22
  124. package/dist/lib/hosts/remote-cmd.js +4 -0
  125. package/dist/lib/hq/floor.d.ts +87 -0
  126. package/dist/lib/hq/floor.js +226 -0
  127. package/dist/lib/menubar/install-menubar.js +14 -20
  128. package/dist/lib/notify.d.ts +1 -0
  129. package/dist/lib/notify.js +3 -3
  130. package/dist/lib/open-url.d.ts +2 -0
  131. package/dist/lib/open-url.js +19 -0
  132. package/dist/lib/openclaw-keychain.d.ts +56 -0
  133. package/dist/lib/openclaw-keychain.js +236 -0
  134. package/dist/lib/overdue.js +10 -0
  135. package/dist/lib/permissions.d.ts +44 -1
  136. package/dist/lib/permissions.js +284 -7
  137. package/dist/lib/project-launch.d.ts +6 -12
  138. package/dist/lib/project-launch.js +13 -228
  139. package/dist/lib/project-resources.d.ts +7 -0
  140. package/dist/lib/project-resources.js +291 -0
  141. package/dist/lib/pty-client.d.ts +27 -0
  142. package/dist/lib/pty-client.js +136 -10
  143. package/dist/lib/refresh.js +14 -10
  144. package/dist/lib/resource-profiles.d.ts +26 -0
  145. package/dist/lib/resource-profiles.js +157 -0
  146. package/dist/lib/resources/permissions.js +7 -1
  147. package/dist/lib/resources/types.d.ts +1 -1
  148. package/dist/lib/resources.d.ts +1 -1
  149. package/dist/lib/resources.js +32 -3
  150. package/dist/lib/routines.d.ts +11 -0
  151. package/dist/lib/routines.js +56 -15
  152. package/dist/lib/runner.d.ts +1 -0
  153. package/dist/lib/runner.js +66 -5
  154. package/dist/lib/secrets/bundles.d.ts +16 -3
  155. package/dist/lib/secrets/bundles.js +206 -37
  156. package/dist/lib/secrets/icloud-import.d.ts +2 -2
  157. package/dist/lib/secrets/icloud-import.js +9 -6
  158. package/dist/lib/secrets/mcp.js +1 -1
  159. package/dist/lib/secrets/vault-age-helper.d.ts +1 -0
  160. package/dist/lib/secrets/vault-age-helper.js +34 -0
  161. package/dist/lib/secrets/vault.d.ts +49 -0
  162. package/dist/lib/secrets/vault.js +397 -0
  163. package/dist/lib/self-heal/checks/path.js +3 -1
  164. package/dist/lib/self-heal/checks/shadowing.js +10 -2
  165. package/dist/lib/self-heal/checks/shims.js +19 -11
  166. package/dist/lib/session/cloud.d.ts +2 -2
  167. package/dist/lib/session/cloud.js +2 -2
  168. package/dist/lib/session/db.d.ts +4 -0
  169. package/dist/lib/session/db.js +44 -7
  170. package/dist/lib/session/discover.d.ts +2 -0
  171. package/dist/lib/session/discover.js +114 -3
  172. package/dist/lib/session/stream-render.d.ts +3 -0
  173. package/dist/lib/session/stream-render.js +130 -0
  174. package/dist/lib/session/types.d.ts +6 -0
  175. package/dist/lib/share/analytics.d.ts +13 -0
  176. package/dist/lib/share/analytics.js +45 -0
  177. package/dist/lib/share/config.d.ts +19 -5
  178. package/dist/lib/share/config.js +44 -8
  179. package/dist/lib/share/provision.d.ts +58 -6
  180. package/dist/lib/share/provision.js +97 -22
  181. package/dist/lib/share/publish.d.ts +36 -13
  182. package/dist/lib/share/publish.js +55 -8
  183. package/dist/lib/share/worker-template.js +83 -17
  184. package/dist/lib/shims.d.ts +9 -0
  185. package/dist/lib/shims.js +91 -13
  186. package/dist/lib/ssh-exec.d.ts +14 -0
  187. package/dist/lib/ssh-exec.js +57 -0
  188. package/dist/lib/staleness/detectors/hooks.js +25 -1
  189. package/dist/lib/staleness/detectors/permissions.js +66 -0
  190. package/dist/lib/staleness/detectors/workflows.js +20 -0
  191. package/dist/lib/staleness/writers/hooks.js +58 -4
  192. package/dist/lib/staleness/writers/permissions.js +2 -2
  193. package/dist/lib/staleness/writers/skills.js +1 -1
  194. package/dist/lib/staleness/writers/sources.d.ts +10 -1
  195. package/dist/lib/staleness/writers/sources.js +68 -1
  196. package/dist/lib/star-nudge.d.ts +45 -0
  197. package/dist/lib/star-nudge.js +91 -0
  198. package/dist/lib/startup/command-registry.d.ts +6 -1
  199. package/dist/lib/startup/command-registry.js +17 -3
  200. package/dist/lib/state.d.ts +2 -0
  201. package/dist/lib/state.js +3 -0
  202. package/dist/lib/subagents-registry.d.ts +2 -0
  203. package/dist/lib/subagents-registry.js +3 -0
  204. package/dist/lib/teams/parsers.js +214 -6
  205. package/dist/lib/teams/supervisor.d.ts +7 -0
  206. package/dist/lib/teams/supervisor.js +2 -1
  207. package/dist/lib/template.d.ts +1 -1
  208. package/dist/lib/template.js +1 -1
  209. package/dist/lib/triggers/webhook.js +36 -3
  210. package/dist/lib/types.d.ts +73 -0
  211. package/dist/lib/version.d.ts +40 -0
  212. package/dist/lib/version.js +94 -16
  213. package/dist/lib/versions.d.ts +11 -0
  214. package/dist/lib/versions.js +208 -76
  215. package/dist/lib/workflows.d.ts +2 -0
  216. package/dist/lib/workflows.js +88 -0
  217. package/package.json +2 -1
  218. package/dist/commands/daemon.d.ts +0 -10
  219. package/dist/commands/daemon.js +0 -121
@@ -37,6 +37,36 @@ export function pickLeaseBundleFromList(bundles) {
37
37
  }
38
38
  return undefined;
39
39
  }
40
+ /**
41
+ * Env keys a bundle may use for a Tailscale auth key. crabbox reads
42
+ * `CRABBOX_TAILSCALE_AUTH_KEY` to join a leased box to the tailnet; we accept the
43
+ * common alternate names too and rename to that canonical key on injection.
44
+ */
45
+ export const TAILSCALE_AUTH_KEY_NAMES = ['CRABBOX_TAILSCALE_AUTH_KEY', 'TAILSCALE_AUTH_KEY', 'TS_AUTHKEY'];
46
+ /** The first bundle + key that declares a Tailscale auth key, or undefined. Pure over `bundles`. */
47
+ export function pickTailscaleBundleFromList(bundles) {
48
+ for (const b of bundles) {
49
+ const key = Object.keys(b.vars ?? {}).find((k) => TAILSCALE_AUTH_KEY_NAMES.includes(k));
50
+ if (key)
51
+ return { name: b.name, key };
52
+ }
53
+ return undefined;
54
+ }
55
+ /** Process-lifetime memo so the tailscale bundle `listBundles()` scan runs at most once. */
56
+ let tailscaleBundleMemo;
57
+ function resolveTailscaleBundleMemo() {
58
+ if (!tailscaleBundleMemo) {
59
+ let value;
60
+ try {
61
+ value = pickTailscaleBundleFromList(listBundles());
62
+ }
63
+ catch {
64
+ /* secrets unreadable — no auto-detect */
65
+ }
66
+ tailscaleBundleMemo = { value };
67
+ }
68
+ return tailscaleBundleMemo.value;
69
+ }
40
70
  /**
41
71
  * The secrets bundle to feed crabbox, resolved in priority order:
42
72
  * 1. `AGENTS_LEASE_SECRETS_BUNDLE` env var — explicit, no keychain
@@ -92,30 +122,55 @@ export function setLeaseSecretsBundle(name) {
92
122
  }
93
123
  /** Build the child env for crabbox, injecting a secrets bundle when configured. */
94
124
  export function crabboxEnv(opts) {
125
+ const out = { ...process.env };
95
126
  const resolved = opts.secretsBundle
96
127
  ? { name: opts.secretsBundle }
97
128
  : resolveLeaseBundleMemo();
98
- if (!resolved)
99
- return process.env;
100
- try {
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
- // --lease is headless by contract and crabboxEnv is called several times
109
- // per run (list/wait/spawn/stop) resolve broker-only so a keychain bundle
110
- // can't pop repeated unwatched Touch ID sheets mid-lease.
111
- agentOnly: isHeadlessSecretsContext(),
112
- });
113
- return { ...process.env, ...env };
129
+ if (resolved) {
130
+ try {
131
+ // Auto-detected bundle → inject ONLY the provider token key(s) (least
132
+ // privilege; an unrelated bundle can't leak its other secrets into crabbox).
133
+ // An explicitly-named bundle (env/config or `opts.secretsBundle`) injects
134
+ // whole the user chose it. Same resolver `agents secrets exec` uses.
135
+ const { env } = readAndResolveBundleEnv(resolved.name, {
136
+ caller: 'agents run --lease (crabbox)',
137
+ keys: resolved.keys,
138
+ // --lease is headless by contract and crabboxEnv is called several times
139
+ // per run (list/wait/spawn/stop) resolve broker-only so a keychain bundle
140
+ // can't pop repeated unwatched Touch ID sheets mid-lease.
141
+ agentOnly: isHeadlessSecretsContext(),
142
+ });
143
+ Object.assign(out, env);
144
+ }
145
+ catch (e) {
146
+ throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
147
+ `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
148
+ }
114
149
  }
115
- catch (e) {
116
- throw new Error(`Could not load secrets bundle "${resolved.name}" for crabbox: ${e.message}. ` +
117
- `Fix the bundle (agents secrets view ${resolved.name}) or unset lease.secretsBundle to use crabbox's own login.`);
150
+ // Tailscale plumbing (F5): inject CRABBOX_TAILSCALE_AUTH_KEY from a bundle that
151
+ // declares a tailscale auth key, when the ambient env doesn't already set one.
152
+ // Best-effort and opt-in a missing/unreadable tailscale bundle never fails a
153
+ // public-network lease; `crabboxWarmup({ netMode: 'tailscale' })` is what decides
154
+ // whether the key is actually used.
155
+ if (!out.CRABBOX_TAILSCALE_AUTH_KEY) {
156
+ const ts = resolveTailscaleBundleMemo();
157
+ if (ts) {
158
+ try {
159
+ const { env } = readAndResolveBundleEnv(ts.name, {
160
+ caller: 'agents run --lease (crabbox tailscale)',
161
+ keys: [ts.key],
162
+ agentOnly: isHeadlessSecretsContext(),
163
+ });
164
+ const value = env[ts.key];
165
+ if (value)
166
+ out.CRABBOX_TAILSCALE_AUTH_KEY = value;
167
+ }
168
+ catch {
169
+ /* best-effort — tailscale is opt-in plumbing, never blocks a public lease */
170
+ }
171
+ }
118
172
  }
173
+ return out;
119
174
  }
120
175
  function normalizeBox(raw) {
121
176
  const labels = (raw.labels ?? {});
@@ -138,6 +193,8 @@ function normalizeBox(raw) {
138
193
  lease: labels.lease ?? '',
139
194
  state,
140
195
  ip: publicNet.ipv4?.ip || undefined,
196
+ tailscaleIPv4: labels.tailscale_ipv4 || undefined,
197
+ tailscaleFQDN: labels.tailscale_fqdn || undefined,
141
198
  profile: labels.profile,
142
199
  class: labels.class,
143
200
  ready: status === 'running' && state === 'ready',
@@ -151,7 +208,11 @@ function normalizeBox(raw) {
151
208
  /** All crabbox machines the broker knows about. */
152
209
  export function crabboxList(opts = {}) {
153
210
  findCrabbox();
154
- const r = spawnSync('crabbox', ['list', '--json'], { encoding: 'utf-8', env: crabboxEnv(opts) });
211
+ const timeoutMs = opts.timeoutMs ?? 8000;
212
+ const r = spawnSync('crabbox', ['list', '--json'], { encoding: 'utf-8', env: crabboxEnv(opts), timeout: timeoutMs });
213
+ if (r.error && r.error.code === 'ETIMEDOUT') {
214
+ throw new Error(`crabbox list timed out after ${Math.round(timeoutMs / 1000)}s (provider slow or unreachable)`);
215
+ }
155
216
  if (r.status !== 0) {
156
217
  throw new Error(`crabbox list failed: ${(r.stderr || r.stdout || '').trim() || 'unknown error'}`);
157
218
  }
@@ -190,6 +251,10 @@ export async function crabboxWarmup(opts = {}) {
190
251
  args.push('--provider', opts.provider);
191
252
  if (opts.code)
192
253
  args.push('--code');
254
+ // Tailscale plumbing (F5): join the box to the tailnet, tagged tag:crabbox.
255
+ // The auth key rides the child env as CRABBOX_TAILSCALE_AUTH_KEY (see crabboxEnv).
256
+ if (opts.netMode === 'tailscale')
257
+ args.push('--network', 'tailscale', '-tailscale-tags', 'tag:crabbox');
193
258
  // Async spawn (not spawnSync): provisioning takes 30-90s and a blocking call
194
259
  // would freeze any caller's progress spinner. Output is captured, not streamed.
195
260
  const r = await new Promise((resolve) => {
@@ -324,6 +389,41 @@ export function crabboxRunScript(slug, script, opts = {}) {
324
389
  proc.stdin.end();
325
390
  });
326
391
  }
392
+ /**
393
+ * Parse crabbox's shell-quoted `ssh` command (`'ssh' '-i' '<key>' … 'crabbox@ip'`)
394
+ * into an argv array. Exported for testing.
395
+ */
396
+ export function parseCrabboxSshArgv(stdout) {
397
+ for (const raw of stdout.split('\n')) {
398
+ const line = raw.trim();
399
+ if (!line.startsWith("'ssh'") && !line.startsWith('ssh '))
400
+ continue;
401
+ const toks = [...line.matchAll(/'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2]);
402
+ if (toks[0] === 'ssh' && toks.length >= 3)
403
+ return toks;
404
+ }
405
+ return null;
406
+ }
407
+ /**
408
+ * The concrete `ssh` argv crabbox uses to reach box `slug` — including the
409
+ * per-lease identity key and known_hosts crabbox provisions under its own config
410
+ * dir. A raw `ssh crabbox@ip` fails `publickey`; this is the only auth that works.
411
+ * `crabbox ssh --id` PRINTS the command (it doesn't connect — that's `crabbox
412
+ * connect`), and `--reclaim` makes it resolve regardless of which repo holds the
413
+ * lease claim (matching `crabboxRunScript`). Returns null when crabbox can't
414
+ * resolve the box (caller treats setup-copy / `agents ssh` as best-effort).
415
+ */
416
+ export function crabboxSshArgv(slug, opts = {}) {
417
+ findCrabbox();
418
+ const r = spawnSync('crabbox', ['ssh', '--id', slug, '--reclaim'], {
419
+ encoding: 'utf-8',
420
+ env: crabboxEnv(opts),
421
+ timeout: opts.timeoutMs ?? 15000,
422
+ });
423
+ if (r.status !== 0 || !r.stdout)
424
+ return null;
425
+ return parseCrabboxSshArgv(r.stdout);
426
+ }
327
427
  /** Release the lease / delete the box. Best-effort; never throws. */
328
428
  export function crabboxStop(slug, opts = {}) {
329
429
  try {
@@ -5,6 +5,24 @@
5
5
  * credentials → run the agent on the box (via `crabbox run`, which owns the
6
6
  * SSH) → tear the box down. The whole box-side sequence rides a single
7
7
  * `--script-stdin` body so the token contents never touch argv.
8
+ *
9
+ * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
+ * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
11
+ * • `buildBootstrapScript(opts)` — `opts.copySetup` (default TRUE; clear for
12
+ * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
+ * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
+ * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
+ * `leaseAndRun` (netMode → `crabboxWarmup`).
16
+ * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
+ * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
+ * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
19
+ * • `CrabboxBox.tailscaleIPv4` / `CrabboxBox.tailscaleFQDN` — populated from
20
+ * the box labels by `normalizeBox` (cli.ts).
21
+ * • Step stream (progress.ts): `type LeaseStep`, the `onStep` router option,
22
+ * and the self-contained `renderStepLine(step)` helper (the lib never prints).
23
+ * • Setup-copy (setup-copy.ts): `copySetupToBox({ host, user?, port?,
24
+ * secretsBundle?, userAgentsDir?, onData? }): Promise<CopySetupResult>` — the
25
+ * push-from-local the command layer runs before the box run.
8
26
  */
9
27
  import type { AgentId } from '../types.js';
10
28
  import { type CrabboxBox } from './cli.js';
@@ -13,6 +31,9 @@ import { type DetectedRuntime } from './runtimes.js';
13
31
  export type LeasePhase = {
14
32
  kind: 'warmup';
15
33
  backend?: string;
34
+ } | {
35
+ kind: 'reuse';
36
+ slug: string;
16
37
  } | {
17
38
  kind: 'ready';
18
39
  box: CrabboxBox;
@@ -29,16 +50,36 @@ export interface LeaseRunOptions {
29
50
  backend?: string;
30
51
  boxClass?: string;
31
52
  profile?: string;
32
- /** Runtimes to install + authenticate on the box (from the picker). */
53
+ /** Runtimes to install on the box. */
33
54
  runtimes: AgentId[];
55
+ /** Runtime credentials to copy; defaults to `runtimes`. */
56
+ credentialRuntimes?: AgentId[];
34
57
  detected: DetectedRuntime[];
58
+ /** Profile-dispatch config to materialize on the leased box before the run. */
59
+ dispatchProfile?: LeaseDispatchProfile;
35
60
  /** Secrets bundle providing crabbox's provider token. */
36
61
  secretsBundle?: string;
62
+ /**
63
+ * Push the git-tracked subset of the local `~/.agents` onto the box before the
64
+ * run (default TRUE). The command layer sets `false` for `--bare`. When true,
65
+ * `buildBootstrapScript` emits the `copy-setup` progress sentinel; the actual
66
+ * push-from-local is `copySetupToBox` (setup-copy.ts), which the command layer
67
+ * runs before the box run.
68
+ */
69
+ copySetup?: boolean;
70
+ /**
71
+ * Network mode threaded to `crabboxWarmup` (default `'public'`). `'tailscale'`
72
+ * leases the box onto the tailnet and adds a `joined-tailnet` progress step.
73
+ * The command layer decides WHEN to enable it — this is plumbing only.
74
+ */
75
+ netMode?: 'public' | 'tailscale';
37
76
  onData?: (s: string) => void;
38
77
  /** Progress phases (warmup → ready → teardown) for a command-layer spinner. */
39
78
  onPhase?: (phase: LeasePhase) => void;
40
79
  /** Keep the box after the run instead of stopping it. */
41
80
  keep?: boolean;
81
+ /** Existing warm crabbox slug to reuse instead of provisioning a new lease. */
82
+ reuseBox?: string;
42
83
  /**
43
84
  * Raw wrapped Claude OAuth payload (from `resolveClaudeCredentialsBlob`), written
44
85
  * to `~/.claude/.credentials.json` on the box. The command layer resolves it
@@ -46,6 +87,16 @@ export interface LeaseRunOptions {
46
87
  */
47
88
  claudeCredentialsJson?: string | null;
48
89
  }
90
+ export interface LeaseDispatchProfile {
91
+ name: string;
92
+ agent: AgentId;
93
+ version?: string;
94
+ env: Record<string, string>;
95
+ description?: string;
96
+ preset?: string;
97
+ provider?: string;
98
+ fallbackModel?: string;
99
+ }
49
100
  export interface LeaseRunResult {
50
101
  box: CrabboxBox;
51
102
  exitCode: number | null;
@@ -5,14 +5,58 @@
5
5
  * credentials → run the agent on the box (via `crabbox run`, which owns the
6
6
  * SSH) → tear the box down. The whole box-side sequence rides a single
7
7
  * `--script-stdin` body so the token contents never touch argv.
8
+ *
9
+ * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
+ * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
11
+ * • `buildBootstrapScript(opts)` — `opts.copySetup` (default TRUE; clear for
12
+ * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
+ * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
+ * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
+ * `leaseAndRun` (netMode → `crabboxWarmup`).
16
+ * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
+ * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
+ * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
19
+ * • `CrabboxBox.tailscaleIPv4` / `CrabboxBox.tailscaleFQDN` — populated from
20
+ * the box labels by `normalizeBox` (cli.ts).
21
+ * • Step stream (progress.ts): `type LeaseStep`, the `onStep` router option,
22
+ * and the self-contained `renderStepLine(step)` helper (the lib never prints).
23
+ * • Setup-copy (setup-copy.ts): `copySetupToBox({ host, user?, port?,
24
+ * secretsBundle?, userAgentsDir?, onData? }): Promise<CopySetupResult>` — the
25
+ * push-from-local the command layer runs before the box run.
8
26
  */
9
- import { crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
10
- import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
11
- import { LEASE_AGENT_MARKER } from './progress.js';
27
+ import { crabboxFind, crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
28
+ import * as yaml from 'yaml';
29
+ import { buildCredentialScript, buildHomeFileWriteScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
30
+ import { LEASE_AGENT_MARKER, leasePhaseSentinel } from './progress.js';
31
+ import { copySetupToBox } from './setup-copy.js';
12
32
  /** POSIX single-quote for safe embedding in the generated bootstrap script. */
13
33
  function q(s) {
14
34
  return "'" + s.replace(/'/g, "'\\''") + "'";
15
35
  }
36
+ function profileRemotePath(name) {
37
+ return `.agents/profiles/${name}.yml`;
38
+ }
39
+ function buildProfileScript(profile) {
40
+ const body = yaml.stringify({
41
+ name: profile.name,
42
+ ...(profile.description ? { description: profile.description } : {}),
43
+ host: {
44
+ agent: profile.agent,
45
+ ...(profile.version ? { version: profile.version } : {}),
46
+ },
47
+ env: profile.env,
48
+ ...(profile.fallbackModel ? { fallback_model: profile.fallbackModel } : {}),
49
+ ...(profile.preset ? { preset: profile.preset } : {}),
50
+ ...(profile.provider ? { provider: profile.provider } : {}),
51
+ });
52
+ return buildHomeFileWriteScript(profileRemotePath(profile.name), body);
53
+ }
54
+ function runtimeInstallSpec(id, dispatchProfile) {
55
+ if (dispatchProfile?.agent === id && dispatchProfile.version) {
56
+ return `${id}@${dispatchProfile.version}`;
57
+ }
58
+ return id;
59
+ }
16
60
  /**
17
61
  * Bash snippet that guarantees `agents` is runnable on the box. Fresh crabbox
18
62
  * images ship without node, and the box user may not own the global npm prefix,
@@ -47,9 +91,11 @@ const ENSURE_AGENTS_CLI = [
47
91
  * the credential files. Best-effort install steps never abort the run.
48
92
  */
49
93
  export function buildBootstrapScript(opts) {
50
- const credScript = buildCredentialScript(opts.runtimes, opts.detected, {
94
+ const credentialRuntimes = opts.credentialRuntimes ?? opts.runtimes;
95
+ const credScript = buildCredentialScript(credentialRuntimes, opts.detected, {
51
96
  claudeCredentialsJson: opts.claudeCredentialsJson,
52
97
  });
98
+ const profileScript = opts.dispatchProfile ? buildProfileScript(opts.dispatchProfile) : '';
53
99
  const runParts = ['agents', 'run', q(opts.agent), q(opts.prompt), '--quiet'];
54
100
  if (opts.mode)
55
101
  runParts.push('--mode', q(opts.mode));
@@ -58,17 +104,40 @@ export function buildBootstrapScript(opts) {
58
104
  // Credential files to shred after the run (home-level paths written above).
59
105
  // Runs regardless of --keep-box (it's in the box body, not teardown), so a kept
60
106
  // box still loses the token after the run — minimizing the credential window.
61
- const shredPaths = opts.runtimes.flatMap((id) => {
107
+ const shredPaths = credentialRuntimes.flatMap((id) => {
62
108
  const paths = { claude: ['.claude.json', CLAUDE_TOKEN_REMOTE], codex: ['.codex/auth.json'], gemini: ['.gemini/google_accounts.json'], grok: ['.grok/auth.json'] }[id];
63
109
  return paths ?? [];
64
110
  });
111
+ if (opts.dispatchProfile)
112
+ shredPaths.push(profileRemotePath(opts.dispatchProfile.name));
65
113
  const shred = shredPaths.map((p) => `rm -f "$HOME/${p}" 2>/dev/null || true`).join('\n');
66
- const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
114
+ const installRuntimes = opts.runtimes
115
+ .map((id) => `agents add ${q(runtimeInstallSpec(id, opts.dispatchProfile))} >/dev/null 2>&1 || true`)
116
+ .join('\n');
117
+ // `echo`-ed phase sentinels (progress.ts) let the command layer drive a
118
+ // step-by-step UI. `createLeaseOutputRouter` swallows these lines and surfaces
119
+ // them as a structured step stream — they never appear as setup noise.
120
+ const step = (name) => `echo ${q(leasePhaseSentinel(name))}`;
121
+ const copySetup = opts.copySetup !== false; // default TRUE
67
122
  return [
68
123
  'set -uo pipefail',
124
+ // crabbox has finished its workspace resync by the time this script runs;
125
+ // the sync sentinel marks the transition out of that (crabbox-driven) phase.
126
+ step('sync'),
127
+ // Only meaningful on a tailnet lease — the box already joined during warmup.
128
+ opts.netMode === 'tailscale' ? step('joined-tailnet') : '',
129
+ step('install'),
69
130
  ENSURE_AGENTS_CLI,
131
+ step('runtime'),
70
132
  installRuntimes,
133
+ step('creds'),
71
134
  credScript,
135
+ profileScript,
136
+ // copy-setup: the host already rsync'd the git-tracked ~/.agents onto the box
137
+ // (leaseAndRun, before this script). Here — after ENSURE_AGENTS_CLI installed
138
+ // the CLI — we materialize that config into the runtime home. Gated by
139
+ // copySetup (cleared for --bare). Best-effort; a refresh failure never aborts.
140
+ copySetup ? [step('copy-setup'), 'agents repo refresh >/dev/null 2>&1 || true'].join('\n') : '',
72
141
  // Marker on its own line: the command layer shows everything before this as
73
142
  // setup progress and everything after (the agent's output) verbatim.
74
143
  `echo ${q(LEASE_AGENT_MARKER)}`,
@@ -82,15 +151,47 @@ export function buildBootstrapScript(opts) {
82
151
  }
83
152
  export async function leaseAndRun(opts) {
84
153
  const startedAt = Date.now();
85
- opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
86
- const box = await crabboxWarmup({
87
- class: opts.boxClass,
88
- profile: opts.profile,
89
- provider: opts.backend,
90
- secretsBundle: opts.secretsBundle,
91
- });
92
- await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
154
+ let box;
155
+ if (opts.reuseBox) {
156
+ opts.onPhase?.({ kind: 'reuse', slug: opts.reuseBox });
157
+ const found = crabboxFind(opts.reuseBox, { secretsBundle: opts.secretsBundle });
158
+ if (!found)
159
+ throw new Error(`crabbox box "${opts.reuseBox}" was not found. Check \`crabbox list\` or pass a different --box slug.`);
160
+ box = found.ready
161
+ ? found
162
+ : await crabboxWaitReady(opts.reuseBox, { secretsBundle: opts.secretsBundle });
163
+ }
164
+ else {
165
+ opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
166
+ box = await crabboxWarmup({
167
+ class: opts.boxClass,
168
+ profile: opts.profile,
169
+ provider: opts.backend,
170
+ secretsBundle: opts.secretsBundle,
171
+ netMode: opts.netMode,
172
+ });
173
+ await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
174
+ }
93
175
  opts.onPhase?.({ kind: 'ready', box, elapsedMs: Date.now() - startedAt });
176
+ // Setup-copy (F1, RUSH-1920): push the git-tracked ~/.agents config onto the
177
+ // box from the host, over crabbox's own per-lease ssh (a raw ssh fails
178
+ // publickey). rsync only here — the box has no agents-cli yet, so the matching
179
+ // `agents repo refresh` runs inside the bootstrap script, after the install
180
+ // step. Best-effort: a copy failure never aborts the run (the agent just runs
181
+ // without the config).
182
+ if (opts.copySetup !== false) {
183
+ try {
184
+ await copySetupToBox({
185
+ slug: box.slug,
186
+ secretsBundle: opts.secretsBundle,
187
+ onData: opts.onData,
188
+ refresh: false,
189
+ });
190
+ }
191
+ catch {
192
+ /* best-effort — never block the run on a config-copy failure */
193
+ }
194
+ }
94
195
  const script = buildBootstrapScript(opts);
95
196
  let exitCode = null;
96
197
  let toreDown = false;
@@ -102,8 +203,8 @@ export async function leaseAndRun(opts) {
102
203
  }
103
204
  finally {
104
205
  // Always attempt teardown (bounds credential lifetime to the run) unless the
105
- // caller explicitly asked to keep the box.
106
- if (!opts.keep) {
206
+ // caller explicitly asked to keep the box or targeted an existing warm box.
207
+ if (!opts.keep && !opts.reuseBox) {
107
208
  opts.onPhase?.({ kind: 'teardown' });
108
209
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
109
210
  }
@@ -9,6 +9,27 @@
9
9
  */
10
10
  /** Sentinel echoed on the box right before `agents run`. Distinctive + collision-proof. */
11
11
  export declare const LEASE_AGENT_MARKER = "___AGENTS_LEASE_AGENT_OUTPUT_b1f4c2___";
12
+ /**
13
+ * A structured setup step, parsed from a `___PHASE_<name>___` sentinel line the
14
+ * bootstrap script echoes before each block (sync/install/runtime/creds/…). The
15
+ * command layer renders these to drive a step-by-step progress UI; the lib never
16
+ * prints (see `renderStepLine`).
17
+ */
18
+ export type LeaseStep = {
19
+ name: string;
20
+ detail?: string;
21
+ elapsedMs?: number;
22
+ };
23
+ /**
24
+ * The sentinel line the bootstrap script echoes to announce phase `name`.
25
+ * Kept distinct from `LEASE_AGENT_MARKER` (which does NOT start with `___PHASE_`).
26
+ */
27
+ export declare function leasePhaseSentinel(name: string): string;
28
+ /**
29
+ * Render one lease step as a single human line the command layer can print
30
+ * (e.g. behind a spinner). Self-contained: the lib never writes to a stream.
31
+ */
32
+ export declare function renderStepLine(step: LeaseStep): string;
12
33
  export interface Spinner {
13
34
  /** Begin animating a phase (renders `frame text` on one line, TTY only). */
14
35
  start(text: string): void;
@@ -49,14 +70,24 @@ export interface LeaseOutputRouter {
49
70
  sawAgent(): boolean;
50
71
  /** The setup lines seen so far (for a failure dump). */
51
72
  setupLines(): string[];
73
+ /** The structured steps parsed from phase sentinels so far. */
74
+ steps(): LeaseStep[];
52
75
  }
53
76
  /**
54
77
  * Split the crabbox run stream at `LEASE_AGENT_MARKER`. `onSetupLine` fires for
55
78
  * each complete non-empty line before the marker; `onAgentChunk` fires with raw
56
79
  * text after it (streamed promptly, not line-buffered, so agent output is live).
80
+ *
81
+ * Setup lines matching a `___PHASE_<name>___` sentinel are swallowed (never shown
82
+ * as setup noise) and surfaced through `onStep` instead — a structured step
83
+ * stream the command layer can render via `renderStepLine`. When `now` is
84
+ * provided, each step carries `elapsedMs` since the previous step (or router
85
+ * start for the first); omit `now` for deterministic, timing-free output.
57
86
  */
58
87
  export declare function createLeaseOutputRouter(cb: {
59
88
  onSetupLine: (line: string) => void;
60
89
  onAgentChunk: (chunk: string) => void;
90
+ onStep?: (step: LeaseStep) => void;
61
91
  marker?: string;
92
+ now?: () => number;
62
93
  }): LeaseOutputRouter;
@@ -9,6 +9,54 @@
9
9
  */
10
10
  /** Sentinel echoed on the box right before `agents run`. Distinctive + collision-proof. */
11
11
  export const LEASE_AGENT_MARKER = '___AGENTS_LEASE_AGENT_OUTPUT_b1f4c2___';
12
+ const PHASE_PREFIX = '___PHASE_';
13
+ const PHASE_SUFFIX = '___';
14
+ /**
15
+ * The sentinel line the bootstrap script echoes to announce phase `name`.
16
+ * Kept distinct from `LEASE_AGENT_MARKER` (which does NOT start with `___PHASE_`).
17
+ */
18
+ export function leasePhaseSentinel(name) {
19
+ return `${PHASE_PREFIX}${name}${PHASE_SUFFIX}`;
20
+ }
21
+ /** Parse a phase name out of a sentinel line, or null when the line is not one. */
22
+ function parsePhaseSentinel(line) {
23
+ const t = line.trim();
24
+ if (!t.startsWith(PHASE_PREFIX) || !t.endsWith(PHASE_SUFFIX) || t.length <= PHASE_PREFIX.length + PHASE_SUFFIX.length) {
25
+ return null;
26
+ }
27
+ const name = t.slice(PHASE_PREFIX.length, t.length - PHASE_SUFFIX.length);
28
+ // Phase names are lowercase kebab tokens; this also rejects the agent marker's
29
+ // underscore-laden tail if it ever changed to share the ___PHASE_ prefix.
30
+ return /^[a-z0-9-]+$/.test(name) ? name : null;
31
+ }
32
+ /** Human labels for the known bootstrap phases. Falls back to the raw name. */
33
+ const STEP_LABELS = {
34
+ sync: 'Syncing workspace',
35
+ install: 'Installing agents-cli',
36
+ runtime: 'Installing agent runtimes',
37
+ creds: 'Provisioning credentials',
38
+ 'copy-setup': 'Copying your setup',
39
+ 'joined-tailnet': 'Joined tailnet',
40
+ };
41
+ /** Human-readable elapsed: "0.8s", "3.4s", "1m 5s". */
42
+ function formatElapsed(ms) {
43
+ if (ms < 10_000)
44
+ return `${(ms / 1000).toFixed(1)}s`;
45
+ const secs = Math.round(ms / 1000);
46
+ if (secs < 60)
47
+ return `${secs}s`;
48
+ return `${Math.floor(secs / 60)}m ${secs % 60}s`;
49
+ }
50
+ /**
51
+ * Render one lease step as a single human line the command layer can print
52
+ * (e.g. behind a spinner). Self-contained: the lib never writes to a stream.
53
+ */
54
+ export function renderStepLine(step) {
55
+ const label = STEP_LABELS[step.name] ?? step.name;
56
+ const detail = step.detail ? ` — ${step.detail}` : '';
57
+ const elapsed = step.elapsedMs !== undefined ? ` (${formatElapsed(step.elapsedMs)})` : '';
58
+ return `${label}${detail}${elapsed}`;
59
+ }
12
60
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
13
61
  /**
14
62
  * A deliberately minimal, self-throttled spinner. Unlike `ora`, it does NOT hook
@@ -77,12 +125,21 @@ export function createSpinner(opts = {}) {
77
125
  * Split the crabbox run stream at `LEASE_AGENT_MARKER`. `onSetupLine` fires for
78
126
  * each complete non-empty line before the marker; `onAgentChunk` fires with raw
79
127
  * text after it (streamed promptly, not line-buffered, so agent output is live).
128
+ *
129
+ * Setup lines matching a `___PHASE_<name>___` sentinel are swallowed (never shown
130
+ * as setup noise) and surfaced through `onStep` instead — a structured step
131
+ * stream the command layer can render via `renderStepLine`. When `now` is
132
+ * provided, each step carries `elapsedMs` since the previous step (or router
133
+ * start for the first); omit `now` for deterministic, timing-free output.
80
134
  */
81
135
  export function createLeaseOutputRouter(cb) {
82
136
  const marker = cb.marker ?? LEASE_AGENT_MARKER;
137
+ const now = cb.now;
83
138
  let seen = false;
84
139
  let buf = '';
85
140
  const setup = [];
141
+ const stepList = [];
142
+ let lastStepAt = now ? now() : undefined;
86
143
  const emitLine = (line) => {
87
144
  const t = line.replace(/\r$/, '');
88
145
  if (t.trim()) {
@@ -90,6 +147,17 @@ export function createLeaseOutputRouter(cb) {
90
147
  cb.onSetupLine(t);
91
148
  }
92
149
  };
150
+ const emitStep = (name) => {
151
+ let elapsedMs;
152
+ if (now) {
153
+ const t = now();
154
+ elapsedMs = lastStepAt !== undefined ? t - lastStepAt : undefined;
155
+ lastStepAt = t;
156
+ }
157
+ const step = elapsedMs !== undefined ? { name, elapsedMs } : { name };
158
+ stepList.push(step);
159
+ cb.onStep?.(step);
160
+ };
93
161
  return {
94
162
  push(chunk) {
95
163
  if (seen) {
@@ -110,6 +178,11 @@ export function createLeaseOutputRouter(cb) {
110
178
  }
111
179
  return;
112
180
  }
181
+ const phase = parsePhaseSentinel(line);
182
+ if (phase !== null) {
183
+ emitStep(phase); // swallow the sentinel; report a structured step
184
+ continue;
185
+ }
113
186
  emitLine(line);
114
187
  }
115
188
  // A trailing partial line stays in `buf` — it may be the marker forming.
@@ -125,5 +198,8 @@ export function createLeaseOutputRouter(cb) {
125
198
  setupLines() {
126
199
  return setup;
127
200
  },
201
+ steps() {
202
+ return stepList;
203
+ },
128
204
  };
129
205
  }
@@ -60,6 +60,10 @@ export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choi
60
60
  * under a non-obvious runtime are resolved separately — see RUSH-1725.
61
61
  */
62
62
  export declare function inferLeaseRuntime(agentName: string, detected: DetectedRuntime[]): AgentId | null;
63
+ /** True when a profile already carries auth for its host runtime via env. */
64
+ export declare function profileNeedsBaseRuntimeCredentials(agent: AgentId, env: Record<string, string>, authEnvVar?: string): boolean;
65
+ /** Build a quoted heredoc write to a path under the remote user's home. */
66
+ export declare function buildHomeFileWriteScript(remote: string, contents: string): string;
63
67
  /**
64
68
  * Where Claude Code reads its OAuth token on the box. `.claude.json` (the file
65
69
  * LEASE_RUNTIMES copies) is config/account-metadata ONLY — the actual token