warpmetal 0.8.11 → 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
 
@@ -317,9 +321,10 @@ compatible external signer.
317
321
  Wallet key management and signing remain outside this package.
318
322
  - Destructive or state-changing commands require explicit confirmations and
319
323
  generate idempotency keys by default.
320
- - Runtime bootstrap credentials remain memory-only and are requested only after
321
- the first host key has been pinned and strictly reverified. Signed supervisor
322
- bundles are checksum- and signature-verified before OpenSSH uploads them.
324
+ - Runtime bootstrap credentials remain memory-only. Manual installation asks
325
+ for one only after the first host key has been pinned and strictly reverified;
326
+ automatic reload bootstrap is rendered directly into provider-bound
327
+ cloud-init. Signed supervisor bundles are checksum- and signature-verified.
323
328
  - Each agent gets a distinct SSH key forced into exactly one sandbox. Token-free
324
329
  connection profiles pin the VPS host key and contain no owner credential or
325
330
  private-key material.
@@ -332,11 +337,43 @@ compatible external signer.
332
337
  external workspace, lifetime, and start time. The wait completes only when
333
338
  the observed digest and generation both match the accepted target.
334
339
  - Guarded reload powers the server off first. Runtime-enabled reload requires a
335
- second acknowledgment, after which the CLI guides supervisor reinstall and
336
- pinned connection-profile refresh. Only a locally recorded reload operation
337
- that succeeds and reports an owner-host-key refresh opens one new managed
338
- trust epoch; failed or ambiguous reloads retain the old pin and never permit
339
- replacement. Erased workspaces are never described as recoverable.
340
+ second acknowledgment because all sandbox workspaces are erased. WarpMetal
341
+ places the approved signed Runtime bootstrap in reload cloud-init
342
+ automatically; wait for Runtime readiness, then refresh pinned sandbox
343
+ connection profiles. Only a locally recorded reload operation that succeeds
344
+ and reports an owner-host-key refresh opens one new managed trust epoch;
345
+ failed or ambiguous reloads retain the old pin and never permit replacement.
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.
360
+
361
+ For a Runtime-enabled reload, no separate installation command is part of the
362
+ successful path:
363
+
364
+ ```sh
365
+ warpmetal server reload \
366
+ --server <serverId> --confirm ERASE --power-off-first \
367
+ --acknowledge-agent-runtime-reset --wait --json
368
+ warpmetal runtime get --server <serverId> --wait --json
369
+ warpmetal sandbox access refresh \
370
+ --server <serverId> --sandbox <sandboxId> --grant <grantId> \
371
+ --connection-file <profile-path> --confirm REFRESH --wait --json
372
+ ```
373
+
374
+ The successful reload records a new operation-bound owner SSH trust epoch.
375
+ Verify the replacement host key before using owner SSH; sandbox access remains
376
+ strictly pinned through each refreshed connection profile.
340
377
 
341
378
  ## Agent Runtime example
342
379
 
@@ -367,6 +404,61 @@ warpmetal sandbox access keygen \
367
404
  --json
368
405
  ```
369
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
+
370
462
  ### Install a standard sandbox SSH alias
371
463
 
372
464
  Use a separate keypair and access grant for each sandbox. After the grant is
@@ -390,10 +482,16 @@ ssh -t <alias> gemini
390
482
  ssh <alias> gemini -p '<task>'
391
483
  ```
392
484
 
393
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
394
- the sandbox first. Provider credentials are sandbox-owned and remain in its
395
- persistent home; WarpMetal does not install these tools, perform their login,
396
- 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.
397
495
 
398
496
  The alias is local-only. It prepends a managed include to `~/.ssh/config` and
399
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.11",
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`.
@@ -254,8 +254,8 @@ warpmetal server power \
254
254
  For a destructive reload, explain that every server-disk file is erased and
255
255
  obtain explicit approval. If Agent Runtime is enabled, also explain that every
256
256
  sandbox workspace is lost, desired sandboxes return as empty workspaces after
257
- reinstall, and pinned profiles must be refreshed. Then use only the guarded
258
- command:
257
+ automatic Runtime setup, and pinned profiles must be refreshed. Then use only
258
+ the guarded command:
259
259
 
260
260
  ```sh
