warpmetal 0.8.11 → 0.8.12

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
@@ -317,9 +317,10 @@ compatible external signer.
317
317
  Wallet key management and signing remain outside this package.
318
318
  - Destructive or state-changing commands require explicit confirmations and
319
319
  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.
320
+ - Runtime bootstrap credentials remain memory-only. Manual installation asks
321
+ for one only after the first host key has been pinned and strictly reverified;
322
+ automatic reload bootstrap is rendered directly into provider-bound
323
+ cloud-init. Signed supervisor bundles are checksum- and signature-verified.
323
324
  - Each agent gets a distinct SSH key forced into exactly one sandbox. Token-free
324
325
  connection profiles pin the VPS host key and contain no owner credential or
325
326
  private-key material.
@@ -332,11 +333,30 @@ compatible external signer.
332
333
  external workspace, lifetime, and start time. The wait completes only when
333
334
  the observed digest and generation both match the accepted target.
334
335
  - 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.
336
+ second acknowledgment because all sandbox workspaces are erased. WarpMetal
337
+ places the approved signed Runtime bootstrap in reload cloud-init
338
+ automatically; wait for Runtime readiness, then refresh pinned sandbox
339
+ connection profiles. Only a locally recorded reload operation that succeeds
340
+ and reports an owner-host-key refresh opens one new managed trust epoch;
341
+ failed or ambiguous reloads retain the old pin and never permit replacement.
342
+ Erased workspaces are never described as recoverable.
343
+
344
+ For a Runtime-enabled reload, no separate installation command is part of the
345
+ successful path:
346
+
347
+ ```sh
348
+ warpmetal server reload \
349
+ --server <serverId> --confirm ERASE --power-off-first \
350
+ --acknowledge-agent-runtime-reset --wait --json
351
+ warpmetal runtime get --server <serverId> --wait --json
352
+ warpmetal sandbox access refresh \
353
+ --server <serverId> --sandbox <sandboxId> --grant <grantId> \
354
+ --connection-file <profile-path> --confirm REFRESH --wait --json
355
+ ```
356
+
357
+ The successful reload records a new operation-bound owner SSH trust epoch.
358
+ Verify the replacement host key before using owner SSH; sandbox access remains
359
+ strictly pinned through each refreshed connection profile.
340
360
 
341
361
  ## Agent Runtime example
342
362
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -277,7 +288,7 @@ returns the OpenSSH or remote exit status. `--connection-file` on grant
277
288
  creation requires `--wait`.
278
289
  `sandbox access refresh` atomically replaces a stale token-free profile with
279
290
  the currently applied grant and API-reported pinned host keys; use it after an
280
- OS reload and supervisor reinstall.
291
+ OS reload once automatic Runtime reconciliation is ready.
281
292
 
282
293
  `sandbox access install-ssh` is local-only and requires CLI 0.8.10 or newer.
283
294
  It turns the reviewed profile and sandbox-private identity into a concrete
@@ -208,11 +208,12 @@ The token-free profile is written only after the grant is `applied` and the
208
208
  API supplies verified VPS host keys. Do not print or open that profile in an
209
209
  agent conversation.
210
210
 
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:
211
+ After a successful destructive OS reload, the signed Runtime bootstrap runs
212
+ automatically. Wait for Runtime readiness and for the retained active grant to
213
+ become `applied`, then replace its stale pinned profile:
214
214
 
215
215
  ```sh
216
+ warpmetal runtime get --server <serverId> --wait --json
216
217
  warpmetal sandbox access refresh \
217
218
  --server <serverId> \
218
219
  --sandbox <sandboxId> \
@@ -225,7 +226,8 @@ warpmetal sandbox access refresh \
225
226
 
226
227
  The sandbox record is retained, but its old workspace is not; reconciliation
227
228
  creates a new empty workspace. Never bypass a host-key mismatch or reuse the
228
- pre-reload profile.
229
+ pre-reload profile. Use manual Runtime installation only as an explicitly
230
+ approved repair when the backend reports that automatic setup failed.
229
231
 
230
232
  ### Install a concrete OpenSSH alias
231
233
 
@@ -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/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 {
@@ -147,6 +148,14 @@ SSH host trust (CLI 0.8.8+):
147
148
  active attacker on the first connection. Provider-console pre-enrollment is
148
149
  the optional higher-assurance alternative.
149
150
 
151
+ Runtime-enabled reloads:
152
+ Reload still requires --acknowledge-agent-runtime-reset because every
153
+ sandbox workspace is erased. After a successful reload, Runtime setup is
154
+ automatic: use warpmetal runtime get --server <serverId> --wait --json,
155
+ then refresh every sandbox connection profile before reconnecting. The
156
+ successful operation also records a new owner SSH trust epoch; verify the
157
+ replacement host key before owner SSH.
158
+
150
159
  Sandbox SSH aliases:
151
160
  Refresh the authenticated connection profile before refreshing an existing
152
161
  alias:
@@ -1995,7 +2004,7 @@ async function applyReloadResult(
1995
2004
  await store.invalidateServerAccess(serverId);
1996
2005
  if (operation.result?.reloadImpact?.agentRuntimeAffected) {
1997
2006
  await store.saveRuntime(serverId, {
1998
- state: "needs_reinstall",
2007
+ state: "pending_install",
1999
2008
  desiredRevision: undefined,
2000
2009
  appliedRevision: 0,
2001
2010
  lastSeenAt: null,
@@ -2103,8 +2112,8 @@ async function handleServerReload(client, store, options, context) {
2103
2112
  (operation?.state === "succeeded" &&
2104
2113
  operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh
2105
2114
  ? 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."
2115
+ ? " 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."
2116
+ : " Verify the replacement host key before owner SSH; the successful reload recorded a new operation-bound trust epoch."
2108
2117
  : operation?.state === "succeeded"
2109
2118
  ? " WarpMetal did not report a host-key refresh; the existing managed pin remains required."
2110
2119
  : ""),
@@ -2753,7 +2762,7 @@ async function handleAccessInstallSsh(options, context) {
2753
2762
  alias: stringOption(options, "alias", { required: true }),
2754
2763
  connectionFile: stringOption(options, "connection-file", { required: true }),
2755
2764
  identity: stringOption(options, "identity", { required: true }),
2756
- homeDirectory: context.env.HOME,
2765
+ homeDirectory: context.env.HOME || context.env.USERPROFILE || homedir(),
2757
2766
  confirm: stringOption(options, "confirm"),
2758
2767
  });
2759
2768
  emit(
@@ -2768,7 +2777,7 @@ async function handleAccessInstallSsh(options, context) {
2768
2777
  async function handleAccessRemoveSsh(options, context) {
2769
2778
  const result = await removeSshAlias({
2770
2779
  alias: stringOption(options, "alias", { required: true }),
2771
- homeDirectory: context.env.HOME,
2780
+ homeDirectory: context.env.HOME || context.env.USERPROFILE || homedir(),
2772
2781
  confirm: stringOption(options, "confirm", { required: true }),
2773
2782
  });
2774
2783
  emit(
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) {