warpmetal 0.8.7 → 0.8.8

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
@@ -103,6 +103,19 @@ key does not encode a login username: use `root@<server-ip>` for an owner shell
103
103
  and `--ssh-user root` for runtime installation, not a distribution convention
104
104
  such as `ubuntu`.
105
105
 
106
+ Starting in CLI 0.8.8, `runtime install --confirm INSTALL` establishes managed
107
+ SSH host trust without requiring access to the VPS provider. For a server trust
108
+ epoch with no pin, the CLI runs only a harmless `ssh true` using the exact owner
109
+ key, trusts the first observed Ed25519 host key, stores it under the private
110
+ WarpMetal state directory, and immediately reconnects with strict checking. It
111
+ requests the Runtime bootstrap only after that strict replay succeeds. Every
112
+ later SSH and SCP operation must match the same pin; a changed key is never
113
+ silently accepted or overwritten.
114
+
115
+ The private WarpMetal state directory is the local trust domain. Back it up
116
+ with the owner key: deleting it also deletes the durable pin, so the next
117
+ install is a new first-use decision rather than a verified continuation.
118
+
106
119
  Pass `--json` for structured, secret-redacted output. Use
107
120
  `WARPMETAL_API_URL` for an alternate API origin and `WARPMETAL_HOME` for an
108
121
  alternate state directory.
@@ -300,8 +313,9 @@ compatible external signer.
300
313
  Wallet key management and signing remain outside this package.
301
314
  - Destructive or state-changing commands require explicit confirmations and
302
315
  generate idempotency keys by default.
303
- - Runtime bootstrap credentials remain memory-only. Signed supervisor bundles
304
- are checksum- and signature-verified before OpenSSH uploads them.
316
+ - Runtime bootstrap credentials remain memory-only and are requested only after
317
+ the first host key has been pinned and strictly reverified. Signed supervisor
318
+ bundles are checksum- and signature-verified before OpenSSH uploads them.
305
319
  - Each agent gets a distinct SSH key forced into exactly one sandbox. Token-free
306
320
  connection profiles pin the VPS host key and contain no owner credential or
307
321
  private-key material.
@@ -315,10 +329,10 @@ compatible external signer.
315
329
  the observed digest and generation both match the accepted target.
316
330
  - Guarded reload powers the server off first. Runtime-enabled reload requires a
317
331
  second acknowledgment, after which the CLI guides supervisor reinstall and
318
- pinned connection-profile refresh. Post-reload owner SSH host keys must be
319
- independently verified because the provider may rotate or preserve them;
320
- update `known_hosts` only when the verified key changed. Erased workspaces
321
- are never described as recoverable.
332
+ pinned connection-profile refresh. Only a locally recorded reload operation
333
+ that succeeds and reports an owner-host-key refresh opens one new managed
334
+ trust epoch; failed or ambiguous reloads retain the old pin and never permit
335
+ replacement. Erased workspaces are never described as recoverable.
322
336
 
323
337
  ## Agent Runtime example
324
338
 
@@ -349,6 +363,13 @@ warpmetal sandbox access keygen \
349
363
  --json
350
364
  ```
351
365
 
366
+ On the first install in a server trust epoch, JSON output includes
367
+ `hostKeyTrust.state: "trusted_first_use"` and the safe Ed25519 fingerprint.
368
+ Later installs report `"matched"`. First-use trust protects continuity after
369
+ that observation, but it cannot detect an active attacker on the first
370
+ connection. Provider-console host-key pre-enrollment remains an optional
371
+ higher-assurance alternative when it is available.
372
+
352
373
  WarpMetal CLI v0.8.7 with Agent Runtime v0.1.25 or newer can enable the narrowly
353
374
  scoped AppArmor exception required by a verified workload that creates an inner
354
375
  Bubblewrap PID namespace and private `/proc`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20.0.0"
17
17
  },
18
18
  "scripts": {
19
- "check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/errors.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
19
+ "check": "node --check bin/warpmetal.js && node --check src/api.js && node --check src/args.js && node --check src/cli.js && node --check src/connection.js && node --check src/errors.js && node --check src/host-trust.js && node --check src/install-skill.js && node --check src/installer.js && node --check src/payment.js && node --check src/runtime.js && node --check src/ssh.js && node --check src/state.js && node --check src/version.js",
20
20
  "plugin:check": "node --test test/plugin.test.js",
21
21
  "test": "node --test",
22
22
  "prepack": "npm run check && npm test"
@@ -266,11 +266,13 @@ warpmetal server reload \
266
266
  ```
267
267
 
268
268
  The CLI requires the recovery owner credential so it can keep polling after
269
- reload revokes SSH-derived access tokens. On success, reinstall Agent Runtime
270
- only after verifying the post-reload owner-facing SSH host-key fingerprint
271
- through a trusted provider or console channel. The provider may rotate or
272
- preserve the key; update `known_hosts` only when the verified key changed and
273
- never bypass a mismatch. Then wait for grants to become applied and refresh
269
+ reload revokes SSH-derived access tokens. CLI 0.8.8 records a new SSH trust
270
+ epoch only when that exact local reload operation succeeds and reports that the
271
+ owner host key needs refresh. The next Runtime install may trust the first
272
+ observed Ed25519 host key once in that epoch, then must reconnect strictly
273
+ before requesting bootstrap. Failed or ambiguous reloads retain the prior pin;
274
+ never bypass a mismatch or delete a pin to force a retry. A provider-console
275
+ pre-seed is the optional higher-assurance path. Then wait for grants to become applied and refresh
274
276
  every connection profile with `sandbox access refresh --confirm REFRESH`
