warpmetal 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,11 +41,15 @@ x402api help --json
41
41
  Install the bundled skill for supported coding agents:
42
42
 
43
43
  ```sh
44
+ warpmetal agent install --target <codex|claude|all>
44
45
  warpmetal agent install --target codex
45
46
  warpmetal agent install --target claude
46
47
  warpmetal agent install --target all
47
48
  ```
48
49
 
50
+ `warpmetal agent install` installs only the bundled WarpMetal skill; it does
51
+ not install sandbox tools or select a sandbox tool profile.
52
+
49
53
  Use `--scope project` to install into the current repository instead of the
50
54
  user-level agent directory.
51
55
 
@@ -58,7 +62,7 @@ integration.
58
62
 
59
63
  The repository also contains a skills-only WarpMetal plugin for the public
60
64
  Plugins Directory shared by Codex and ChatGPT. The plugin remains a separate
61
- artifact from the npm CLI and requires `warpmetal` CLI version 0.8.1 or newer.
65
+ artifact from the npm CLI and requires `warpmetal` CLI version 0.8.13 or newer.
62
66
 
63
67
  To test the repository marketplace after the plugin lands on `main`:
64
68
 
@@ -340,6 +344,19 @@ compatible external signer.
340
344
  and reports an owner-host-key refresh opens one new managed trust epoch;
341
345
  failed or ambiguous reloads retain the old pin and never permit replacement.
342
346
  Erased workspaces are never described as recoverable.
347
+ - New agent-enabled candidate orders and Runtime-enabled reloads pass the
348
+ closed nested-sandbox opt-in inside verified provider cloud-init. The signed
349
+ Runtime bundle installs the exact-path AppArmor policy for the neutral
350
+ image's immutable Bubblewrap helper; this needs no follow-up customer SSH
351
+ key and adds no public order field or CLI flag. VPS-only cloud-init is
352
+ unchanged. Existing enrolled hosts use reload/reprovision; the CLI does not
353
+ claim a silent in-place policy repair command.
354
+ - Runtime reports a new or replaced sandbox generation as running only after a
355
+ fixed, credential-free inner-Bubblewrap oracle proves nested namespaces, an
356
+ inherited read-only view of the outer Agent Box's already isolated procfs,
357
+ an allowed workspace write, empty Linux capability sets,
358
+ `no-new-privileges`, and protected-root write denial. Failure stops the
359
+ container before access grants or tool setup advance.
343
360
 
344
361
  For a Runtime-enabled reload, no separate installation command is part of the
345
362
  successful path:
@@ -387,6 +404,61 @@ warpmetal sandbox access keygen \
387
404
  --json
388
405
  ```
389
406
 
407
+ ### Registered sandbox tools
408
+
409
+ WarpMetal can apply only registered, immutable tool profiles. These commands use
410
+ the owner credential already stored in the private WarpMetal state file; they do
411
+ not accept a bearer token on the command line or permit a caller-supplied URL,
412
+ shell command, environment, or artifact override.
413
+
414
+ ```sh
415
+ warpmetal tools list --server <serverId> --json
416
+ warpmetal tools install --server <serverId> --sandbox <sandboxId> --profile <profileId> \
417
+ [--idempotency-key <key>] [--wait] [--timeout-seconds <n>] --json
418
+ warpmetal tools status --server <serverId> [--wait] [--timeout-seconds <n>] --json
419
+ ```
420
+
421
+ `tools install` posts only the selected registered `profileId`. Waiting is
422
+ bounded: `ready` exits 0, `failed` or `cancelled` exits 5, and a timeout exits 8.
423
+ JSON output contains public profile, operation, and receipt metadata only.
424
+ Successful non-wait installs and status inspections exit 0 while reporting the
425
+ accepted or current state; exit 8 is reserved for an actual `--wait` deadline
426
+ timeout.
427
+
428
+ Codex is a released automatic tool profile when the public
429
+ `/agent-tool-profiles` endpoint advertises it as `available`. WarpMetal installs
430
+ the exact registered artifacts when selected at order time or through
431
+ `tools install`. Claude Code uses the released `claude-code` automatic tool
432
+ profile under the same public availability gate. Claude Managed Agents are separate:
433
+ `claude-managed-ant` is an install-only CLI profile. Installing `ant` does not
434
+ authenticate a worker and does not activate Managed Agents. Cursor CLI remains
435
+ manual and unavailable as an automatic profile until separately qualified later.
436
+ Gemini CLI remains manual.
437
+
438
+ An order `--runtime-file` may add the closed optional setup intent beside its
439
+ `sandboxes` array:
440
+
441
+ ```json
442
+ {
443
+ "sandboxes": [
444
+ { "name": "codex-worker", "size": "small" },
445
+ { "name": "claude-worker", "size": "small" },
446
+ { "name": "managed-worker", "size": "small" }
447
+ ],
448
+ "setup": {
449
+ "version": 1,
450
+ "sandboxProfiles": [
451
+ { "sandboxName": "codex-worker", "profileId": "codex" },
452
+ { "sandboxName": "claude-worker", "profileId": "claude-code" },
453
+ { "sandboxName": "managed-worker", "profileId": "claude-managed-ant" }
454
+ ]
455
+ }
456
+ }
457
+ ```
458
+
459
+ Only those fields are accepted. Each setup entry must name a sandbox in the
460
+ same file and a registered profile ID.
461
+
390
462
  ### Install a standard sandbox SSH alias
391
463
 
392
464
  Use a separate keypair and access grant for each sandbox. After the grant is
@@ -410,10 +482,16 @@ ssh -t <alias> gemini
410
482
  ssh <alias> gemini -p '<task>'
411
483
  ```
