warpmetal 0.2.3 → 0.3.0

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
@@ -4,8 +4,8 @@ The official command-line client and portable Agent Skill for WarpMetal.
4
4
 
5
5
  The CLI uses the public API at `https://api.warpmetal.com`, stores generated
6
6
  WarpMetal credentials in a user-private state file, and never reads or stores
7
- wallet private keys or SSH private-key contents. Version 0.2 adds the optional
8
- Agent Runtime workflow for fixed-size, isolated sandboxes on one owner's VPS.
7
+ wallet private keys or SSH private-key contents. Version 0.3 adds guarded OS
8
+ reload and Agent Runtime recovery to the fixed-size, isolated sandbox workflow.
9
9
 
10
10
  ## Distribution
11
11
 
@@ -69,7 +69,7 @@ alternate state directory.
69
69
  every OS reload. Password and keyboard-interactive login are disabled; never
70
70
  request, store, or expect a VPS login password.
71
71
  - The CLI accepts an externally produced x402 `PAYMENT-SIGNATURE` from a file.
72
- Wallet key management and signing remain outside this package.
72
+ Wallet key management and signing remain outside this package.
73
73
  - Destructive or state-changing commands require explicit confirmations and
74
74
  generate idempotency keys by default.
75
75
  - Runtime bootstrap credentials remain memory-only. Signed supervisor bundles
@@ -80,6 +80,11 @@ alternate state directory.
80
80
  - Sandboxes use the fixed runtime image and fixed sizes. Persistent is the
81
81
  default; temporary sandboxes require explicit confirmation and permanently
82
82
  delete their workspace after 15 minutes to 24 hours.
83
+ - Guarded reload powers the server off first. Runtime-enabled reload requires a
84
+ second acknowledgment, after which the CLI guides supervisor reinstall and
85
+ pinned connection-profile refresh. Replacement owner SSH host keys must be
86
+ verified before updating `known_hosts`; erased workspaces are never described
87
+ as recoverable.
83
88
 
84
89
  ## Agent Runtime example
85
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Agent-safe CLI and skill for purchasing and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -118,13 +118,31 @@ warpmetal server power \
118
118
  --json
119
119
  ```
120
120
 
121
- Do not fall back to raw API calls for reload, deletion, networking, renewal,
122
- or another unsupported mutation. Explain that the installed CLI version does
123
- not yet expose that guarded operation. The public reload contract requires
124
- both `confirm: "ERASE"` and `powerOffFirst: true`; the latter authorizes the
125
- same lifecycle operation to shut down the server and wait for provider power
126
- off before erasing it. This contract knowledge is not permission to bypass the
127
- CLI restriction.
121
+ For a destructive reload, explain that every server-disk file is erased and
122
+ obtain explicit approval. If Agent Runtime is enabled, also explain that every
123
+ sandbox workspace is lost, desired sandboxes return as empty workspaces after
124
+ reinstall, and pinned profiles must be refreshed. Then use only the guarded
125
+ command:
126
+
127
+ ```sh
128
+ warpmetal server reload \
129
+ --server <serverId> \
130
+ --confirm ERASE \
131
+ --power-off-first \
132
+ [--acknowledge-agent-runtime-reset] \
133
+ [--os '<exact-live-os-name>'] \
134
+ --wait \
135
+ --json
136
+ ```
137
+
138
+ The CLI requires the recovery owner credential so it can keep polling after
139
+ reload revokes SSH-derived access tokens. On success, reinstall Agent Runtime
140
+ only after verifying the replacement owner-facing SSH host-key fingerprint
141
+ through a trusted provider or console channel and safely updating
142
+ `known_hosts`. Never bypass a mismatch. Then wait for grants to become applied and refresh every
143
+ connection profile with `sandbox access refresh --confirm REFRESH` before
144
+ connecting. Do not fall back to raw API calls for deletion, networking,
145
+ renewal, or another unsupported mutation.
128
146
 
129
147
  ## Use Agent Runtime
130
148
 
@@ -68,6 +68,12 @@ warpmetal server power \
68
68
  [--wait] [--timeout-seconds <n>] \
69
69
  --json
70
70
 
71
+ warpmetal server reload \
72
+ --server <serverId> --confirm ERASE --power-off-first \
73
+ [--acknowledge-agent-runtime-reset] [--hostname <name>] \
74
+ [--os <exact-live-os-name>] [--ssh-public-key-file <public-key-path>] \
75
+ [--idempotency-key <key>] [--wait] [--timeout-seconds <n>] --json
76
+
71
77
  warpmetal operation get \
72
78
  --operation <operationId> \
73
79
  [--server <serverId>] \
@@ -75,10 +81,11 @@ warpmetal operation get \
75
81
  --json
76
82
  ```
