warpmetal 0.8.7 → 0.8.9

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.
@@ -143,11 +156,15 @@ receipt finality while provisioning or renewal proceeds.
143
156
 
144
157
  For an interactive initial purchase, `checkout challenge` may also return a
145
158
  short-lived `humanCheckout` object. Its `url` and `qrPayload` are the same
146
- `https://pay.x402api.com/c/...` bearer capability for the exact charge; encode
147
- that URL as the QR, never the WarpMetal recipient address. The buyer connects
148
- their own wallet and needs only the advertised USDC/USDT balance because
149
- x402api sponsors the native gas. This is an alternative to the agent-wallet
150
- workflow, not a second payment. After browser payment, run the exact
159
+ `https://pay.x402api.com/c/...` bearer capability for the exact charge.
160
+ Present the exact URL as a clickable x402api handoff. If a QR is useful for
161
+ another device, encode only the identical `qrPayload` and explain that
162
+ scanning opens the hosted checkout without authorizing payment. x402api owns
163
+ any wallet selection or wallet-specific opening QR inside that page; never
164
+ synthesize a wallet link or encode the WarpMetal recipient address. The buyer
165
+ needs only the advertised USDC/USDT balance because x402api sponsors the native
166
+ gas. This is an alternative to the agent-wallet workflow, not a second
167
+ payment. After browser payment, run the exact
151
168
  `humanCheckout.afterPayment.argv` status command. When the ready result asks
152
169
  for `ask_human_for_notification_email`, ask the owner and add the optional
153
170
  lifecycle-notification address they provide. Autonomous purchases and renewals
@@ -300,8 +317,9 @@ compatible external signer.
300
317
  Wallet key management and signing remain outside this package.
301
318
  - Destructive or state-changing commands require explicit confirmations and
302
319
  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.
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.
305
323
  - Each agent gets a distinct SSH key forced into exactly one sandbox. Token-free
306
324
  connection profiles pin the VPS host key and contain no owner credential or
307
325
  private-key material.
@@ -315,10 +333,10 @@ compatible external signer.
315
333
  the observed digest and generation both match the accepted target.
316
334
  - Guarded reload powers the server off first. Runtime-enabled reload requires a
317
335
  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.
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.
322
340
 
323
341
  ## Agent Runtime example
324
342
 
@@ -349,6 +367,13 @@ warpmetal sandbox access keygen \
349
367
  --json
350
368
  ```
351
369
 
370
+ On the first install in a server trust epoch, JSON output includes
371
+ `hostKeyTrust.state: "trusted_first_use"` and the safe Ed25519 fingerprint.
372
+ Later installs report `"matched"`. First-use trust protects continuity after
373
+ that observation, but it cannot detect an active attacker on the first
374
+ connection. Provider-console host-key pre-enrollment remains an optional
375
+ higher-assurance alternative when it is available.
376
+
352
377
  WarpMetal CLI v0.8.7 with Agent Runtime v0.1.25 or newer can enable the narrowly
353
378
  scoped AppArmor exception required by a verified workload that creates an inner
354
379
  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.9",
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"
@@ -90,11 +90,14 @@ open either file.
90
90
 
91
91
  In an interactive initial purchase, the result may also contain
92
92
  `humanCheckout`. Offer it as an alternative to the local agent wallet. Show
93
- `humanCheckout.url` as clickable text and render only the identical
94
- `humanCheckout.qrPayload` URL as the purchase QR; never render the recipient
95
- address as this QR. The URL is an expiring bearer capability, so do not log,
96
- save, or send it anywhere except to the buyer who requested this purchase. If
97
- the buyer uses it, do not authorize or submit through the agent wallet. Run the
93
+ `humanCheckout.url` as clickable x402api text. If another-device acquisition
94
+ is useful, render only the identical `humanCheckout.qrPayload` URL as a QR and
95
+ explain that scanning opens the hosted checkout without authorizing payment.
96
+ x402api owns any wallet selection and wallet-specific opening QR inside that
97
+ page; never synthesize a wallet link or render the recipient address as this
98
+ QR. The URL is an expiring bearer capability, so do not log, save, or send it
99
+ anywhere except to the buyer who requested this purchase. If the buyer uses it,
100
+ do not authorize or submit through the agent wallet. Run the
98
101
  exact `humanCheckout.afterPayment.argv` command, wait for the server to become
99
102
  ready, then follow `ask_human_for_notification_email`: ask the owner for the
100
103
  optional lifecycle-notification address and add only the address they provide.
@@ -266,11 +269,13 @@ warpmetal server reload \
266
269
  ```
267
270
 
268
271
  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
272
+ reload revokes SSH-derived access tokens. CLI 0.8.8 records a new SSH trust
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;
277
+ 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
274
279
  every connection profile with `sandbox access refresh --confirm REFRESH`
275
280
  before connecting. Do not fall back to raw API calls for deletion, networking,
276
281
  or another unsupported mutation.
@@ -283,6 +288,14 @@ sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
283
288
  `warpmetal runtime enable` after the VPS is ready. Supervisor installation is
284
289
  separate and requires approval plus `--confirm INSTALL`.
285
290
 
291
+ With CLI 0.8.8, that confirmation also authorizes managed trust on first use
292
+ when the exact server trust epoch has no pin. The CLI performs only an owner-
293
+ key-authenticated `ssh true`, pins the first observed Ed25519 key, immediately
294
+ reconnects strictly, and requests bootstrap only afterward. Later connections
295
+ must match. Explain that TOFU cannot detect an active attacker on the first
296
+ connection; never use `ssh-keyscan`, accept a mismatch, or expose a generic pin
297
+ reset.
298
+
286
299
  For any verified workload that creates an inner Bubblewrap PID namespace and