412
484
 
413
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
414
- the sandbox first. Provider credentials are sandbox-owned and remain in its
415
- persistent home; WarpMetal does not install these tools, perform their login,
416
- or receive their credentials.
485
+ Authenticate Codex or Claude Code inside the sandbox after its selected profile
486
+ is ready. The public `/agent-tool-profiles` response is authoritative for
487
+ availability and checkout selection. Claude Code uses the `claude-code`
488
+ automatic tool profile. Claude Managed Agents are
489
+ separate: `claude-managed-ant` is an install-only CLI profile. Installing `ant`
490
+ does not authenticate a worker and does not activate Managed Agents. Cursor CLI
491
+ remains manual and unavailable as an automatic profile until separately
492
+ qualified later. Install and authenticate Cursor CLI or Gemini CLI inside the
493
+ sandbox. Provider credentials are sandbox-owned and remain in its
494
+ persistent home; WarpMetal does not perform provider login or receive them.
417
495
 
418
496
  The alias is local-only. It prepends a managed include to `~/.ssh/config` and
419
497
  uses the profile's pinned host key plus the sandbox identity. It never uses or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ with ad hoc HTTP commands.
11
11
 
12
12
  ## Start safely
13
13
 
14
- 1. Run `warpmetal --version` and require version `0.8.1` or newer for this
14
+ 1. Run `warpmetal --version` and require version `0.8.13` or newer for this
15
15
  plugin. If it is missing or older, explain the compatibility requirement,
16
16
  ask before installing or upgrading software, and use only the official npm
17
17
  package from `https://www.npmjs.com/package/warpmetal`.
@@ -221,6 +221,13 @@ process listings.
221
221
 
222
222
  ## Agent Runtime and sandboxes
223
223
 
224
+ Agent-enabled first boot and Runtime-enabled reload automatically carry the
225
+ closed nested-sandbox opt-in inside verified provider cloud-init. The signed
226
+ Runtime bundle installs the exact-path policy for the image's immutable
227
+ Bubblewrap helper without a later customer SSH key. This is not a public order
228
+ field or CLI flag; VPS-only cloud-init remains unchanged, and existing enrolled
229
+ hosts use reload/reprovision. There is no silent in-place policy repair.
230
+
224
231
  ```sh
225
232
  warpmetal runtime enable --server <serverId> [--idempotency-key <key>] --json
226
233
  warpmetal runtime get --server <serverId> [--wait] [--timeout-seconds <n>] --json
@@ -243,6 +250,11 @@ warpmetal sandbox action \
243
250
  [--wait] --json
244
251
  warpmetal sandbox delete \
245
252
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
253
+
254
+ warpmetal tools list --server <serverId> --json
255
+ warpmetal tools install --server <serverId> --sandbox <sandboxId> --profile <profileId> \
256
+ [--idempotency-key <key>] [--wait] [--timeout-seconds <n>] --json
257
+ warpmetal tools status --server <serverId> [--wait] [--timeout-seconds <n>] --json
246
258
  ```
247
259
 
248
260
  CLI 0.8.8 manages owner-facing VPS host trust during `runtime install`. With no
@@ -256,7 +268,52 @@ reloads never replace trust. This TOFU step cannot detect an active attacker on
256
268
  the first connection. Provider-console pre-enrollment is optional and stronger.
257
269
 