275
277
  before connecting. Do not fall back to raw API calls for deletion, networking,
276
278
  or another unsupported mutation.
@@ -283,6 +285,14 @@ sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
283
285
  `warpmetal runtime enable` after the VPS is ready. Supervisor installation is
284
286
  separate and requires approval plus `--confirm INSTALL`.
285
287
 
288
+ With CLI 0.8.8, that confirmation also authorizes managed trust on first use
289
+ when the exact server trust epoch has no pin. The CLI performs only an owner-
290
+ key-authenticated `ssh true`, pins the first observed Ed25519 key, immediately
291
+ reconnects strictly, and requests bootstrap only afterward. Later connections
292
+ must match. Explain that TOFU cannot detect an active attacker on the first
293
+ connection; never use `ssh-keyscan`, accept a mismatch, or expose a generic pin
294
+ reset.
295
+
286
296
  For any verified workload that creates an inner Bubblewrap PID namespace and
287
297
  private `/proc`, CLI 0.8.7 with Runtime 0.1.25 or newer may add
288
298
  `--nested-private-procfs enable` to the approved Runtime install. Planning with
@@ -232,6 +232,16 @@ warpmetal sandbox delete \
232
232
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
233
233
  ```
234
234
 
235
+ CLI 0.8.8 manages owner-facing VPS host trust during `runtime install`. With no
236
+ pin for the exact server trust epoch, the confirmed install performs one
237
+ harmless owner-key-authenticated SSH connection, trusts the first observed
238
+ Ed25519 host key, atomically pins it, and immediately reconnects strictly before
239
+ requesting bootstrap. JSON reports `hostKeyTrust.state` as
240
+ `trusted_first_use` or `matched` plus the safe fingerprint. Every later SSH and
241
+ SCP operation is strict; changed keys, malformed pins, and failed or ambiguous
242
+ reloads never replace trust. This TOFU step cannot detect an active attacker on
243
+ the first connection. Provider-console pre-enrollment is optional and stronger.
244
+
235
245
  The nested-private-procfs action requires CLI 0.8.7 and Runtime 0.1.25 or
236
246
  newer. It defaults to `preserve`. `enable` is a host-level opt-in for any
237
247
  verified workload that creates an inner Bubblewrap PID namespace and private
@@ -65,9 +65,21 @@ warpmetal runtime get --server <serverId> --wait --json
65
65
  ```
66
66
 
67
67
  Ask before installation. Pass the owner key path without reading the file.
68
- The CLI holds the one-time bootstrap only in memory, verifies the signed
69
- artifact, uploads it through OpenSSH without a shell-enabled local spawn, and
70
- does not print or store the bootstrap.
68
+ CLI 0.8.8 first validates that the public half matches the server's ordering-key
69
+ fingerprint. When that server trust epoch has no pin, it runs only an owner-key-
70
+ authenticated `ssh true`, trusts the first observed Ed25519 host key, publishes
71
+ it atomically under the private WarpMetal state directory, and immediately
72
+ reconnects strictly. Only then does the CLI request the one-time bootstrap. It
73
+ holds that bootstrap only in memory, verifies the signed artifact, uploads it
74
+ through OpenSSH without a shell-enabled local spawn, and does not print or store
75
+ the bootstrap. Every later SSH/SCP operation uses the same strict pin; a
76
+ mismatch never overwrites it.
77
+
78
+ First-use trust provides continuity after the first observation but cannot
79
+ detect an active attacker on that connection. Provider-console pre-enrollment
80
+ is an optional higher-assurance alternative, not a requirement. Only a locally
81
+ recorded successful reload that reports an owner-host-key refresh creates one
82
+ new operation-bound trust epoch; failed or ambiguous reloads do not.
71
83
 
72
84
  `--nested-private-procfs` is supported by `warpmetal` CLI 0.8.7 with Agent
73
85
  Runtime 0.1.25 or newer. Its default is `preserve`, which makes no AppArmor
@@ -99,9 +99,18 @@ the supervisor identity is revoked, desired sandboxes are recreated empty
99
99
  after reinstall, and all pinned profiles require refresh. Use only the guarded
100
100
  CLI command and stop on `manual_review`. Reload invalidates the prior
101
101
  owner-facing SSH host-key trust decision; the provider may rotate or preserve
102
- the key. Verify the post-reload fingerprint through a trusted provider or
103
- console channel, change `known_hosts` only when the verified key changed, and
104
- never disable host-key checking.
102
+ the key. CLI 0.8.8 opens one new managed trust epoch only after the exact local
103
+ reload operation succeeds and reports that refresh is required. The first
104
+ owner-key-authenticated connection may pin the observed Ed25519 key once, then
105
+ all connections are strict. Failed or ambiguous reloads never advance trust.
106
+ Never use `ssh-keyscan`, disable host-key checking, accept a mismatch, or delete
107
+ a pin as a generic reset. Provider-console pre-enrollment remains the optional
108
+ higher-assurance alternative; TOFU cannot detect an active attacker on the
109
+ first connection.
110
+
111
+ The private WarpMetal state directory is the local trust domain. Deleting it
112
+ removes the durable pin and makes the next install a new first-use decision;
113
+ never present that as continuity with the prior server identity.
105
114
 