261
261
  warpmetal server reload \
@@ -271,14 +271,18 @@ warpmetal server reload \
271
271
  The CLI requires the recovery owner credential so it can keep polling after
272
272
  reload revokes SSH-derived access tokens. CLI 0.8.8 records a new SSH trust
273
273
  epoch only when that exact local reload operation succeeds and reports that the
274
- owner host key needs refresh. The next Runtime install may trust the first
275
- observed Ed25519 host key once in that epoch, then must reconnect strictly
276
- before requesting bootstrap. Failed or ambiguous reloads retain the prior pin;
274
+ owner host key needs refresh. Failed or ambiguous reloads retain the prior pin;
277
275
  never bypass a mismatch or delete a pin to force a retry. A provider-console
278
- pre-seed is the optional higher-assurance path. Then wait for grants to become applied and refresh
279
- every connection profile with `sandbox access refresh --confirm REFRESH`
280
- before connecting. Do not fall back to raw API calls for deletion, networking,
281
- or another unsupported mutation.
276
+ pre-seed is the optional higher-assurance path.
277
+
278
+ After a successful Runtime-enabled reload, WarpMetal performs signed Runtime
279
+ setup automatically. Do not run a separate installation command. Wait with
280
+ `warpmetal runtime get --server <serverId> --wait --json`, then wait for grants
281
+ to become `applied` and refresh every connection profile with
282
+ `sandbox access refresh --confirm REFRESH` before connecting. The successful
283
+ operation created a new owner host-trust epoch, so verify the replacement host
284
+ key before owner SSH. Do not fall back to raw API calls for deletion,
285
+ networking, or another unsupported mutation.
282
286
 
283
287
  ## Use Agent Runtime
284
288
 
@@ -286,12 +290,13 @@ Agent Runtime is optional and shares one owner's VPS only among that owner's
286
290
  agents. Discover live `agentRuntime` capacity and OS support before choosing
287
291
  sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
288
292
  `warpmetal runtime enable` after the VPS is ready. Supervisor installation is
289
- separate and requires approval plus `--confirm INSTALL`.
293
+ separate and requires approval plus `--confirm INSTALL` for initial setup or
294
+ explicit repair; Runtime-enabled OS reloads use automatic signed cloud-init.
290
295
 
291
296
  With CLI 0.8.8, that confirmation also authorizes managed trust on first use
292
297
  when the exact server trust epoch has no pin. The CLI performs only an owner-
293
298
  key-authenticated `ssh true`, pins the first observed Ed25519 key, immediately
294
- reconnects strictly, and requests bootstrap only afterward. Later connections
299
+ reconnects strictly before requesting bootstrap. Later connections
295
300
  must match. Explain that TOFU cannot detect an active attacker on the first
296
301
  connection; never use `ssh-keyscan`, accept a mismatch, or expose a generic pin
297
302
  reset.
@@ -201,7 +201,18 @@ Reload requires the recovery owner credential rather than a short-lived
201
201
  SSH-derived token. `--power-off-first` authorizes shutdown and powered-off
202
202
  verification inside the same operation. When Agent Runtime is enabled,
203
203
  `--acknowledge-agent-runtime-reset` is required because workspaces are erased,
204
- the supervisor must be reinstalled, and connection profiles must be refreshed.
204
+ the Runtime identity is replaced, empty sandboxes are reconciled automatically,
205
+ and connection profiles must be refreshed. After a successful reload, wait for
206
+ automatic setup before refreshing profiles:
207
+
208
+ ```sh
209
+ warpmetal runtime get --server <serverId> --wait --json
210
+ ```
211
+
212
+ The successful operation records a new owner SSH trust epoch. Verify the
213
+ replacement host key before owner SSH. Manual Runtime installation remains an
214
+ explicit repair path when automatic setup fails; it is not part of a
215
+ successful reload.
205
216
 
206
217
  Use `--token-file` only for recovery when local state is unavailable. Prefer
207
218
  `WARPMETAL_OWNER_TOKEN` or `WARPMETAL_ACCESS_TOKEN` for a single command over a
@@ -210,6 +221,13 @@ process listings.
210
221
 
211
222
  ## Agent Runtime and sandboxes