287
300
  private `/proc`, CLI 0.8.7 with Runtime 0.1.25 or newer may add
288
301
  `--nested-private-procfs enable` to the approved Runtime install. Planning with
@@ -75,8 +75,11 @@ workflow.
75
75
  For interactive initial purchases, the response may additionally include
76
76
  `humanCheckout.url`, the identical `qrPayload`, `expiresAt`, and an exact
77
77
  `afterPayment.argv` command. The URL is a short-lived bearer capability for the
78
- same charge, not a recipient address. If the buyer pays in the hosted checkout,
79
- do not submit an agent-wallet artifact; run the returned status command and,
78
+ same charge, not a recipient address. Present the URL as a clickable x402api
79
+ handoff. An optional QR contains that exact web link for another-device
80
+ acquisition; scanning it does not authorize payment, and x402api owns any
81
+ wallet-specific choices inside the hosted page. If the buyer pays there, do not
82
+ submit an agent-wallet artifact; run the returned status command and,
80
83
  after ready, follow `ask_human_for_notification_email` to offer lifecycle
81
84
  notices. The CLI does not persist the hosted URL. Renewal commands never expose
82
85
  or use this interactive option.
@@ -232,6 +235,16 @@ warpmetal sandbox delete \
232
235
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
233
236
  ```
234
237
 
238
+ CLI 0.8.8 manages owner-facing VPS host trust during `runtime install`. With no
239
+ pin for the exact server trust epoch, the confirmed install performs one
240
+ harmless owner-key-authenticated SSH connection, trusts the first observed
241
+ Ed25519 host key, atomically pins it, and immediately reconnects strictly before
242
+ requesting bootstrap. JSON reports `hostKeyTrust.state` as
243
+ `trusted_first_use` or `matched` plus the safe fingerprint. Every later SSH and
244
+ SCP operation is strict; changed keys, malformed pins, and failed or ambiguous
245
+ reloads never replace trust. This TOFU step cannot detect an active attacker on
246
+ the first connection. Provider-console pre-enrollment is optional and stronger.
247
+
235
248
  The nested-private-procfs action requires CLI 0.8.7 and Runtime 0.1.25 or
236
249
  newer. It defaults to `preserve`. `enable` is a host-level opt-in for any
237
250
  verified workload that creates an inner Bubblewrap PID namespace and private
@@ -125,9 +125,13 @@ argv arrays returned under `paymentWorkflow`:
125
125
 
126
126
  For an interactive initial purchase, an optional `humanCheckout` object offers
127
127
  a second presentation path for the same charge. Its `url` and `qrPayload` must
128
- be identical hosted-checkout URLs; render that URL as the QR and copyable link,
129
- never a recipient address. If the buyer completes this path, skip agent-wallet
130
- authorization and run `humanCheckout.afterPayment.argv`. Continue bounded
128
+ be identical hosted-checkout URLs. Present `url` as the clickable x402api
129
+ handoff. If a QR is useful for another device, render only the exact
130
+ `qrPayload` and explain that scanning opens the checkout without authorizing
131
+ payment. x402api owns wallet selection and any wallet-specific opening QR
132
+ inside that page; never synthesize a wallet link or render a recipient address.
133
+ If the buyer completes this path, skip agent-wallet authorization and run
134
+ `humanCheckout.afterPayment.argv`. Continue bounded
131
135
  status polling until ready, then ask for the optional lifecycle-notification
132
136
  email when the result returns `ask_human_for_notification_email`. The hosted
133
137
  URL expires at `humanCheckout.expiresAt`, is not persisted by the CLI, and is
@@ -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
@@ -876,7 +887,7 @@ async function handleCheckoutChallenge(client, store, options, context) {
876
887
  stringOption(options, "request-envelope-out"),
877
888
  );
878
889
  const directCheckoutInstructions = safe.humanCheckout
879
- ? `\nDirect wallet checkout (optional, expires ${safe.humanCheckout.expiresAt}): ${safe.humanCheckout.url}\nEncode only that URL as the purchase QR. After payment, continue with: ${shellCommand(safe.humanCheckout.afterPayment.argv)}. When provisioning is ready, ask the owner for the optional lifecycle-notification email before adding it.`
890
+ ? `\nHosted human checkout (optional, expires ${safe.humanCheckout.expiresAt}): ${safe.humanCheckout.url}\nOpen this exact x402api link. If another-device acquisition is useful, encode only the returned QR URL; scanning opens the checkout and does not authorize payment. x402api owns wallet selection and explicit approval. After payment, continue with: ${shellCommand(safe.humanCheckout.afterPayment.argv)}. When provisioning is ready, ask the owner for the optional lifecycle-notification email before adding it.`
880
891
  : "";
881
892
  const paymentInstructions = safe.paymentWorkflow
882
893
  ? `\nWallet package: ${safe.paymentWorkflow.signerPackage.spec} (Node ${safe.paymentWorkflow.signerNodeRequirement})\nInstall: ${shellCommand(safe.paymentWorkflow.signerPackage.install.argv)}\nVerify: ${shellCommand(safe.paymentWorkflow.signerContract.probe.argv)}\n${walletWorkflowInstructions(safe.paymentWorkflow)}\nRequest envelope: ${safe.paymentWorkflow.requestEnvelopePath}\nAuthorize only after selecting/funding one wallet: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nSubmit with WarpMetal: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
@@ -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