106
115
  An order preparation is unpaid but consumes a limited prepared-order slot.
107
116
  Confirm the plan, hostname, OS, and public key before preparing it.
package/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { WarpMetalClient } from "./api.js";
13
13
  import { connectionProfile, writeConnectionProfile } from "./connection.js";
14
14
  import { CliError, toErrorMessage } from "./errors.js";
15
+ import { establishHostTrust } from "./host-trust.js";
15
16
  import { installSkill } from "./install-skill.js";
16
17
  import { installRuntime } from "./installer.js";
17
18
  import {
@@ -142,6 +143,16 @@ inner Bubblewrap PID namespace and private /proc. Planning, coding, and QA are
142
143
  common examples. GitHub access, an AI CLI, and subagent delegation alone do not
143
144
  require it. The capability is host-scoped, not per-sandbox.
144
145
 
146
+ SSH host trust (CLI 0.8.8+):
147
+ runtime install authenticates with the exact owner key and trusts the first
148
+ observed Ed25519 host key once for that server trust epoch. It pins the key
149
+ before requesting a Runtime bootstrap, immediately reconnects strictly, and
150
+ requires that same pin thereafter. A changed key is never accepted or
151
+ overwritten. A successful recorded server reload may create one new trust
152
+ epoch; failed or ambiguous reloads do not. First-use trust cannot detect an
153
+ active attacker on the first connection. Provider-console pre-enrollment is
154
+ the optional higher-assurance alternative.
155
+
145
156
  Credential environment variables:
146
157
  WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
147
158
  WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
@@ -1939,6 +1950,7 @@ async function applyReloadResult(
1939
1950
  operation,
1940
1951
  pendingIdentityId,
1941
1952
  ) {
1953
+ await store.applyReloadHostTrust(serverId, operation);
1942
1954
  if (operation?.state !== "succeeded") return;
1943
1955
  if (pendingIdentityId) {
1944
1956
  await store.bindIdentity(pendingIdentityId, undefined, serverId);
@@ -2052,10 +2064,12 @@ async function handleServerReload(client, store, options, context) {
2052
2064
  context.json,
2053
2065
  `Reload operation ${operationId}: ${operation?.state}.` +
2054
2066
  (operation?.state === "succeeded" &&
2055
- operation.result?.reloadImpact?.agentRuntimeAffected
2056
- ? " Verify and refresh the owner SSH host key, reinstall Agent Runtime, then refresh every sandbox connection profile."
2067
+ operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh
2068
+ ? operation.result?.reloadImpact?.agentRuntimeAffected
2069
+ ? " The next Runtime install will establish the operation-bound owner SSH trust epoch, then reinstall Agent Runtime and refresh every sandbox connection profile."
2070
+ : " The next Runtime install will establish the operation-bound owner SSH trust epoch before reconnecting."
2057
2071
  : operation?.state === "succeeded"
2058
- ? " Verify and refresh the owner SSH host key before reconnecting."
2072
+ ? " WarpMetal did not report a host-key refresh; the existing managed pin remains required."
2059
2073
  : ""),
2060
2074
  );
2061
2075
  if (operation?.state === "manual_review") return 6;
@@ -2170,6 +2184,20 @@ async function handleRuntimeInstall(client, store, options, context) {
2170
2184
  serverId,
2171
2185
  identity,
2172
2186
  );
2187
+ const server = (await client.getServer(serverId, issued.accessToken)).data
2188
+ ?.task;
2189
+ const trustEpoch = await store.hostTrustEpoch(serverId);
2190
+ const hostKeyTrust = await establishHostTrust({
2191
+ stateDirectory: store.directory,
2192
+ serverId,
2193
+ trustEpoch,
2194
+ server,
2195
+ identityPath: identity,
2196
+ sshUser,
2197
+ reinspectServer: async () =>
2198
+ (await client.getServer(serverId, issued.accessToken)).data?.task,
2199
+ spawnImpl: context.spawnImpl,
2200
+ });
2173
2201
  const key =
2174
2202
  stringOption(options, "idempotency-key") ||
2175
2203
  idempotencyKey("runtime-bootstrap");
@@ -2184,11 +2212,19 @@ async function handleRuntimeInstall(client, store, options, context) {
2184
2212
  token: issued.accessToken,
2185
2213
  identity,
2186
2214
  sshUser,
2215
+ knownHostsFile: hostKeyTrust.knownHostsFile,
2216
+ trustedPublicIp: hostKeyTrust.publicIp,
2187
2217
  bootstrap: bootstrap.data,
2188
2218
  nestedPrivateProcfs,
2189
2219
  fetchImpl: context.fetchImpl,
2190
2220
  spawnImpl: context.spawnImpl,
2191
2221
  });
2222
+ safe.hostKeyTrust = {
2223
+ state: hostKeyTrust.state,
2224
+ algorithm: hostKeyTrust.algorithm,
2225
+ fingerprint: hostKeyTrust.fingerprint,
2226
+ trustEpoch: hostKeyTrust.trustEpoch,
2227
+ };
2192
2228
  let runtimeResult;
2193
2229
  if (booleanOption(options, "wait")) {
2194
2230
  runtimeResult = await pollRuntime(
@@ -2204,7 +2240,7 @@ async function handleRuntimeInstall(client, store, options, context) {
2204
2240
  context.stdout,
2205
2241
  safe,
2206
2242
  context.json,
2207
- `Installed Agent Runtime ${safe.supervisorVersion} on ${serverId}${safe.runtime ? `: ${safe.runtime.state}` : "."}`,
2243
+ `Installed Agent Runtime ${safe.supervisorVersion} on ${serverId}${safe.runtime ? `: ${safe.runtime.state}` : "."} SSH host key ${safe.hostKeyTrust.state === "trusted_first_use" ? "trusted on first use" : "matched"} (${safe.hostKeyTrust.fingerprint}).`,
2208
2244
  );
2209
2245
  return safe.runtime?.state === "degraded" ? 5 : 0;
2210
2246
  }
@@ -0,0 +1,524 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+ import {
4
+ chmod,
5
+ link,
6
+ lstat,
7
+ mkdir,
8
+ open,
9
+ readFile,
10
+ rm,
11
+ unlink,
12
+ } from "node:fs/promises";
13
+ import { isIP } from "node:net";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
15
+
16
+ import { CliError } from "./errors.js";
17
+ import { readSshPublicKey, sshFingerprint } from "./ssh.js";
18
+
19
+ const SERVER_ID = /^srv_[A-Za-z0-9_-]{8,60}$/;
20
+ const TRUST_EPOCH = /^(?:initial|reload-[A-Za-z0-9_-]{8,128})$/;
21
+
22
+ function currentUid() {
23
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
24
+ }
25
+
26
+ function exactMode(metadata) {
27
+ return metadata.mode & 0o777;
28
+ }
29
+
30
+ async function requirePrivateDirectory(path) {
31
+ const metadata = await lstat(path);
32
+ const uid = currentUid();
33
+ if (
34
+ !metadata.isDirectory() ||
35
+ metadata.isSymbolicLink() ||
36
+ (process.platform !== "win32" && exactMode(metadata) !== 0o700) ||
37
+ (uid !== undefined && metadata.uid !== uid)
38
+ ) {
39
+ throw new CliError("The managed SSH trust directory is not private.", {
40
+ exitCode: 4,
41
+ code: "host_trust_directory_unsafe",
42
+ });
43
+ }
44
+ }
45
+
46
+ async function ensurePrivateDirectory(path) {
47
+ let created = false;
48
+ try {
49
+ await mkdir(path, { mode: 0o700 });
50
+ created = true;
51
+ } catch (error) {
52
+ if (error?.code !== "EEXIST") throw error;
53
+ }
54
+ if (created && process.platform !== "win32") await chmod(path, 0o700);
55
+ await requirePrivateDirectory(path);
56
+ if (created) await syncPath(dirname(path));
57
+ }
58
+
59
+ async function syncPath(path) {
60
+ const handle = await open(path, "r");
61
+ try {
62
+ await handle.sync();
63
+ } finally {
64
+ await handle.close();
65
+ }
66
+ }
67
+
68
+ async function requirePrivateFile(path, label) {
69
+ const metadata = await lstat(path);
70
+ const uid = currentUid();
71
+ if (
72
+ !metadata.isFile() ||
73
+ metadata.isSymbolicLink() ||
74
+ metadata.nlink !== 1 ||
75
+ (process.platform !== "win32" && exactMode(metadata) !== 0o600) ||
76
+ (uid !== undefined && metadata.uid !== uid)
77
+ ) {
78
+ throw new CliError(`${label} is not an owner-only regular file.`, {
79
+ exitCode: 4,
80
+ code: "host_trust_file_unsafe",
81
+ });
82
+ }
83
+ }
84
+
85
+ async function requireIdentity(identityPath, expectedFingerprint) {
86
+ const identity = resolve(identityPath);
87
+ await requirePrivateFile(identity, "The SSH identity");
88
+ const publicPath = `${identity}.pub`;
89
+ const publicMetadata = await lstat(publicPath);
90
+ const uid = currentUid();
91
+ if (
92
+ !publicMetadata.isFile() ||
93
+ publicMetadata.isSymbolicLink() ||
94
+ publicMetadata.nlink !== 1 ||
95
+ (uid !== undefined && publicMetadata.uid !== uid)
96
+ ) {
97
+ throw new CliError("The SSH public identity is not a safe regular file.", {
98
+ exitCode: 4,
99
+ code: "host_trust_identity_unsafe",
100
+ });
101
+ }
102
+ const fingerprint = sshFingerprint(await readSshPublicKey(publicPath));
103
+ if (fingerprint !== expectedFingerprint) {
104
+ throw new CliError(
105
+ "The local SSH identity does not match the server owner key.",
106
+ { exitCode: 4, code: "host_trust_identity_mismatch" },
107
+ );
108
+ }
109
+ return { identity, fingerprint };
110
+ }
111
+
112
+ export function hostTrustPaths(stateDirectory, serverId, trustEpoch = "initial") {
113
+ if (!SERVER_ID.test(serverId)) {
114
+ throw new CliError("The server ID is invalid.", { exitCode: 2 });
115
+ }
116
+ if (!TRUST_EPOCH.test(trustEpoch)) {
117
+ throw new CliError("The SSH host-trust epoch is invalid.", {
118
+ exitCode: 4,
119
+ code: "host_trust_epoch_invalid",
120
+ });
121
+ }
122
+ const stateRoot = resolve(stateDirectory);
123
+ const sshDirectory = join(stateRoot, "ssh");
124
+ const knownHostsDirectory = join(sshDirectory, "known-hosts");
125
+ const serverDirectory = join(knownHostsDirectory, serverId);
126
+ return {
127
+ stateRoot,
128
+ sshDirectory,
129
+ knownHostsDirectory,
130
+ serverDirectory,
131
+ knownHostsFile: join(serverDirectory, `${trustEpoch}.known_hosts`),
132
+ };
133
+ }
134
+
135
+ async function ensureTrustDirectories(paths) {
136
+ await requirePrivateDirectory(paths.stateRoot);
137
+ await ensurePrivateDirectory(paths.sshDirectory);
138
+ await ensurePrivateDirectory(paths.knownHostsDirectory);
139
+ await ensurePrivateDirectory(paths.serverDirectory);
140
+ }
141
+
142
+ function decodeEd25519(material) {
143
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(material)) return undefined;
144
+ const decoded = Buffer.from(material, "base64");
145
+ if (decoded.toString("base64") !== material) return undefined;
146
+ if (decoded.length !== 51 || decoded.readUInt32BE(0) !== 11) return undefined;
147
+ if (decoded.subarray(4, 15).toString("utf8") !== "ssh-ed25519") {
148
+ return undefined;
149
+ }
150
+ if (decoded.readUInt32BE(15) !== 32) return undefined;
151
+ return decoded;
152
+ }
153
+
154
+ export function validateKnownHosts(content, publicIp, sshPort = 22) {
155
+ if (isIP(publicIp) !== 4) {
156
+ throw new CliError("The server returned an invalid public IP.", {
157
+ exitCode: 3,
158
+ code: "host_trust_ip_invalid",
159
+ });
160
+ }
161
+ if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65_535) {
162
+ throw new CliError("The SSH port is invalid.", { exitCode: 2 });
163
+ }
164
+ const expectedHost =
165
+ sshPort === 22 ? publicIp : `[${publicIp}]:${sshPort}`;
166
+ const lines = String(content).split(/\r?\n/).filter(Boolean);
167
+ const match = /^([^ ]+) (ssh-ed25519) ([A-Za-z0-9+/]+={0,2})$/.exec(
168
+ lines[0] || "",
169
+ );
170
+ const decoded = match && decodeEd25519(match[3]);
171
+ if (lines.length !== 1 || !match || match[1] !== expectedHost || !decoded) {
172
+ throw new CliError("The observed SSH host key is not a canonical Ed25519 pin.", {
173
+ exitCode: 4,
174
+ code: "host_trust_pin_invalid",
175
+ });
176
+ }
177
+ return {
178
+ content: `${match[1]} ${match[2]} ${match[3]}\n`,
179
+ algorithm: match[2],
180
+ fingerprint: `SHA256:${createHash("sha256")
181
+ .update(decoded)
182
+ .digest("base64")
183
+ .replace(/=+$/, "")}`,
184
+ };
185
+ }
186
+
187
+ export function strictSshOptions(identityPath, knownHostsFile) {
188
+ if (!isAbsolute(knownHostsFile)) {
189
+ throw new CliError("The managed SSH host pin path is invalid.", {
190
+ exitCode: 4,
191
+ code: "host_trust_path_invalid",
192
+ });
193
+ }
194
+ return [
195
+ "-F",
196
+ "/dev/null",
197
+ "-i",
198
+ resolve(identityPath),
199
+ "-o",
200
+ "IdentitiesOnly=yes",
201
+ "-o",
202
+ "IdentityAgent=none",
203
+ "-o",
204
+ "BatchMode=yes",
205
+ "-o",
206
+ "ConnectTimeout=10",
207
+ "-o",
208
+ "ConnectionAttempts=1",
209
+ "-o",
210
+ "ServerAliveInterval=5",
211
+ "-o",
212
+ "ServerAliveCountMax=1",
213
+ "-o",
214
+ "PubkeyAuthentication=yes",
215
+ "-o",
216
+ "PreferredAuthentications=publickey",
217
+ "-o",
218
+ "PasswordAuthentication=no",
219
+ "-o",
220
+ "KbdInteractiveAuthentication=no",
221
+ "-o",
222
+ "ChallengeResponseAuthentication=no",
223
+ "-o",
224
+ "GSSAPIAuthentication=no",
225
+ "-o",
226
+ "StrictHostKeyChecking=yes",
227
+ "-o",
228
+ `UserKnownHostsFile=${knownHostsFile}`,
229
+ "-o",
230
+ "GlobalKnownHostsFile=/dev/null",
231
+ "-o",
232
+ "HashKnownHosts=no",
233
+ "-o",
234
+ "UpdateHostKeys=no",
235
+ "-o",
236
+ "VerifyHostKeyDNS=no",
237
+ "-o",
238
+ "HostKeyAlgorithms=ssh-ed25519",
239
+ "-o",
240
+ "ForwardAgent=no",
241
+ "-o",
242
+ "ForwardX11=no",
243
+ "-o",
244
+ "ClearAllForwardings=yes",
245
+ "-o",
246
+ "PermitLocalCommand=no",
247
+ "-o",
248
+ "ProxyCommand=none",
249
+ "-o",
250
+ "RequestTTY=no",
251
+ ];
252
+ }
253
+
254
+ function firstUseSshOptions(identityPath, candidatePath) {
255
+ const args = strictSshOptions(identityPath, candidatePath);
256
+ const index = args.indexOf("StrictHostKeyChecking=yes");
257
+ args[index] = "StrictHostKeyChecking=accept-new";
258
+ return args;
259
+ }
260
+
261
+ async function spawnSsh(
262
+ args,
263
+ spawnImpl,
264
+ errorMessage,
265
+ diagnosticDirectory,
266
+ publicIp,
267
+ sshPort,
268
+ ) {
269
+ const diagnosticPath = join(
270
+ diagnosticDirectory,
271
+ `.ssh-auth.${process.pid}.${randomUUID()}.log`,
272
+ );
273
+ const diagnosticHandle = await open(diagnosticPath, "wx", 0o600);
274
+ await diagnosticHandle.close();
275
+ let result;
276
+ try {
277
+ try {
278
+ result = await new Promise((resolvePromise, reject) => {
279
+ const child = spawnImpl("ssh", ["-v", "-E", diagnosticPath, ...args], {
280
+ shell: false,
281
+ stdio: ["ignore", "ignore", "ignore"],
282
+ });
283
+ child.once("error", reject);
284
+ child.once("close", (status) => resolvePromise({ status }));
285
+ });
286
+ } catch (error) {
287
+ if (error?.code === "ENOENT") {
288
+ throw new CliError("OpenSSH is required to establish VPS host trust.", {
289
+ exitCode: 2,
290
+ code: "host_trust_ssh_unavailable",
291
+ });
292
+ }
293
+ throw error;
294
+ }
295
+ if (result.status !== 0) {
296
+ throw new CliError(errorMessage, {
297
+ exitCode: 4,
298
+ code: "host_trust_authentication_failed",
299
+ });
300
+ }
301
+ await requirePrivateFile(diagnosticPath, "The private OpenSSH diagnostic");
302
+ const diagnostic = await readFile(diagnosticPath, "utf8");
303
+ const authenticationResults = diagnostic
304
+ .split(/\r?\n/)
305
+ .filter((line) => /^Authenticated to .* using "[^"]+"\.$/.test(line));
306
+ const expectedResult = `Authenticated to ${publicIp} ([${publicIp}]:${sshPort}) using "publickey".`;
307
+ if (
308
+ authenticationResults.length !== 1 ||
309
+ authenticationResults[0] !== expectedResult
310
+ ) {
311
+ throw new CliError(
312
+ "SSH did not prove authentication with the exact owner public key. No runtime bootstrap was requested.",
313
+ { exitCode: 4, code: "host_trust_authentication_method_invalid" },
314
+ );
315
+ }
316
+ } finally {
317
+ await rm(diagnosticPath, { force: true });
318
+ }
319
+ }
320
+
321
+ async function strictProbe({
322
+ identity,
323
+ publicIp,
324
+ sshPort,
325
+ sshUser,
326
+ knownHostsFile,
327
+ spawnImpl,
328
+ }) {
329
+ await spawnSsh(
330
+ [
331
+ ...strictSshOptions(identity, knownHostsFile),
332
+ ...(sshPort === 22 ? [] : ["-p", String(sshPort)]),
333
+ `${sshUser}@${publicIp}`,
334
+ "true",
335
+ ],
336
+ spawnImpl,
337
+ "The VPS host key or owner SSH authentication did not match the managed pin.",
338
+ dirname(knownHostsFile),
339
+ publicIp,
340
+ sshPort,
341
+ );
342
+ }
343
+
344
+ async function publishCandidate(
345
+ candidatePath,
346
+ knownHostsFile,
347
+ expectedContent,
348
+ publicIp,
349
+ sshPort,
350
+ ) {
351
+ await syncPath(candidatePath);
352
+ try {
353
+ await link(candidatePath, knownHostsFile);
354
+ await unlink(candidatePath);
355
+ } catch (error) {
356
+ if (error?.code !== "EEXIST") throw error;
357
+ const existing = validateKnownHosts(
358
+ await readFile(knownHostsFile, "utf8"),
359
+ publicIp,
360
+ sshPort,
361
+ );
362
+ if (existing.content !== expectedContent) {
363
+ throw new CliError("The VPS host key changed during first-use pinning.", {
364
+ exitCode: 4,
365
+ code: "host_trust_race_mismatch",
366
+ });
367
+ }
368
+ await unlink(candidatePath);
369
+ }
370
+ await requirePrivateFile(knownHostsFile, "The managed SSH host pin");
371
+ await syncPath(knownHostsFile);
372
+ await syncPath(resolve(knownHostsFile, ".."));
373
+ }
374
+
375
+ export async function establishHostTrust({
376
+ stateDirectory,
377
+ serverId,
378
+ trustEpoch = "initial",
379
+ server,
380
+ identityPath,
381
+ sshUser,
382
+ sshPort = 22,
383
+ reinspectServer,
384
+ spawnImpl = nodeSpawn,
385
+ }) {
386
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(sshUser)) {
387
+ throw new CliError("--ssh-user is invalid.", { exitCode: 2 });
388
+ }
389
+ if (!server || isIP(server.publicIp) !== 4) {
390
+ throw new CliError("The server returned an invalid public IP.", {
391
+ exitCode: 3,
392
+ });
393
+ }
394
+ if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65_535) {
395
+ throw new CliError("The SSH port is invalid.", { exitCode: 2 });
396
+ }
397
+ const activeCancellation =
398
+ ["cancellation_pending", "cancelled"].includes(server.state) &&
399
+ Number.isFinite(Date.parse(server.termEndsAt || "")) &&
400
+ Date.parse(server.termEndsAt) > Date.now();
401
+ if (
402
+ server.serverId !== serverId ||
403
+ (server.state !== "ready" && !activeCancellation)
404
+ ) {
405
+ throw new CliError(
406
+ "The VPS must be ready and bound to this server before SSH host trust is established.",
407
+ { exitCode: 5, code: "host_trust_server_unavailable" },
408
+ );
409
+ }
410
+ if (typeof server.sshFingerprint !== "string") {
411
+ throw new CliError("The server did not return its owner-key fingerprint.", {
412
+ exitCode: 3,
413
+ });
414
+ }
415
+ const { identity } = await requireIdentity(
416
+ identityPath,
417
+ server.sshFingerprint,
418
+ );
419
+ const paths = hostTrustPaths(stateDirectory, serverId, trustEpoch);
420
+ await ensureTrustDirectories(paths);
421
+
422
+ try {
423
+ await requirePrivateFile(paths.knownHostsFile, "The managed SSH host pin");
424
+ const pin = validateKnownHosts(
425
+ await readFile(paths.knownHostsFile, "utf8"),
426
+ server.publicIp,
427
+ sshPort,
428
+ );
429
+ await strictProbe({
430
+ identity,
431
+ publicIp: server.publicIp,
432
+ sshPort,
433
+ sshUser,
434
+ knownHostsFile: paths.knownHostsFile,
435
+ spawnImpl,
436
+ });
437
+ return {
438
+ state: "matched",
439
+ algorithm: pin.algorithm,
440
+ fingerprint: pin.fingerprint,
441
+ knownHostsFile: paths.knownHostsFile,
442
+ publicIp: server.publicIp,
443
+ trustEpoch,
444
+ };
445
+ } catch (error) {
446
+ if (error?.code !== "ENOENT") throw error;
447
+ }
448
+
449
+ if (typeof reinspectServer !== "function") {
450
+ throw new CliError(
451
+ "A fresh server inspection is required before first-use SSH trust can be published.",
452
+ { exitCode: 4, code: "host_trust_reinspection_required" },
453
+ );
454
+ }
455
+
456
+ const candidatePath = join(
457
+ paths.serverDirectory,
458
+ `.${trustEpoch}.${process.pid}.${randomUUID()}.candidate`,
459
+ );
460
+ const handle = await open(candidatePath, "wx", 0o600);
461
+ await handle.close();
462
+ try {
463
+ await spawnSsh(
464
+ [
465
+ ...firstUseSshOptions(identity, candidatePath),
466
+ ...(sshPort === 22 ? [] : ["-p", String(sshPort)]),
467
+ `${sshUser}@${server.publicIp}`,
468
+ "true",
469
+ ],
470
+ spawnImpl,
471
+ "Could not authenticate the first SSH connection to the exact VPS. No runtime bootstrap was requested.",
472
+ paths.serverDirectory,
473
+ server.publicIp,
474
+ sshPort,
475
+ );
476
+ await requirePrivateFile(candidatePath, "The observed SSH host-key candidate");
477
+ const pin = validateKnownHosts(
478
+ await readFile(candidatePath, "utf8"),
479
+ server.publicIp,
480
+ sshPort,
481
+ );
482
+ const refreshedServer = await reinspectServer();
483
+ const refreshedCancellation =
484
+ ["cancellation_pending", "cancelled"].includes(refreshedServer?.state) &&
485
+ Number.isFinite(Date.parse(refreshedServer?.termEndsAt || "")) &&
486
+ Date.parse(refreshedServer.termEndsAt) > Date.now();
487
+ if (
488
+ refreshedServer?.serverId !== serverId ||
489
+ (refreshedServer.state !== "ready" && !refreshedCancellation) ||
490
+ refreshedServer.publicIp !== server.publicIp ||
491
+ refreshedServer.sshFingerprint !== server.sshFingerprint
492
+ ) {
493
+ throw new CliError(
494
+ "The server identity changed while first-use SSH trust was being established. No host pin or runtime bootstrap was created.",
495
+ { exitCode: 4, code: "host_trust_server_changed" },
496
+ );
497
+ }
498
+ await publishCandidate(
499
+ candidatePath,
500
+ paths.knownHostsFile,
501
+ pin.content,
502
+ server.publicIp,
503
+ sshPort,
504
+ );
505
+ await strictProbe({
506
+ identity,
507
+ publicIp: server.publicIp,
508
+ sshPort,
509
+ sshUser,
510
+ knownHostsFile: paths.knownHostsFile,
511
+ spawnImpl,
512
+ });
513
+ return {
514
+ state: "trusted_first_use",
515
+ algorithm: pin.algorithm,
516
+ fingerprint: pin.fingerprint,
517
+ knownHostsFile: paths.knownHostsFile,
518
+ publicIp: server.publicIp,
519
+ trustEpoch,
520
+ };
521
+ } finally {
522
+ await rm(candidatePath, { force: true });
523
+ }
524
+ }
package/src/installer.js CHANGED
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { join, resolve } from "node:path";
11
11
 