77
83
 
78
- This CLI version does not expose reload. The public reload API requires both
79
- `confirm: "ERASE"` and `powerOffFirst: true`, with shutdown, powered-off
80
- verification, and reload handled by one lifecycle operation. Do not call that
81
- endpoint with raw HTTP; wait for a guarded CLI command.
84
+ Reload requires the recovery owner credential rather than a short-lived
85
+ SSH-derived token. `--power-off-first` authorizes shutdown and powered-off
86
+ verification inside the same operation. When Agent Runtime is enabled,
87
+ `--acknowledge-agent-runtime-reset` is required because workspaces are erased,
88
+ the supervisor must be reinstalled, and connection profiles must be refreshed.
82
89
 
83
90
  Use `--token-file` only for recovery when local state is unavailable. Prefer
84
91
  `WARPMETAL_OWNER_TOKEN` or `WARPMETAL_ACCESS_TOKEN` for a single command over a
@@ -124,6 +131,9 @@ warpmetal sandbox access grant \
124
131
  warpmetal sandbox access list --server <serverId> --sandbox <sandboxId> --json
125
132
  warpmetal sandbox access get \
126
133
  --server <serverId> --sandbox <sandboxId> --grant <grantId> [--wait] --json
134
+ warpmetal sandbox access refresh \
135
+ --server <serverId> --sandbox <sandboxId> --grant <grantId> \
136
+ --connection-file <profile-path> --confirm REFRESH [--wait] --json
127
137
  warpmetal sandbox access revoke \
128
138
  --server <serverId> --sandbox <sandboxId> --grant <grantId> \
129
139
  --confirm REVOKE [--wait] --json
@@ -134,6 +144,9 @@ warpmetal sandbox connect --connection-file <profile-path> \
134
144
  `sandbox connect` is the only runtime command that does not use `--json`; it
135
145
  returns the OpenSSH or remote exit status. `--connection-file` on grant
136
146
  creation requires `--wait`.
147
+ `sandbox access refresh` atomically replaces a stale token-free profile with
148
+ the currently applied grant and API-reported pinned host keys; use it after an
149
+ OS reload and supervisor reinstall.
137
150
 
138
151
  ## Skill installation and state
139
152
 
@@ -145,6 +145,25 @@ The token-free profile is written only after the grant is `applied` and the
145
145
  API supplies verified VPS host keys. Do not print or open that profile in an
146
146
  agent conversation.
147
147
 
148
+ After a destructive OS reload, reinstall the supervisor and wait for the
149
+ retained active grant to become `applied`, then replace its stale pinned
150
+ profile:
151
+
152
+ ```sh
153
+ warpmetal sandbox access refresh \
154
+ --server <serverId> \
155
+ --sandbox <sandboxId> \
156
+ --grant <grantId> \
157
+ --connection-file <profile-path> \
158
+ --confirm REFRESH \
159
+ --wait \
160
+ --json
161
+ ```
162
+
163
+ The sandbox record is retained, but its old workspace is not; reconciliation
164
+ creates a new empty workspace. Never bypass a host-key mismatch or reuse the
165
+ pre-reload profile.
166
+
148
167
  Connect without an owner management credential:
149
168
 