258
270
  See [runtime.md](runtime.md) for capacity, lifetime, cleanup, polling, and
259
- installation safety. Exit 8 means accepted or pending, never applied.
271
+ installation safety. Exit 8 is reserved for an actual bounded `--wait`
272
+ deadline timeout; a successful non-wait inspection exits 0 while reporting an
273
+ accepted, pending, applying, ready, or empty current state.
274
+
275
+ The `tools` commands use only the owner token already stored for the named
276
+ server. They do not accept token argv or `--token-file`. `tools list` returns
277
+ the registered immutable profiles. `tools install` sends only `{profileId}`
278
+ with an idempotency key; callers cannot supply download URLs, shell commands,
279
+ argv, environment variables, or artifacts. With `--wait`, `ready` exits 0,
280
+ `failed` or `cancelled` exits 5, and a bounded timeout exits 8. JSON contains
281
+ only the public profile/setup-operation fields returned by WarpMetal.
282
+
283
+ Codex is a released automatic tool profile when the public
284
+ `/agent-tool-profiles` endpoint advertises it as `available`. WarpMetal installs
285
+ the exact registered artifacts when selected at order time or through
286
+ `tools install`. Claude Code uses the released `claude-code` automatic tool
287
+ profile under the same public availability gate. Claude Managed Agents are separate:
288
+ `claude-managed-ant` is an install-only CLI profile. Installing `ant` does not
289
+ authenticate a worker and does not activate Managed Agents. Cursor CLI remains
290
+ manual and unavailable as an automatic profile until separately qualified later.
291
+ Gemini CLI remains manual.
292
+
293
+ For order-time setup, the runtime JSON file may contain the exact optional
294
+ shape below in addition to `sandboxes`:
295
+
296
+ ```json
297
+ {
298
+ "sandboxes": [
299
+ { "name": "codex-worker", "size": "small" },
300
+ { "name": "claude-worker", "size": "small" },
301
+ { "name": "managed-worker", "size": "small" }
302
+ ],
303
+ "setup": {
304
+ "version": 1,
305
+ "sandboxProfiles": [
306
+ { "sandboxName": "codex-worker", "profileId": "codex" },
307
+ { "sandboxName": "claude-worker", "profileId": "claude-code" },
308
+ { "sandboxName": "managed-worker", "profileId": "claude-managed-ant" }
309
+ ]
310
+ }
311
+ }
312
+ ```
313
+
314
+ Every selection must reference a sandbox name in the same file. Unknown fields,
315
+ including URL, shell, command, argv, environment, or artifact overrides, are
316
+ rejected before an API request.
260
317
 
261
318
  ## Per-agent access
262
319
 
@@ -315,9 +372,16 @@ ssh -t <alias> gemini
315
372
  ssh <alias> gemini -p '<task>'
316
373
  ```
317
374
 
318
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
319
- the sandbox first. WarpMetal does not install, authenticate, configure, or
320
- receive credentials for those tools.
375
+ Authenticate each selected provider tool inside the sandbox after setup is
376
+ ready. The public `/agent-tool-profiles` response is authoritative for
377
+ availability and checkout selection. Claude Code uses the `claude-code`
378
+ automatic tool profile. Claude Managed Agents are
379
+ separate: `claude-managed-ant` is an install-only CLI profile. Installing `ant`
380
+ does not authenticate a worker and does not activate Managed Agents. Cursor CLI
381
+ remains manual and unavailable as an automatic profile until separately
382
+ qualified later. Install and authenticate Cursor CLI or Gemini CLI inside the
383
+ sandbox. WarpMetal does not perform provider login or receive provider
384
+ credentials.
321
385
 
322
386
  [Codex Desktop](https://learn.chatgpt.com/docs/remote-connections) reads
323
387
  the concrete alias from `~/.ssh/config` and starts Codex through the sandbox
@@ -341,6 +405,9 @@ warpmetal agent install --target <codex|claude|all> [--scope user|project]
341
405
  warpmetal state list --json
342
406
  ```
343
407
 
408
+ `warpmetal agent install` installs only the bundled WarpMetal skill; it does
409
+ not install sandbox tools or select a sandbox tool profile.
410
+
344
411
  `state list` returns identifiers, public runtime metadata, and
345
412
  credential-presence booleans only. Never
346
413
  open the underlying state file from an agent session.
@@ -46,6 +46,20 @@ Pass it to `warpmetal order prepare --runtime-file <path>`. If it contains a
46
46
  temporary sandbox, pass `--confirm TEMPORARY`. Preparing remains unpaid; keep