212
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
+
213
231
  ```sh
214
232
  warpmetal runtime enable --server <serverId> [--idempotency-key <key>] --json
215
233
  warpmetal runtime get --server <serverId> [--wait] [--timeout-seconds <n>] --json
@@ -232,6 +250,11 @@ warpmetal sandbox action \
232
250
  [--wait] --json
233
251
  warpmetal sandbox delete \
234
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
235
258
  ```
236
259
 
237
260
  CLI 0.8.8 manages owner-facing VPS host trust during `runtime install`. With no
@@ -245,7 +268,52 @@ reloads never replace trust. This TOFU step cannot detect an active attacker on
245
268
  the first connection. Provider-console pre-enrollment is optional and stronger.
246
269
 
247
270
  See [runtime.md](runtime.md) for capacity, lifetime, cleanup, polling, and
248
- 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.
249
317
 
250
318
  ## Per-agent access
251
319
 
@@ -277,7 +345,7 @@ returns the OpenSSH or remote exit status. `--connection-file` on grant
277
345
  creation requires `--wait`.
278
346
  `sandbox access refresh` atomically replaces a stale token-free profile with
279
347
  the currently applied grant and API-reported pinned host keys; use it after an
280
- OS reload and supervisor reinstall.
348
+ OS reload once automatic Runtime reconciliation is ready.
281
349
 
282
350
  `sandbox access install-ssh` is local-only and requires CLI 0.8.10 or newer.
283
351
  It turns the reviewed profile and sandbox-private identity into a concrete
@@ -304,9 +372,16 @@ ssh -t <alias> gemini
304
372
  ssh <alias> gemini -p '<task>'
305
373
  ```
306
374
 
307
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
308
- the sandbox first. WarpMetal does not install, authenticate, configure, or
309
- 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.
310
385
 
311
386
  [Codex Desktop](https://learn.chatgpt.com/docs/remote-connections) reads
312
387
  the concrete alias from `~/.ssh/config` and starts Codex through the sandbox
@@ -330,6 +405,9 @@ warpmetal agent install --target <codex|claude|all> [--scope user|project]
330
405
  warpmetal state list --json
331
406
  ```
332
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
+
333
411
  `state list` returns identifiers, public runtime metadata, and
334
412
  credential-presence booleans only. Never
335
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
@@ -208,11 +222,12 @@ The token-free profile is written only after the grant is `applied` and the
208
222
  API supplies verified VPS host keys. Do not print or open that profile in an
209
223
  agent conversation.
210
224
 
211
- After a destructive OS reload, reinstall the supervisor and wait for the
212
- retained active grant to become `applied`, then replace its stale pinned
213
- profile:
225
+ After a successful destructive OS reload, the signed Runtime bootstrap runs
226
+ automatically. Wait for Runtime readiness and for the retained active grant to
227
+ become `applied`, then replace its stale pinned profile:
214
228
 
215
229
  ```sh
230
+ warpmetal runtime get --server <serverId> --wait --json
216
231
  warpmetal sandbox access refresh \
217
232
  --server <serverId> \
218
233
  --sandbox <sandboxId> \
@@ -225,7 +240,8 @@ warpmetal sandbox access refresh \
225
240
 
226
241
  The sandbox record is retained, but its old workspace is not; reconciliation
227
242
  creates a new empty workspace. Never bypass a host-key mismatch or reuse the
228
- pre-reload profile.
243
+ pre-reload profile. Use manual Runtime installation only as an explicitly
244
+ approved repair when the backend reports that automatic setup failed.
229
245
 
230
246
  ### Install a concrete OpenSSH alias
231
247
 
@@ -298,10 +314,15 @@ ssh -t <alias> gemini
298
314
  ssh <alias> gemini -p '<task>'
299
315
  ```
300
316
 
301
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
302
- the sandbox first. Provider authentication and credentials are sandbox-owned
303
- and persist only in the sandbox home. WarpMetal does not install, authenticate,
304
- 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.
305
326
 