150
169
  ```sh
@@ -54,8 +54,13 @@ Obtain explicit user approval immediately before:
54
54
  A reload requires both `confirm: "ERASE"` and `powerOffFirst: true`. Treat
55
55
  `powerOffFirst` as explicit authorization for WarpMetal to shut down the
56
56
  server, wait until it is powered off, and then erase and reinstall it inside
57
- one lifecycle operation. Do not reconstruct this flow with raw HTTP while the
58
- installed CLI lacks a guarded reload command.
57
+ one lifecycle operation. When Agent Runtime is enabled, also require
58
+ `acknowledgeAgentRuntimeReset`: all sandbox workspaces are permanently erased,
59
+ the supervisor identity is revoked, desired sandboxes are recreated empty
60
+ after reinstall, and all pinned profiles require refresh. Use only the guarded
61
+ CLI command and stop on `manual_review`. Reload also replaces the owner-facing
62
+ SSH host key; verify its fingerprint through a trusted provider or console
63
+ channel before changing `known_hosts`, and never disable host-key checking.
59
64
 
60
65
  An order preparation is unpaid but consumes a limited prepared-order slot.
61
66
  Confirm the plan, hostname, OS, and public key before preparing it.
package/src/api.js CHANGED
@@ -188,6 +188,18 @@ export class WarpMetalClient {
188
188
  );
189
189
  }
190
190
 
191
+ reloadServer(serverId, body, token, idempotencyKey) {
192
+ return this.request(
193
+ "POST",
194
+ `/api/servers/${encodeURIComponent(serverId)}/reload`,
195
+ {
196
+ body,
197
+ token,
198
+ idempotencyKey,
199
+ },
200
+ );
201
+ }
202
+
191
203
  getOperation(operationId, token) {
192
204
  return this.request(
193
205
  "GET",
package/src/cli.js CHANGED
@@ -73,6 +73,9 @@ Usage:
73
73
  warpmetal server get --server <serverId>
74
74
  warpmetal server power --server <serverId> --action <boot|reboot|shutdown>
75
75
  --confirm <same-action> [--wait] [--idempotency-key <key>]
76
+ warpmetal server reload --server <serverId> --confirm ERASE --power-off-first
77
+ [--acknowledge-agent-runtime-reset] [--hostname <name>] [--os <exact-name>]
78
+ [--ssh-public-key-file <path>] [--wait] [--idempotency-key <key>]
76
79
  warpmetal operation get --operation <operationId> [--server <serverId>] [--wait]
77
80
  warpmetal runtime enable|get --server <serverId> [--wait]
78
81
  warpmetal runtime install --server <serverId> --identity <owner-key> --ssh-user <user>
@@ -83,6 +86,8 @@ Usage:
83
86
  warpmetal sandbox list|get|action|delete ...
84
87
  warpmetal sandbox access keygen --output <private-key-path> --confirm GENERATE
85
88
  warpmetal sandbox access grant|list|get|revoke ...
89
+ warpmetal sandbox access refresh --server <serverId> --sandbox <sandboxId>
90
+ --grant <grantId> --connection-file <path> --confirm REFRESH [--wait]
86
91
  warpmetal sandbox connect --connection-file <path> --identity <sandbox-key> [-- <command>]
87
92
  warpmetal state list
88
93
  warpmetal agent install --target <codex|claude|all> [--scope <user|project>] [--force]
@@ -199,6 +204,19 @@ async function requireServerToken(store, serverId, options, env) {
199
204
  return token;
200
205
  }
201
206
 
207
+ async function requireReloadToken(store, serverId, options, env) {
208
+ const token =
209
+ (await credentialFromFile(options)) ||
210
+ (await store.serverOwnerToken(serverId, env));
211
+ if (!token) {
212
+ throw new CliError(
213
+ `Reload requires the recovery owner credential for ${serverId} so the CLI can poll after SSH-derived tokens are revoked. Restore the private state file, set WARPMETAL_OWNER_TOKEN, or use --token-file.`,
214
+ { exitCode: 4 },
215
+ );
216
+ }
217
+ return token;
218
+ }
219
+
202
220
  function safePreparedOrder(data, stateFile) {
203
221
  return {
204
222
  task: data.task,
@@ -614,6 +632,98 @@ async function handleServerPower(client, store, options, context) {
614
632
  return result.data.operation.state === "manual_review" ? 6 : 0;
615
633
  }
616
634
 
635
+ async function applyReloadResult(store, serverId, operation) {
636
+ if (operation?.state !== "succeeded") return;
637
+ await store.invalidateServerAccess(serverId);
638
+ if (operation.result?.reloadImpact?.agentRuntimeAffected) {
639
+ await store.saveRuntime(serverId, {
640
+ state: "needs_reinstall",
641
+ desiredRevision: undefined,
642
+ appliedRevision: 0,
643
+ lastSeenAt: null,
644
+ });
645
+ }
646
+ }
647
+
648
+ async function handleServerReload(client, store, options, context) {
649
+ const serverId = stringOption(options, "server", { required: true });
650
+ if (stringOption(options, "confirm", { required: true }) !== "ERASE") {
651
+ throw new CliError("Confirm disk erasure with --confirm ERASE.", {
652
+ exitCode: 2,
653
+ });
654
+ }
655
+ if (!booleanOption(options, "power-off-first")) {
656
+ throw new CliError(
657
+ "Authorize the guarded shutdown with --power-off-first.",
658
+ { exitCode: 2 },
659
+ );
660
+ }
661
+ const acknowledgeRuntimeReset = booleanOption(
662
+ options,
663
+ "acknowledge-agent-runtime-reset",
664
+ );
665
+ const token = await requireReloadToken(
666
+ store,
667
+ serverId,
668
+ options,
669
+ context.env,
670
+ );
671
+ const server = (await client.getServer(serverId, token)).data?.task;
672
+ if (server?.agentRuntime && !acknowledgeRuntimeReset) {
673
+ throw new CliError(
674
+ "Agent Runtime is enabled. Reload permanently erases sandbox workspaces, revokes supervisor identity, and requires connection-profile refresh. Confirm with --acknowledge-agent-runtime-reset.",
675
+ { exitCode: 2 },
676
+ );
677
+ }
678
+
679
+ const body = {
680
+ confirm: "ERASE",
681
+ powerOffFirst: true,
682
+ };
683
+ if (acknowledgeRuntimeReset) body.acknowledgeAgentRuntimeReset = true;
684
+ const hostname = stringOption(options, "hostname");
685
+ const osName = stringOption(options, "os");
686
+ const publicKeyFile = stringOption(options, "ssh-public-key-file");
687
+ if (hostname) body.hostname = hostname;
688
+ if (osName) body.osName = osName;
689
+ if (publicKeyFile) body.sshPublicKey = await readSshPublicKey(publicKeyFile);
690
+
691
+ const key =
692
+ stringOption(options, "idempotency-key") || idempotencyKey("reload");
693
+ let result = await client.reloadServer(serverId, body, token, key);
694
+ const operationId = result.data?.operation?.id;
695
+ if (!operationId) {
696
+ throw new CliError("WarpMetal did not return a reload operation ID.");
697
+ }
698
+ await store.saveOperation(operationId, serverId, "reload");
699
+ if (booleanOption(options, "wait")) {
700
+ result = await pollOperation(
701
+ client,
702
+ operationId,
703
+ token,
704
+ integerOption(options, "timeout-seconds", 900),
705
+ );
706
+ }
707
+
708
+ const operation = result.data?.operation;
709
+ await applyReloadResult(store, serverId, operation);
710
+ emit(
711
+ context.stdout,
712
+ result.data,
713
+ context.json,
714
+ `Reload operation ${operationId}: ${operation?.state}.` +
715
+ (operation?.state === "succeeded" &&
716
+ operation.result?.reloadImpact?.agentRuntimeAffected
717
+ ? " Verify and refresh the owner SSH host key, reinstall Agent Runtime, then refresh every sandbox connection profile."
718
+ : operation?.state === "succeeded"
719
+ ? " Verify and refresh the owner SSH host key before reconnecting."
720
+ : ""),
721
+ );
722
+ if (operation?.state === "manual_review") return 6;
723
+ if (operation?.state === "failed") return 5;
724
+ return operation?.state === "succeeded" ? 0 : 8;
725
+ }
726
+
617
727
  async function handleOperationGet(client, store, options, context) {
618
728
  const operationId = stringOption(options, "operation", { required: true });
619
729
  const saved = await store.operation(operationId);
@@ -626,7 +736,10 @@ async function handleOperationGet(client, store, options, context) {
626
736
  },
627
737
  );
628
738
  }
629
- const token = await requireServerToken(store, serverId, options, context.env);
739
+ const token =
740
+ saved?.kind === "reload"
741
+ ? await requireReloadToken(store, serverId, options, context.env)
742
+ : await requireServerToken(store, serverId, options, context.env);
630
743
  const result = booleanOption(options, "wait")
631
744
  ? await pollOperation(
632
745
  client,
@@ -635,6 +748,9 @@ async function handleOperationGet(client, store, options, context) {
635
748
  integerOption(options, "timeout-seconds", 900),
636
749
  )
637
750
  : await client.getOperation(operationId, token);
751
+ if (saved?.kind === "reload") {
752
+ await applyReloadResult(store, serverId, result.data?.operation);
753
+ }
638
754
  emit(
639
755
  context.stdout,
640
756
  result.data,
@@ -1076,6 +1192,69 @@ async function handleAccessGet(client, store, options, context) {
1076
1192
  return result.data.accessGrant.observedState === "failed" ? 5 : 0;
1077
1193
  }
1078
1194
 
1195
+ async function handleAccessRefresh(client, store, options, context) {
1196
+ const serverId = stringOption(options, "server", { required: true });
1197
+ const sandboxId = stringOption(options, "sandbox", { required: true });
1198
+ const grantId = stringOption(options, "grant", { required: true });
1199
+ const profilePath = stringOption(options, "connection-file", {
1200
+ required: true,
1201
+ });
1202
+ if (stringOption(options, "confirm", { required: true }) !== "REFRESH") {
1203
+ throw new CliError(
1204
+ "Confirm replacement of the pinned connection profile with --confirm REFRESH.",
1205
+ { exitCode: 2 },
1206
+ );
1207
+ }
1208
+ const token = await requireServerToken(store, serverId, options, context.env);
1209
+ const result = booleanOption(options, "wait")
1210
+ ? await pollGrant(
1211
+ client,
1212
+ serverId,
1213
+ sandboxId,
1214
+ grantId,
1215
+ token,
1216
+ integerOption(options, "timeout-seconds", 900),
1217
+ (accessGrant) => accessGrant.observedState === "applied",
1218
+ )
1219
+ : await client.getAccessGrant(serverId, sandboxId, grantId, token);
1220
+ if (result.data?.accessGrant?.observedState !== "applied") {
1221
+ emit(
1222
+ context.stdout,
1223
+ { accessGrant: result.data?.accessGrant, connectionFile: null },
1224
+ context.json,
1225
+ `Access grant ${grantId} is not applied; no profile was changed.`,
1226
+ );
1227
+ return result.data?.accessGrant?.observedState === "failed" ? 5 : 8;
1228
+ }
1229
+ const profile = connectionProfile(
1230
+ serverId,
1231
+ sandboxId,
1232
+ grantId,
1233
+ result.data.connection,
1234
+ );
1235
+ const writtenConnectionFile = await writeConnectionProfile(
1236
+ profilePath,
1237
+ profile,
1238
+ );
1239
+ await store.saveAccessGrant(
1240
+ serverId,
1241
+ result.data.accessGrant,
1242
+ writtenConnectionFile,
1243
+ );
1244
+ const safe = {
1245
+ accessGrant: result.data.accessGrant,
1246
+ connection: { available: true, profileWritten: true, printed: false },
1247
+ connectionFile: writtenConnectionFile,
1248
+ };
1249
+ emit(
1250
+ context.stdout,
1251
+ safe,
1252
+ context.json,
1253
+ `Refreshed pinned connection profile for ${grantId} at ${writtenConnectionFile}.`,
1254
+ );
1255
+ return 0;
1256
+ }
1257
+
1079
1258
  async function handleAccessRevoke(client, store, options, context) {
1080
1259
  const serverId = stringOption(options, "server", { required: true });
1081
1260
  const sandboxId = stringOption(options, "sandbox", { required: true });
@@ -1254,6 +1433,22 @@ async function dispatch(positionals, options, passthrough, context) {
1254
1433
  "timeout-seconds",
1255
1434
  ]);
1256
1435
  return handleServerPower(client, store, options, context);
1436
+ case "server reload":
1437
+ rejectUnknownOptions(options, [
1438
+ ...COMMON_OPTIONS,
1439
+ "server",
1440
+ "token-file",
1441
+ "confirm",
1442
+ "power-off-first",
1443
+ "acknowledge-agent-runtime-reset",
1444
+ "hostname",
1445
+ "os",
1446
+ "ssh-public-key-file",
1447
+ "idempotency-key",
1448
+ "wait",
1449
+ "timeout-seconds",
1450
+ ]);
1451
+ return handleServerReload(client, store, options, context);
1257
1452
  case "operation get":
1258
1453
  rejectUnknownOptions(options, [
1259
1454
  ...COMMON_OPTIONS,
@@ -1387,6 +1582,19 @@ async function dispatch(positionals, options, passthrough, context) {
1387
1582
  "timeout-seconds",
1388
1583
  ]);
1389
1584
  return handleAccessGet(client, store, options, context);
1585
+ case "sandbox access refresh":
1586
+ rejectUnknownOptions(options, [
1587
+ ...COMMON_OPTIONS,
1588
+ "server",
1589
+ "sandbox",
1590
+ "grant",
1591
+ "token-file",
1592
+ "connection-file",
1593
+ "confirm",
1594
+ "wait",
1595
+ "timeout-seconds",
1596
+ ]);
1597
+ return handleAccessRefresh(client, store, options, context);
1390
1598
  case "sandbox access revoke":
1391
1599
  rejectUnknownOptions(options, [
1392
1600
  ...COMMON_OPTIONS,
package/src/state.js CHANGED
@@ -261,6 +261,20 @@ export class StateStore {
261
261
  return server.ownerToken;
262
262
  }
263
263
 
264
+ async serverOwnerToken(serverId, env = process.env) {
265
+ if (env.WARPMETAL_OWNER_TOKEN) return env.WARPMETAL_OWNER_TOKEN;
266
+ return (await this.server(serverId))?.ownerToken;
267
+ }
268
+
269
+ async invalidateServerAccess(serverId) {
270
+ await this.update((state) => {
271
+ const server = state.servers[serverId];
272
+ if (!server) return;
273
+ delete server.accessToken;
274
+ delete server.accessTokenExpiresAt;
275
+ });
276
+ }
277
+
264
278
  async summary() {
265
279
  const state = await this.read();
266
280
  return {