47
47
  the existing separate interactive-or-autonomous payment authority check.
48
48
 
49
+ For released Codex and Claude Code automatic profiles, an agent-enabled first
50
+ boot passes a closed nested-sandbox opt-in through verified
51
+ provider cloud-init. The signed Runtime bundle owns the exact-path AppArmor
52
+ policy for the neutral image's immutable Bubblewrap helper. No later customer
53
+ SSH key is used. No public order field or CLI flag exists; VPS-only cloud-init
54
+ remains unchanged. Existing enrolled hosts require reload/reprovision for this policy
55
+ because no silent in-place repair command is claimed.
56
+ Runtime does not report a new or replaced sandbox generation as running until a
57
+ fixed, credential-free inner-Bubblewrap oracle proves nested namespaces, an
58
+ inherited read-only view of the outer Agent Box's already isolated procfs, an
59
+ allowed workspace write, empty Linux capability sets, `no-new-privileges`, and
60
+ protected-root write denial; failure stops the container before access grants
61
+ or tool setup advance.
62
+
49
63
  For an existing ready server:
50
64
 
51
65
  Use the WarpMetal owner account `root` for every supported host image; do not
@@ -300,10 +314,15 @@ ssh -t <alias> gemini
300
314
  ssh <alias> gemini -p '<task>'
301
315
  ```
302
316
 
303
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
304
- the sandbox first. Provider authentication and credentials are sandbox-owned
305
- and persist only in the sandbox home. WarpMetal does not install, authenticate,
306
- configure, or receive credentials for those tools.
317
+ Authenticate each selected provider tool inside the sandbox after setup is
318
+ ready. The public `/agent-tool-profiles` endpoint is authoritative for profile
319
+ availability. Claude Code uses the released `claude-code` automatic tool
320
+ profile. Claude Managed Agents are separate: `claude-managed-ant` is an
321
+ install-only CLI profile. Installing `ant` does not authenticate a worker and
322
+ does not activate Managed Agents. Cursor CLI remains manual and unavailable as
323
+ an automatic profile until separately qualified later. Provider authentication
324
+ and credentials are sandbox-owned and persist only in the sandbox home;
325
+ WarpMetal does not perform provider login or receive those credentials.
307
326
 
308
327
  [Codex Desktop](https://learn.chatgpt.com/docs/remote-connections)
309
328
  discovers a concrete alias through `~/.ssh/config`, requires ordinary
package/src/api.js CHANGED
@@ -346,6 +346,36 @@ export class WarpMetalClient {
346
346
  );
347
347
  }
348
348
 
349
+ listAgentToolProfiles(token) {
350
+ return this.request("GET", "/agent-tool-profiles", { token });
351
+ }
352
+
353
+ installAgentToolProfile(
354
+ serverId,
355
+ sandboxId,
356
+ profileId,
357
+ token,
358
+ idempotencyKey,
359
+ ) {
360
+ return this.request(
361
+ "POST",
362
+ `/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/tool-setup-operations`,
363
+ {
364
+ body: { profileId },
365
+ token,
366
+ idempotencyKey,
367
+ },
368
+ );
369
+ }
370
+
371
+ getAgentSetup(serverId, token) {
372
+ return this.request(
373
+ "GET",
374
+ `/servers/${encodeURIComponent(serverId)}/agent-setup`,
375
+ { token },
376
+ );
377
+ }
378
+
349
379
  sandboxAction(serverId, sandboxId, action, token, idempotencyKey) {
350
380
  const body =
351
381
  action === "refresh_image"
package/src/cli.js CHANGED
@@ -68,6 +68,7 @@ const SANDBOX_TERMINAL_STATES = new Set([
68
68
  "failed",
69
69
  ]);
70
70
  const GRANT_TERMINAL_STATES = new Set(["applied", "revoked", "failed"]);
71
+ const TOOL_SETUP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
71
72
  const COMMON_OPTIONS = ["base-url", "json", "state-dir", "help"];
72
73
 
73
74
  const HELP = `WarpMetal CLI ${VERSION}
@@ -128,6 +129,10 @@ Usage:
128
129
  --alias <alias> [--confirm REFRESH]
129
130
  warpmetal sandbox access remove-ssh --alias <alias> --confirm REMOVE
130
131
  warpmetal sandbox connect --connection-file <path> --identity <sandbox-key> [-- <command>]