306
327
  [Codex Desktop](https://learn.chatgpt.com/docs/remote-connections)
307
328
  discovers a concrete alias through `~/.ssh/config`, requires ordinary
@@ -95,9 +95,10 @@ A reload requires both `confirm: "ERASE"` and `powerOffFirst: true`. Treat
95
95
  server, wait until it is powered off, and then erase and reinstall it inside
96
96
  one lifecycle operation. When Agent Runtime is enabled, also require
97
97
  `acknowledgeAgentRuntimeReset`: all sandbox workspaces are permanently erased,
98
- the supervisor identity is revoked, desired sandboxes are recreated empty
99
- after reinstall, and all pinned profiles require refresh. Use only the guarded
100
- CLI command and stop on `manual_review`. Reload invalidates the prior
98
+ the supervisor identity is revoked, signed Runtime setup runs automatically,
99
+ desired sandboxes are recreated empty, and all pinned profiles require refresh.
100
+ Use only the guarded CLI command, wait for Runtime readiness, and stop on
101
+ `manual_review`. Reload invalidates the prior
101
102
  owner-facing SSH host-key trust decision; the provider may rotate or preserve
102
103
  the key. CLI 0.8.8 opens one new managed trust epoch only after the exact local
103
104
  reload operation succeeds and reports that refresh is required. The first
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
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { basename, join, resolve } from "node:path";
4
5
 
5
6
  import {
@@ -67,6 +68,7 @@ const SANDBOX_TERMINAL_STATES = new Set([
67
68
  "failed",
68
69
  ]);
69
70
  const GRANT_TERMINAL_STATES = new Set(["applied", "revoked", "failed"]);
71
+ const TOOL_SETUP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
70
72
  const COMMON_OPTIONS = ["base-url", "json", "state-dir", "help"];
71
73
 
72
74
  const HELP = `WarpMetal CLI ${VERSION}
@@ -127,6 +129,10 @@ Usage:
127
129
  --alias <alias> [--confirm REFRESH]
128
130
  warpmetal sandbox access remove-ssh --alias <alias> --confirm REMOVE
129
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>]
130
136
  warpmetal state list
131
137
  warpmetal agent install --target <codex|claude|all> [--scope <user|project>] [--force]
132
138
 
@@ -147,6 +153,34 @@ SSH host trust (CLI 0.8.8+):
147
153
  active attacker on the first connection. Provider-console pre-enrollment is
148
154
  the optional higher-assurance alternative.
149
155
 
156
+ Runtime-enabled reloads:
157
+ Reload still requires --acknowledge-agent-runtime-reset because every
158
+ sandbox workspace is erased. After a successful reload, Runtime setup is
159
+ automatic: use warpmetal runtime get --server <serverId> --wait --json,
160
+ then refresh every sandbox connection profile before reconnecting. The
161
+ successful operation also records a new owner SSH trust epoch; verify the
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.
183
+
150
184
  Sandbox SSH aliases:
151
185
  Refresh the authenticated connection profile before refreshing an existing
152
186
  alias:
@@ -168,9 +202,16 @@ Sandbox SSH aliases:
168
202
  ssh -t <alias> gemini
169
203
  ssh <alias> gemini -p "<prompt>"
170
204
 
171
- Install and authenticate Codex, Claude Code, Cursor CLI, or Gemini CLI inside
172
- the sandbox first. WarpMetal does not install, authenticate, configure, or
173
- 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.
174
215
 
175
216
  Codex Desktop discovers the concrete alias through ~/.ssh/config and opens
176
217
  the sandbox login shell, so Codex must be available on the login-shell PATH.
@@ -370,6 +411,17 @@ async function requireReloadToken(store, serverId, options, env) {
370
411
  return token;
371
412
  }
372
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
+
373
425
  function safePreparedOrder(data, stateFile) {
374
426
  return {
375
427
  task: data.task,
@@ -432,6 +484,94 @@ async function pollRuntime(client, serverId, token, timeoutSeconds) {
432
484
  }
433
485
  }
434
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
+
435
575
  async function pollSandbox(
436
576
  client,
437
577
  serverId,
@@ -771,9 +911,12 @@ async function handlePrepareOrder(client, store, options, context) {
771
911
  }
772
912
  const email = stringOption(options, "email");
773
913
  const runtimeFile = stringOption(options, "runtime-file");
774
- const sandboxes = runtimeFile
914
+ const runtimeIntent = runtimeFile
775
915
  ? await readSandboxFile(runtimeFile)
776
916
  : undefined;
917
+ const sandboxes = Array.isArray(runtimeIntent)
918
+ ? runtimeIntent
919
+ : runtimeIntent?.sandboxes;
777
920
  if (sandboxes) {
778
921
  requireTemporaryConfirmation(sandboxes, stringOption(options, "confirm"));
779
922
  }
@@ -819,7 +962,11 @@ async function handlePrepareOrder(client, store, options, context) {
819
962
  sshKeyLabel: selected.identity.keyName,
820
963
  };
821
964
  if (email) request.email = email;
822
- if (sandboxes) request.agentRuntime = { sandboxes };
965
+ if (runtimeIntent) {
966
+ request.agentRuntime = Array.isArray(runtimeIntent)
967
+ ? { sandboxes: runtimeIntent }
968
+ : runtimeIntent;
969
+ }
823
970
  const key =
824
971
  stringOption(options, "idempotency-key") || idempotencyKey("order");
825
972
  const response = await client.prepareOrder(request, key);
@@ -830,7 +977,7 @@ async function handlePrepareOrder(client, store, options, context) {
830
977
  selected.identity.identityId,
831
978
  );
832
979
  const safe = safePreparedOrder(response.data, store.path);
833
- if (sandboxes) {
980
+ if (runtimeIntent) {
834
981
  safe.agentRuntimeIntent = {
835
982
  sandboxes: sandboxes.map((sandbox) => ({
836
983
  name: sandbox.name,
@@ -839,6 +986,9 @@ async function handlePrepareOrder(client, store, options, context) {
839
986
  expiresInSeconds: sandbox.expiresInSeconds,
840
987
  })),
841
988
  };
989
+ if (!Array.isArray(runtimeIntent)) {
990
+ safe.agentRuntimeIntent.setup = runtimeIntent.setup;
991
+ }
842
992
  }
843
993
  emit(
844
994
  context.stdout,
@@ -1995,7 +2145,7 @@ async function applyReloadResult(
1995
2145
  await store.invalidateServerAccess(serverId);
1996
2146
  if (operation.result?.reloadImpact?.agentRuntimeAffected) {
1997
2147
  await store.saveRuntime(serverId, {
1998
- state: "needs_reinstall",
2148
+ state: "pending_install",
1999
2149
  desiredRevision: undefined,
2000
2150
  appliedRevision: 0,
2001
2151
  lastSeenAt: null,
@@ -2103,8 +2253,8 @@ async function handleServerReload(client, store, options, context) {
2103
2253
  (operation?.state === "succeeded" &&
2104
2254
  operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh
2105
2255
  ? operation.result?.reloadImpact?.agentRuntimeAffected
2106
- ? " The next Runtime install will establish the operation-bound owner SSH trust epoch, then reinstall Agent Runtime and refresh every sandbox connection profile."
2107
- : " The next Runtime install will establish the operation-bound owner SSH trust epoch before reconnecting."
2256
+ ? " Agent Runtime setup is automatic. Wait for Agent Runtime to become ready, then refresh every sandbox connection profile. Verify the replacement host key before owner SSH."
2257
+ : " Verify the replacement host key before owner SSH; the successful reload recorded a new operation-bound trust epoch."
2108
2258
  : operation?.state === "succeeded"
2109
2259
  ? " WarpMetal did not report a host-key refresh; the existing managed pin remains required."
2110
2260
  : ""),
@@ -2753,7 +2903,7 @@ async function handleAccessInstallSsh(options, context) {
2753
2903
  alias: stringOption(options, "alias", { required: true }),
2754
2904
  connectionFile: stringOption(options, "connection-file", { required: true }),
2755
2905
  identity: stringOption(options, "identity", { required: true }),
2756
- homeDirectory: context.env.HOME,
2906
+ homeDirectory: context.env.HOME || context.env.USERPROFILE || homedir(),
2757
2907
  confirm: stringOption(options, "confirm"),
2758
2908
  });
2759
2909
  emit(
@@ -2768,7 +2918,7 @@ async function handleAccessInstallSsh(options, context) {
2768
2918
  async function handleAccessRemoveSsh(options, context) {
2769
2919
  const result = await removeSshAlias({
2770
2920
  alias: stringOption(options, "alias", { required: true }),
2771
- homeDirectory: context.env.HOME,
2921
+ homeDirectory: context.env.HOME || context.env.USERPROFILE || homedir(),
2772
2922
  confirm: stringOption(options, "confirm", { required: true }),
2773
2923
  });
2774
2924
  emit(
@@ -2780,6 +2930,82 @@ async function handleAccessRemoveSsh(options, context) {
2780
2930
  return 0;
2781
2931
  }
2782
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
+
2783
3009
  async function dispatch(positionals, options, passthrough, context) {
2784
3010
  const command = positionals.join(" ");
2785
3011
  if (passthrough.length > 0 && command !== "sandbox connect") {
@@ -3233,6 +3459,28 @@ async function dispatch(positionals, options, passthrough, context) {
3233
3459
  case "sandbox connect":
3234
3460
  rejectUnknownOptions(options, ["connection-file", "identity", "help"]);
3235
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);
3236
3484
  case "state list": {
3237
3485
  rejectUnknownOptions(options, COMMON_OPTIONS);
3238
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) {
package/src/ssh-alias.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  rmdir,
11
11
  unlink,
12
12
  } from "node:fs/promises";
13
- import { isAbsolute, join, parse, resolve } from "node:path";
13
+ import { isAbsolute, join, parse, posix, resolve, win32 } from "node:path";
14
14
  import { randomUUID } from "node:crypto";
15
15
 
16
16
  import {
@@ -21,7 +21,8 @@ import {
21
21
  import { CliError } from "./errors.js";
22
22
 
23
23
  const ALIAS_PATTERN = /^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
24
- const UNSAFE_PATH = /[\0\r\n\t%"'`$!&;<>|*?()[\]{}\\#]/;
24
+ const POSIX_UNSAFE_PATH = /[\0\r\n\t%"'`$!&;<>|*?()[\]{}\\#]/;
25
+ const WINDOWS_UNSAFE_PATH = /[\0\r\n\t%"'`$!&;<>|*?()[\]{}#]/;
25
26
  const MANAGED_HEADER = "# Managed by WarpMetal. Do not edit.";
26
27
 
27
28
  function invalid(message) {
@@ -37,17 +38,18 @@ export function validateSshAlias(value) {
37
38
  return value;
38
39
  }
39
40
 
40
- function validateAbsolutePath(value, label) {
41
- if (
42
- typeof value !== "string" ||
43
- !isAbsolute(value) ||
44
- resolve(value) !== value ||
45
- value === parse(value).root ||
46
- UNSAFE_PATH.test(value)
47
- ) {
41
+ export function validateAbsolutePath(value, label, platform = process.platform) {
42
+ const path = platform === "win32" ? win32 : posix;
43
+ if (typeof value !== "string" || !path.isAbsolute(value)) {
48
44
  invalid(`${label} must be an absolute path without unsafe interpolation characters.`);
49
45
  }
50
- return value;
46
+ const resolved = path.resolve(value);
47
+ const unsafePath =
48
+ platform === "win32" ? WINDOWS_UNSAFE_PATH : POSIX_UNSAFE_PATH;
49
+ if (resolved === path.parse(resolved).root || unsafePath.test(resolved)) {
50
+ invalid(`${label} must be an absolute path without unsafe interpolation characters.`);
51
+ }
52
+ return resolved;
51
53
  }
52
54
 
53
55
  function requireOwner(metadata, label) {
@@ -420,12 +422,19 @@ async function syncDirectory(path) {
420
422
  handle = await open(path, "r");
421
423
  await handle.sync();
422
424
  } catch (error) {
423
- if (!["EINVAL", "ENOTSUP", "EISDIR"].includes(error?.code)) throw error;
425
+ if (!isIgnorableDirectorySyncError(error)) throw error;
424
426
  } finally {
425
427
  await handle?.close().catch(() => {});
426
428
  }
427
429
  }
428
430
 
431
+ export function isIgnorableDirectorySyncError(error, platform = process.platform) {
432
+ return (
433
+ ["EINVAL", "ENOTSUP", "EISDIR"].includes(error?.code) ||
434
+ (platform === "win32" && ["EPERM", "EACCES"].includes(error?.code))
435
+ );
436
+ }
437
+
429
438
  async function commitStaged(staged) {
430
439
  try {
431
440
  for (const item of staged) {