12
12
  import { CliError } from "./errors.js";
13
+ import { strictSshOptions } from "./host-trust.js";
13
14
 
14
15
  const MAX_ARTIFACT_BYTES = 256 * 1024 * 1024;
15
16
  const BASE_REQUIRED_FILES = [
@@ -323,7 +324,7 @@ async function spawnChecked(
323
324
  return result;
324
325
  }
325
326
 
326
- function sshBase(identity, sshUser, host) {
327
+ function sshBase(identity, sshUser, host, knownHostsFile) {
327
328
  if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(sshUser)) {
328
329
  throw new CliError("--ssh-user is invalid.", { exitCode: 2 });
329
330
  }
@@ -333,16 +334,7 @@ function sshBase(identity, sshUser, host) {
333
334
  });
334
335
  }
335
336
  return [
336
- "-i",
337
- resolve(identity),
338
- "-o",
339
- "IdentitiesOnly=yes",
340
- "-o",
341
- "StrictHostKeyChecking=yes",
342
- "-o",
343
- "ForwardAgent=no",
344
- "-o",
345
- "ClearAllForwardings=yes",
337
+ ...strictSshOptions(resolve(identity), knownHostsFile),
346
338
  `${sshUser}@${host}`,
347
339
  ];
348
340
  }
@@ -353,6 +345,8 @@ export async function installRuntime({
353
345
  token,
354
346
  identity,
355
347
  sshUser,
348
+ knownHostsFile,
349
+ trustedPublicIp,
356
350
  bootstrap,
357
351
  nestedPrivateProcfs = "preserve",
358
352
  fetchImpl = globalThis.fetch,
@@ -367,6 +361,12 @@ export async function installRuntime({
367
361
  exitCode: 5,
368
362
  });
369
363
  }
364
+ if (server.publicIp !== trustedPublicIp) {
365
+ throw new CliError(
366
+ "The server public IP changed after SSH host-key trust was established.",
367
+ { exitCode: 4, code: "host_trust_ip_changed" },
368
+ );
369
+ }
370
370
  const metadata = validateArtifact(bootstrap?.artifact);
371
371
  if (!new Set(["preserve", "enable", "disable"]).has(nestedPrivateProcfs)) {
372
372
  throw new CliError("The nested private procfs action is invalid.", {
@@ -400,7 +400,7 @@ export async function installRuntime({
400
400
  const remoteID = randomUUID().replaceAll("-", "");
401
401
  const remoteArchive = `/tmp/warpmetal-runtime-${remoteID}.tar.gz`;
402
402
  const remoteBundle = `/tmp/warpmetal-runtime-${remoteID}`;
403
- const ssh = sshBase(identity, sshUser, server.publicIp);
403
+ const ssh = sshBase(identity, sshUser, server.publicIp, knownHostsFile);
404
404
  let remoteTouched = false;
405
405
  let operationFailed = false;
406
406
  try {
package/src/state.js CHANGED
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
 
6
6
  import { CliError } from "./errors.js";
7
7
 
8
- const STATE_VERSION = 3;
8
+ const STATE_VERSION = 4;
9
9
  const WALLET_PAYMENT_STATE_FIELDS = [
10
10
  "walletPaymentAttemptId",
11
11
  "walletBuyerPaymentIdentifier",
@@ -160,6 +160,19 @@ function migrateState(value) {
160
160
  renewals: {},
161
161
  };
162
162
  }
163
+ if (
164
+ value?.version === 3 &&
165
+ value.orders &&
166
+ value.servers &&
167
+ value.operations &&
168
+ value.runtimes &&
169
+ value.sandboxes &&
170
+ value.accessGrants &&
171
+ value.identities &&
172
+ value.renewals
173
+ ) {
174
+ return { ...value, version: STATE_VERSION };
175
+ }
163
176
  return value;
164
177
  }
165
178
 
@@ -597,6 +610,46 @@ export class StateStore {
597
610
  ...metadata,
598
611
  createdAt: new Date().toISOString(),
599
612
  };
613
+ if (kind === "reload") {
614
+ const server = state.servers[serverId] || { serverId };
615
+ server.pendingHostTrustReloadOperationId = operationId;
616
+ state.servers[serverId] = server;
617
+ }
618
+ });
619
+ }
620
+
621
+ async hostTrustEpoch(serverId) {
622
+ return (await this.server(serverId))?.hostTrustEpoch || "initial";
623
+ }
624
+
625
+ async applyReloadHostTrust(serverId, operation) {
626
+ const operationId = operation?.id;
627
+ if (
628
+ typeof operationId !== "string" ||
629
+ !["succeeded", "failed", "manual_review"].includes(operation?.state)
630
+ ) {
631
+ return;
632
+ }
633
+ await this.update((state) => {
634
+ const saved = state.operations[operationId];
635
+ const server = state.servers[serverId];
636
+ if (
637
+ !saved ||
638
+ saved.serverId !== serverId ||
639
+ saved.kind !== "reload" ||
640
+ !server ||
641
+ server.pendingHostTrustReloadOperationId !== operationId
642
+ ) {
643
+ return;
644
+ }
645
+ if (
646
+ operation.state === "succeeded" &&
647
+ operation.result?.reloadImpact?.ownerKnownHostsNeedRefresh === true
648
+ ) {
649
+ server.hostTrustEpoch = `reload-${operationId}`;
650
+ server.hostTrustReloadOperationId = operationId;
651
+ }
652
+ delete server.pendingHostTrustReloadOperationId;
600
653
  });
601
654
  }
602
655