132
+ warpmetal tools list --server <serverId>
133
+ warpmetal tools install --server <serverId> --sandbox <sandboxId> --profile <profileId>
134
+ [--idempotency-key <key>] [--wait] [--timeout-seconds <n>]
135
+ warpmetal tools status --server <serverId> [--wait] [--timeout-seconds <n>]
131
136
  warpmetal state list
132
137
  warpmetal agent install --target <codex|claude|all> [--scope <user|project>] [--force]
133
138
 
@@ -155,6 +160,26 @@ Runtime-enabled reloads:
155
160
  then refresh every sandbox connection profile before reconnecting. The
156
161
  successful operation also records a new owner SSH trust epoch; verify the
157
162
  replacement host key before owner SSH.
163
+ Agent-enabled first boot and reload also activate the signed Runtime's exact
164
+ immutable-Bubblewrap host policy through verified cloud-init. There is no
165
+ public nested-sandbox order field or runtime-install flag, and VPS-only
166
+ cloud-init is unchanged. Existing enrolled hosts use reload/reprovision;
167
+ no silent in-place policy repair is claimed.
168
+
169
+ Sandbox tool profiles:
170
+ Codex is a released automatic tool profile when the public
171
+ /agent-tool-profiles endpoint advertises it as available. WarpMetal installs
172
+ the exact registered artifacts when selected at order time or with tools
173
+ install. Claude Code uses the released claude-code automatic tool profile
174
+ under the same public availability gate. Claude Managed Agents are separate: claude-managed-ant
175
+ is an install-only CLI profile. Installing ant does not authenticate a worker
176
+ and does not activate Managed Agents. Cursor CLI remains manual and unavailable
177
+ as an automatic profile until separately qualified later. Gemini CLI remains manual. Successful
178
+ non-wait installs and status inspections exit 0; exit 8 is reserved for an
179
+ actual --wait deadline timeout.
180
+
181
+ warpmetal agent install installs only the bundled WarpMetal skill; it does not
182
+ install sandbox tools or change a sandbox tool profile.
158
183
 
159
184
  Sandbox SSH aliases:
160
185
  Refresh the authenticated connection profile before refreshing an existing
@@ -177,9 +202,16 @@ Sandbox SSH aliases:
177
202
  ssh -t <alias> gemini
178
203
  ssh <alias> gemini -p "<prompt>"
179
204
 
180
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
181
- the sandbox first. WarpMetal does not install, authenticate, configure, or
182
- receive credentials for those tools.
205
+ Authenticate Codex or Claude Code inside the sandbox after the selected
206
+ profile is ready. The public /agent-tool-profiles response is authoritative
207
+ for availability and checkout selection. The Claude Code automatic profile
208
+ ID is claude-code. Claude Managed Agents are separate:
209
+ claude-managed-ant is an install-only CLI profile. Installing ant does not
210
+ authenticate a worker and does not activate Managed Agents. Cursor CLI remains
211
+ manual and unavailable as an automatic profile until separately qualified later.
212
+ Install and authenticate Cursor CLI or Gemini CLI inside the sandbox.
213
+ WarpMetal never receives provider credentials; authentication remains inside
214
+ the sandbox.
183
215
 
184
216
  Codex Desktop discovers the concrete alias through ~/.ssh/config and opens
185
217
  the sandbox login shell, so Codex must be available on the login-shell PATH.
@@ -379,6 +411,17 @@ async function requireReloadToken(store, serverId, options, env) {
379
411
  return token;
380
412
  }
381
413
 
414
+ async function requireStoredOwnerToken(store, serverId) {
415
+ const token = (await store.server(serverId))?.ownerToken;
416
+ if (!token) {
417
+ throw new CliError(
418
+ `No stored owner credential is available for ${serverId}. Restore the private state file created for that server.`,
419
+ { exitCode: 4 },
420
+ );
421
+ }
422
+ return token;
423
+ }
424
+
382
425
  function safePreparedOrder(data, stateFile) {
383
426
  return {
384
427
  task: data.task,
@@ -441,6 +484,94 @@ async function pollRuntime(client, serverId, token, timeoutSeconds) {
441
484
  }
442
485
  }
443
486
 
487
+ function toolSetupOutcome(result) {
488
+ const operations = result.data?.setupOperations || [];
489
+ if (operations.some((operation) => ["failed", "cancelled"].includes(operation.state))) {
490
+ return 5;
491
+ }
492
+ return 0;
493
+ }
494
+
495
+ function publicToolSetupOperation(operation) {
496
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
497
+ return {};
498
+ }
499
+ return Object.fromEntries(
500
+ [
501
+ "id",
502
+ "sandboxId",
503
+ "sandboxGeneration",
504
+ "profileId",
505
+ "profileRevision",
506
+ "profileDigest",
507
+ "state",
508
+ "receiptDigest",
509
+ "errorCode",
510
+ ]
511
+ .filter((field) => Object.hasOwn(operation, field))
512
+ .map((field) => [field, operation[field]]),
513
+ );
514
+ }
515
+
516
+ function publicToolSetupData(data) {
517
+ if (Array.isArray(data?.setupOperations)) {
518
+ return {
519
+ setupOperations: data.setupOperations.map(publicToolSetupOperation),
520
+ };
521
+ }
522
+ return { setupOperation: publicToolSetupOperation(data?.setupOperation) };
523
+ }
524
+
525
+ function publicAgentToolProfiles(data) {
526
+ return {
527
+ profiles: Array.isArray(data?.profiles)
528
+ ? data.profiles.map((profile) =>
529
+ profile && typeof profile === "object" && !Array.isArray(profile)
530
+ ? Object.fromEntries(
531
+ [
532
+ "id",
533
+ "displayName",
534
+ "revision",
535
+ "digest",
536
+ "platform",
537
+ "mode",
538
+ "availability",
539
+ "checkoutSelectable",
540
+ ]
541
+ .filter((field) => Object.hasOwn(profile, field))
542
+ .map((field) => [field, profile[field]]),
543
+ )
544
+ : {},
545
+ )
546
+ : [],
547
+ };
548
+ }
549
+
550
+ async function pollToolSetup(client, serverId, token, timeoutSeconds, operationId) {
551
+ const deadline = timeoutDeadline(timeoutSeconds);
552
+ while (true) {
553
+ const result = await client.getAgentSetup(serverId, token);
554
+ const operations = result.data?.setupOperations || [];
555
+ const selected = operationId
556
+ ? operations.filter((operation) => operation.id === operationId)
557
+ : operations;
558
+ const terminal = selected.every((operation) =>
559
+ TOOL_SETUP_TERMINAL_STATES.has(operation.state),
560
+ );
561
+ if ((operationId ? selected.length > 0 : true) && terminal) {
562
+ return operationId
563
+ ? { ...result, data: { setupOperation: selected[0] } }
564
+ : result;
565
+ }
566
+ ensureBeforeDeadline(deadline, `Tool setup for ${serverId}`);
567
+ const seconds = suggestedDelay(result);
568
+ const remainingMs = deadline - Date.now();
569
+ await new Promise((resolveDelay) =>
570
+ setTimeout(resolveDelay, Math.min(seconds * 1_000, Math.max(0, remainingMs))),
571
+ );
572
+ }
573
+ }
574
+
444
575
  async function pollSandbox(
445
576
  client,
446
577
  serverId,
@@ -780,9 +911,12 @@ async function handlePrepareOrder(client, store, options, context) {
780
911
  }
781
912
  const email = stringOption(options, "email");
782
913
  const runtimeFile = stringOption(options, "runtime-file");
783
- const sandboxes = runtimeFile
914
+ const runtimeIntent = runtimeFile
784
915
  ? await readSandboxFile(runtimeFile)
785
916
  : undefined;
917
+ const sandboxes = Array.isArray(runtimeIntent)
918
+ ? runtimeIntent
919
+ : runtimeIntent?.sandboxes;
786
920
  if (sandboxes) {
787
921
  requireTemporaryConfirmation(sandboxes, stringOption(options, "confirm"));
788
922
  }
@@ -828,7 +962,11 @@ async function handlePrepareOrder(client, store, options, context) {
828
962
  sshKeyLabel: selected.identity.keyName,
829
963
  };
830
964
  if (email) request.email = email;
831
- if (sandboxes) request.agentRuntime = { sandboxes };
965
+ if (runtimeIntent) {
966
+ request.agentRuntime = Array.isArray(runtimeIntent)
967
+ ? { sandboxes: runtimeIntent }
968
+ : runtimeIntent;
969
+ }
832
970
  const key =
833
971
  stringOption(options, "idempotency-key") || idempotencyKey("order");
834
972
  const response = await client.prepareOrder(request, key);
@@ -839,7 +977,7 @@ async function handlePrepareOrder(client, store, options, context) {
839
977
  selected.identity.identityId,
840
978
  );
841
979
  const safe = safePreparedOrder(response.data, store.path);
842
- if (sandboxes) {
980
+ if (runtimeIntent) {
843
981
  safe.agentRuntimeIntent = {
844
982
  sandboxes: sandboxes.map((sandbox) => ({
845
983
  name: sandbox.name,
@@ -848,6 +986,9 @@ async function handlePrepareOrder(client, store, options, context) {
848
986
  expiresInSeconds: sandbox.expiresInSeconds,
849
987
  })),
850
988
  };
989
+ if (!Array.isArray(runtimeIntent)) {
990
+ safe.agentRuntimeIntent.setup = runtimeIntent.setup;
991
+ }
851
992
  }
852
993
  emit(
853
994
  context.stdout,
@@ -2789,6 +2930,82 @@ async function handleAccessRemoveSsh(options, context) {
2789
2930
  return 0;
2790
2931
  }
2791
2932
 
2933
+ async function handleToolsList(client, store, options, context) {
2934
+ const serverId = stringOption(options, "server", { required: true });
2935
+ const token = await requireStoredOwnerToken(store, serverId);
2936
+ const result = await client.listAgentToolProfiles(token);
2937
+ const output = publicAgentToolProfiles(result.data);
2938
+ emit(
2939
+ context.stdout,
2940
+ output,
2941
+ context.json,
2942
+ output.profiles
2943
+ .map((profile) => `${profile.id} revision ${profile.revision}`)
2944
+ .join("\n"),
2945
+ );
2946
+ return 0;
2947
+ }
2948
+
2949
+ async function handleToolsInstall(client, store, options, context) {
2950
+ const serverId = stringOption(options, "server", { required: true });
2951
+ const sandboxId = stringOption(options, "sandbox", { required: true });
2952
+ const profileId = stringOption(options, "profile", { required: true });
2953
+ const token = await requireStoredOwnerToken(store, serverId);
2954
+ const key =
2955
+ stringOption(options, "idempotency-key") || idempotencyKey("tools-install");
2956
+ let result = await client.installAgentToolProfile(
2957
+ serverId,
2958
+ sandboxId,
2959
+ profileId,
2960
+ token,
2961
+ key,
2962
+ );
2963
+ const wait = booleanOption(options, "wait");
2964
+ if (wait) {
2965
+ result = await pollToolSetup(
2966
+ client,
2967
+ serverId,
2968
+ token,
2969
+ integerOption(options, "timeout-seconds", 900),
2970
+ result.data?.setupOperation?.id,
2971
+ );
2972
+ }
2973
+ const output = publicToolSetupData(result.data);
2974
+ const operation = output.setupOperation;
2975
+ emit(
2976
+ context.stdout,
2977
+ output,
2978
+ context.json,
2979
+ `Tool setup ${operation?.id || "requested"}: ${operation?.state || "pending"}.`,
2980
+ );
2981
+ if (["failed", "cancelled"].includes(operation?.state)) return 5;
2982
+ if (!wait) return 0;
2983
+ return operation?.state === "ready" ? 0 : 5;
2984
+ }
2985
+
2986
+ async function handleToolsStatus(client, store, options, context) {
2987
+ const serverId = stringOption(options, "server", { required: true });
2988
+ const token = await requireStoredOwnerToken(store, serverId);
2989
+ const result = booleanOption(options, "wait")
2990
+ ? await pollToolSetup(
2991
+ client,
2992
+ serverId,
2993
+ token,
2994
+ integerOption(options, "timeout-seconds", 900),
2995
+ )
2996
+ : await client.getAgentSetup(serverId, token);
2997
+ const output = publicToolSetupData(result.data);
2998
+ emit(
2999
+ context.stdout,
3000
+ output,
3001
+ context.json,
3002
+ output.setupOperations
3003
+ .map((operation) => `${operation.id}: ${operation.state}`)
3004
+ .join("\n"),
3005
+ );
3006
+ return toolSetupOutcome({ ...result, data: output });
3007
+ }
3008
+
2792
3009
  async function dispatch(positionals, options, passthrough, context) {
2793
3010
  const command = positionals.join(" ");
2794
3011
  if (passthrough.length > 0 && command !== "sandbox connect") {
@@ -3242,6 +3459,28 @@ async function dispatch(positionals, options, passthrough, context) {
3242
3459
  case "sandbox connect":
3243
3460
  rejectUnknownOptions(options, ["connection-file", "identity", "help"]);
3244
3461
  return handleSandboxConnect(options, passthrough, context);
3462
+ case "tools list":
3463
+ rejectUnknownOptions(options, [...COMMON_OPTIONS, "server"]);
3464
+ return handleToolsList(client, store, options, context);
3465
+ case "tools install":
3466
+ rejectUnknownOptions(options, [
3467
+ ...COMMON_OPTIONS,
3468
+ "server",
3469
+ "sandbox",
3470
+ "profile",
3471
+ "idempotency-key",
3472
+ "wait",
3473
+ "timeout-seconds",
3474
+ ]);
3475
+ return handleToolsInstall(client, store, options, context);
3476
+ case "tools status":
3477
+ rejectUnknownOptions(options, [
3478
+ ...COMMON_OPTIONS,
3479
+ "server",
3480
+ "wait",
3481
+ "timeout-seconds",
3482
+ ]);
3483
+ return handleToolsStatus(client, store, options, context);
3245
3484
  case "state list": {
3246
3485
  rejectUnknownOptions(options, COMMON_OPTIONS);
3247
3486
  const summary = await store.summary();
package/src/runtime.js CHANGED
@@ -15,6 +15,9 @@ const SANDBOX_FIELDS = new Set([
15
15
  "lifetime",
16
16
  "expiresInSeconds",
17
17
  ]);
18
+ const RUNTIME_FIELDS = new Set(["sandboxes", "setup"]);
19
+ const SETUP_FIELDS = new Set(["version", "sandboxProfiles"]);
20
+ const SANDBOX_PROFILE_FIELDS = new Set(["sandboxName", "profileId"]);
18
21
 
19
22
  function exactFields(value, allowed, label) {
20
23
  const unknown = Object.keys(value).find((field) => !allowed.has(field));
@@ -113,8 +116,62 @@ export async function readSandboxFile(path) {
113
116
  exitCode: 2,
114
117
  });
115
118
  }
116
- exactFields(document, new Set(["sandboxes"]), "The sandbox JSON file");
117
- return validateSandboxBatch(document.sandboxes);
119
+ exactFields(document, RUNTIME_FIELDS, "The sandbox JSON file");
120
+ const sandboxes = validateSandboxBatch(document.sandboxes);
121
+ if (document.setup === undefined) return sandboxes;
122
+ const setup = document.setup;
123
+ if (!setup || typeof setup !== "object" || Array.isArray(setup)) {
124
+ throw new CliError("setup must be a JSON object.", { exitCode: 2 });
125
+ }
126
+ exactFields(setup, SETUP_FIELDS, "setup");
127
+ if (setup.version !== 1) {
128
+ throw new CliError("setup.version must be 1.", { exitCode: 2 });
129
+ }
130
+ if (
131
+ !Array.isArray(setup.sandboxProfiles) ||
132
+ setup.sandboxProfiles.length < 1 ||
133
+ setup.sandboxProfiles.length > 32
134
+ ) {
135
+ throw new CliError(
136
+ "setup.sandboxProfiles must contain between 1 and 32 entries.",
137
+ { exitCode: 2 },
138
+ );
139
+ }
140
+ const sandboxNames = new Set(sandboxes.map((sandbox) => sandbox.name));
141
+ const selectedNames = new Set();
142
+ const sandboxProfiles = setup.sandboxProfiles.map((input) => {
143
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
144
+ throw new CliError("Every setup profile selection must be a JSON object.", {
145
+ exitCode: 2,
146
+ });
147
+ }
148
+ exactFields(input, SANDBOX_PROFILE_FIELDS, "A setup profile selection");
149
+ if (typeof input.sandboxName !== "string" || !NAME.test(input.sandboxName)) {
150
+ throw new CliError("setup sandbox names must use lowercase identifiers.", {
151
+ exitCode: 2,
152
+ });
153
+ }
154
+ if (typeof input.profileId !== "string" || !NAME.test(input.profileId)) {
155
+ throw new CliError("setup profile IDs must use lowercase identifiers.", {
156
+ exitCode: 2,
157
+ });
158
+ }
159
+ if (!sandboxNames.has(input.sandboxName)) {
160
+ throw new CliError(
161
+ `setup references an unknown sandbox: ${input.sandboxName}`,
162
+ { exitCode: 2 },
163
+ );
164
+ }
165
+ if (selectedNames.has(input.sandboxName)) {
166
+ throw new CliError(
167
+ `setup selects the sandbox more than once: ${input.sandboxName}`,
168
+ { exitCode: 2 },
169
+ );
170
+ }
171
+ selectedNames.add(input.sandboxName);
172
+ return { sandboxName: input.sandboxName, profileId: input.profileId };
173
+ });
174
+ return { sandboxes, setup: { version: 1, sandboxProfiles } };
118
175
  }
119
176
 
120
177
  export function containsTemporary(sandboxes) {