warpmetal 0.8.6 → 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,43 @@ 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
+
373
+ WarpMetal CLI v0.8.7 with Agent Runtime v0.1.25 or newer can enable the narrowly
374
+ scoped AppArmor exception required by a verified workload that creates an inner
375
+ Bubblewrap PID namespace and private `/proc`:
376
+
377
+ ```sh
378
+ warpmetal runtime install \
379
+ --server <serverId> \
380
+ --ssh-user root \
381
+ --confirm INSTALL \
382
+ --nested-private-procfs enable \
383
+ --wait \
384
+ --json
385
+ ```
386
+
387
+ This is an explicit host-scoped opt-in for nested-Bubblewrap hosts, not a
388
+ requirement for ordinary VPS or Runtime workloads that use only the outer
389
+ sandbox. The default `preserve` action leaves the current policy state
390
+ unchanged. Use `disable` during an approved maintenance window to unload
391
+ WarpMetal's policy and restore the pre-install file and loaded-policy state.
392
+ Because Runtime sandboxes share one Unix owner, treat an enabled policy as
393
+ available to every sandbox on that Runtime host whose process matches the
394
+ signed, root-owned bwrap path; it is not a per-sandbox permission.
395
+
396
+ Common inner-boundary designs are planning with an exact read-only checkout,
397
+ coding with only one approved checkout and output directory writable, and QA
398
+ with an exact candidate plus isolated test processes and scratch space. The
399
+ boundary protects a persistent trusted runner from repository-controlled
400
+ commands and sibling attempts. GitHub access, installing Codex or another AI
401
+ CLI, and delegating to subagents do not by themselves require this capability.
402
+
352
403
  Installation gives pre-existing Docker containers exact liveness checks and
353
404
  tracks common container-runtime processes without collecting application
354
405
  configuration. The signed installer uses `crun` for its private rootless Podman
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warpmetal",
3
- "version": "0.8.6",
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,24 @@ 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
+
296
+ For any verified workload that creates an inner Bubblewrap PID namespace and
297
+ private `/proc`, CLI 0.8.7 with Runtime 0.1.25 or newer may add
298
+ `--nested-private-procfs enable` to the approved Runtime install. Planning with
299
+ a read-only checkout, coding in one writable checkout, and independent QA are
300
+ common uses. GitHub access, an AI CLI, or subagent delegation alone does not
301
+ require it. Omission means `preserve`, which leaves policy state unchanged;
302
+ `disable` is an explicit maintenance action. The exception is host-scoped
303
+ rather than sandbox-scoped, so separate workloads onto different VPS hosts when
304
+ they must not share it.
305
+
286
306
  Omitted lifetime means persistent. A temporary sandbox requires
287
307
  `--confirm TEMPORARY`, expires 15 minutes to 24 hours after first reaching
288
308
  running, and permanently deletes its workspace at expiry. Never describe a
@@ -212,7 +212,9 @@ warpmetal runtime enable --server <serverId> [--idempotency-key <key>] --json
212
212
  warpmetal runtime get --server <serverId> [--wait] [--timeout-seconds <n>] --json
213
213
  warpmetal runtime install \
214
214
  --server <serverId> [--identity <owner-key>] --ssh-user root \
215
- --confirm INSTALL [--wait] [--timeout-seconds <n>] --json
215
+ --confirm INSTALL \
216
+ [--nested-private-procfs <preserve|enable|disable>] \
217
+ [--wait] [--timeout-seconds <n>] --json
216
218
 
217
219
  warpmetal sandbox create \
218
220
  --server <serverId> --name <name> --size <small|medium|large|xlarge> \
@@ -230,6 +232,25 @@ warpmetal sandbox delete \
230
232
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
231
233
  ```
232
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
+
245
+ The nested-private-procfs action requires CLI 0.8.7 and Runtime 0.1.25 or
246
+ newer. It defaults to `preserve`. `enable` is a host-level opt-in for any
247
+ verified workload that creates an inner Bubblewrap PID namespace and private
248
+ `/proc`; read-only planning, single-workspace coding, and independent QA are
249
+ common examples. GitHub use, an AI CLI, or subagent delegation alone does not
250
+ require it. `disable` unloads WarpMetal's policy and restores the recorded
251
+ pre-enable state. It is not a per-sandbox capability because Runtime sandboxes
252
+ share one Unix owner.
253
+
233
254
  See [runtime.md](runtime.md) for capacity, lifetime, cleanup, polling, and
234
255
  installation safety. Exit 8 means accepted or pending, never applied.
235
256
 
@@ -58,15 +58,51 @@ warpmetal runtime install \
58
58
  --identity <owner-private-key-path> \
59
59
  --ssh-user root \
60
60
  --confirm INSTALL \
61
+ [--nested-private-procfs <preserve|enable|disable>] \
61
62
  --wait \
62
63
  --json
63
64
  warpmetal runtime get --server <serverId> --wait --json
64
65
  ```
65
66
 
66
67
  Ask before installation. Pass the owner key path without reading the file.
67
- The CLI holds the one-time bootstrap only in memory, verifies the signed
68
- artifact, uploads it through OpenSSH without a shell-enabled local spawn, and
69
- 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.
83
+
84
+ `--nested-private-procfs` is supported by `warpmetal` CLI 0.8.7 with Agent
85
+ Runtime 0.1.25 or newer. Its default is `preserve`, which makes no AppArmor
86
+ policy change. Select `enable` only when this VPS is intentionally dedicated to
87
+ a verified workload that creates an inner Bubblewrap PID namespace and private
88
+ procfs. Ordinary VPS users and Runtime workloads that rely only on the outer
89
+ sandbox do not need it. Select `disable` only during an approved maintenance
90
+ action to unload WarpMetal's policy and restore the exact file and loaded-policy
91
+ state that existed before enablement.
92
+
93
+ Examples include a planner with an exact read-only checkout, a coder with only
94
+ one approved checkout and output directory writable, and QA with an exact
95
+ candidate plus isolated test processes and scratch space. This protects a
96
+ persistent trusted runner from repository-controlled commands and sibling
97
+ attempts. GitHub access, installing an AI CLI, and subagent delegation alone do
98
+ not require nested private procfs.
99
+
100
+ This setting is host-scoped, not sandbox-scoped. Runtime sandboxes share one
101
+ Unix owner, so every sandbox on the host can use the exception only through the
102
+ signed, root-owned, fixed bwrap path matched by the policy. Enabling it does not
103
+ authorize arbitrary bwrap binaries or change the trust boundary for the
104
+ Runtime image selected by WarpMetal's authenticated backend. Isolate workloads
105
+ on separate VPS hosts if they must not share this host capability.
70
106
 
71
107
  The signed installer is designed to preserve container workloads already
72
108
  running on a supported host. It selects `crun` for WarpMetal's private rootless
@@ -92,6 +128,16 @@ host automatically:
92
128
  unexpectedly; stop and review the host package logs;
93
129
  - `runtime_legacy_migration_required`: preview Podman state needs a separate,
94
130
  explicitly reviewed migration and was not reset.
131
+ - `runtime_nested_private_procfs_architecture_unsupported`: the requested
132
+ coding-host policy is unsupported by this CPU architecture;
133
+ - `runtime_apparmor_state_unverifiable`: the host's current AppArmor state
134
+ cannot be established safely;
135
+ - `runtime_apparmor_policy_conflict`: an existing file or loaded-policy state
136
+ conflicts with WarpMetal's recorded transaction;
137
+ - `runtime_apparmor_policy_rollback_failed`: the installer could not restore
138
+ the exact prior AppArmor state; stop and perform operator recovery.
139
+ - `runtime_apparmor_policy_recovery_failed`: an interrupted prior policy
140
+ transaction could not be recovered; stop and perform operator recovery.
95
141
 
96
142
  There is no force bypass. If `runtime_reboot_required` is returned, schedule
97
143
  the reboot as a separate maintenance action and retry only after the host and
@@ -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 {
@@ -110,7 +111,7 @@ Usage:
110
111
  warpmetal operation get --operation <operationId> [--server <serverId>] [--wait]
111
112
  warpmetal runtime enable|get --server <serverId> [--wait]
112
113
  warpmetal runtime install --server <serverId> [--identity <owner-key>] --ssh-user <user>
113
- --confirm INSTALL [--wait]
114
+ --confirm INSTALL [--nested-private-procfs <preserve|enable|disable>] [--wait]
114
115
  warpmetal sandbox create --server <serverId> --name <name> --size <size>
115
116
  [--lifetime temporary] [--expires-in-seconds <n>] [--confirm TEMPORARY] [--wait]
116
117
  warpmetal sandbox create --server <serverId> --file <batch.json> [--confirm TEMPORARY]
@@ -132,6 +133,26 @@ Global options:
132
133
  --help Show help
133
134
  --version Show the CLI version
134
135
 
136
+ Nested private procfs (CLI 0.8.7+, Runtime 0.1.25+):
137
+ preserve Default; do not inspect or change AppArmor policy state
138
+ enable Enable the exact-path Bubblewrap policy on an amd64 host
139
+ disable Remove it and restore the recorded pre-enable policy state
140
+
141
+ Use enable once per dedicated Runtime host when a verified workload creates an
142
+ inner Bubblewrap PID namespace and private /proc. Planning, coding, and QA are
143
+ common examples. GitHub access, an AI CLI, and subagent delegation alone do not
144
+ require it. The capability is host-scoped, not per-sandbox.
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
+
135
156
  Credential environment variables:
136
157
  WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
137
158
  WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
@@ -1929,6 +1950,7 @@ async function applyReloadResult(
1929
1950
  operation,
1930
1951
  pendingIdentityId,
1931
1952
  ) {
1953
+ await store.applyReloadHostTrust(serverId, operation);
1932
1954
  if (operation?.state !== "succeeded") return;
1933
1955
  if (pendingIdentityId) {
1934
1956
  await store.bindIdentity(pendingIdentityId, undefined, serverId);
@@ -2042,10 +2064,12 @@ async function handleServerReload(client, store, options, context) {
2042
2064
  context.json,
2043
2065
  `Reload operation ${operationId}: ${operation?.state}.` +
2044
2066
  (operation?.state === "succeeded" &&
2045
- operation.result?.reloadImpact?.agentRuntimeAffected
2046
- ? " 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."
2047
2071
  : operation?.state === "succeeded"
2048
- ? " 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."
2049
2073
  : ""),
2050
2074
  );
2051
2075
  if (operation?.state === "manual_review") return 6;
@@ -2140,6 +2164,14 @@ async function handleRuntimeInstall(client, store, options, context) {
2140
2164
  stringOption(options, "identity"),
2141
2165
  );
2142
2166
  const sshUser = stringOption(options, "ssh-user", { required: true });
2167
+ const nestedPrivateProcfs =
2168
+ stringOption(options, "nested-private-procfs") || "preserve";
2169
+ if (!["preserve", "enable", "disable"].includes(nestedPrivateProcfs)) {
2170
+ throw new CliError(
2171
+ "--nested-private-procfs must be preserve, enable, or disable.",
2172
+ { exitCode: 2 },
2173
+ );
2174
+ }
2143
2175
  if (stringOption(options, "confirm", { required: true }) !== "INSTALL") {
2144
2176
  throw new CliError(
2145
2177
  "Confirm supervisor installation with --confirm INSTALL.",
@@ -2152,6 +2184,20 @@ async function handleRuntimeInstall(client, store, options, context) {
2152
2184
  serverId,
2153
2185
  identity,
2154
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
+ });
2155
2201
  const key =
2156
2202
  stringOption(options, "idempotency-key") ||
2157
2203
  idempotencyKey("runtime-bootstrap");
@@ -2166,10 +2212,19 @@ async function handleRuntimeInstall(client, store, options, context) {
2166
2212
  token: issued.accessToken,
2167
2213
  identity,
2168
2214
  sshUser,
2215
+ knownHostsFile: hostKeyTrust.knownHostsFile,
2216
+ trustedPublicIp: hostKeyTrust.publicIp,
2169
2217
  bootstrap: bootstrap.data,
2218
+ nestedPrivateProcfs,
2170
2219
  fetchImpl: context.fetchImpl,
2171
2220
  spawnImpl: context.spawnImpl,
2172
2221
  });
2222
+ safe.hostKeyTrust = {
2223
+ state: hostKeyTrust.state,
2224
+ algorithm: hostKeyTrust.algorithm,
2225
+ fingerprint: hostKeyTrust.fingerprint,
2226
+ trustEpoch: hostKeyTrust.trustEpoch,
2227
+ };
2173
2228
  let runtimeResult;
2174
2229
  if (booleanOption(options, "wait")) {
2175
2230
  runtimeResult = await pollRuntime(
@@ -2185,7 +2240,7 @@ async function handleRuntimeInstall(client, store, options, context) {
2185
2240
  context.stdout,
2186
2241
  safe,
2187
2242
  context.json,
2188
- `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}).`,
2189
2244
  );
2190
2245
  return safe.runtime?.state === "degraded" ? 5 : 0;
2191
2246
  }
@@ -2975,6 +3030,7 @@ async function dispatch(positionals, options, passthrough, context) {
2975
3030
  "identity",
2976
3031
  "ssh-user",
2977
3032
  "confirm",
3033
+ "nested-private-procfs",
2978
3034
  "idempotency-key",
2979
3035
  "wait",
2980
3036
  "timeout-seconds",
@@ -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
@@ -5,14 +5,15 @@ import {
5
5
  verify as verifySignature,
6
6
  } from "node:crypto";
7
7
  import { spawn as nodeSpawn } from "node:child_process";
8
- import { access, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
8
+ import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
9
9
  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
- const REQUIRED_FILES = [
16
+ const BASE_REQUIRED_FILES = [
16
17
  "install.sh",
17
18
  "warpmetal-agentctl",
18
19
  "warpmetal-sandbox-gateway",
@@ -24,6 +25,26 @@ const REQUIRED_FILES = [
24
25
  "warpmetal-sandbox.conf",
25
26
  ];
26
27
 
28
+ const NESTED_PRIVATE_PROCFS_FILES = [
29
+ "nested-private-procfs-oracle.sh",
30
+ "warpmetal-agent-runtime-bwrap",
31
+ "warpmetal-apparmor-policy.sh",
32
+ "warpmetal-policy-metadata",
33
+ ];
34
+
35
+ function supportsNestedPrivateProcfs(version) {
36
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
37
+ if (!match) return false;
38
+ const [, major, minor, patch] = match.map(Number);
39
+ return major > 0 || minor > 1 || (minor === 1 && patch >= 25);
40
+ }
41
+
42
+ function requiredFiles(version) {
43
+ return supportsNestedPrivateProcfs(version)
44
+ ? [...BASE_REQUIRED_FILES, ...NESTED_PRIVATE_PROCFS_FILES]
45
+ : BASE_REQUIRED_FILES;
46
+ }
47
+
27
48
  const INSTALLER_ERROR_MESSAGES = new Map([
28
49
  [
29
50
  "runtime_install_lock_unavailable",
@@ -77,12 +98,75 @@ const INSTALLER_ERROR_MESSAGES = new Map([
77
98
  "runtime_legacy_migration_required",
78
99
  "Existing preview Agent Runtime state requires a separately reviewed migration; it was not reset.",
79
100
  ],
101
+ [
102
+ "runtime_nested_private_procfs_architecture_unsupported",
103
+ "Nested private procfs is unavailable on this host architecture.",
104
+ ],
105
+ [
106
+ "runtime_nested_private_procfs_mode_invalid",
107
+ "The Agent Runtime installer rejected the nested-private-procfs action.",
108
+ ],
109
+ [
110
+ "runtime_apparmor_policy_bundle_invalid",
111
+ "The signed Runtime bundle does not contain a valid nested-private-procfs policy set.",
112
+ ],
113
+ [
114
+ "runtime_apparmor_state_unverifiable",
115
+ "The server's AppArmor state could not be verified safely.",
116
+ ],
117
+ [
118
+ "runtime_apparmor_policy_unsupported",
119
+ "The server cannot safely enable the nested-private-procfs AppArmor policy.",
120
+ ],
121
+ [
122
+ "runtime_apparmor_policy_backup_failed",
123
+ "The existing AppArmor policy state could not be preserved exactly.",
124
+ ],
125
+ [
126
+ "runtime_apparmor_policy_conflict",
127
+ "The server has a conflicting nested-private-procfs AppArmor policy state.",
128
+ ],
129
+ [
130
+ "runtime_apparmor_policy_parse_failed",
131
+ "The signed nested-private-procfs AppArmor policy did not pass host validation.",
132
+ ],
133
+ [
134
+ "runtime_apparmor_policy_install_failed",
135
+ "The nested-private-procfs AppArmor policy could not be installed safely.",
136
+ ],
137
+ [
138
+ "runtime_apparmor_policy_load_failed",
139
+ "The nested-private-procfs AppArmor policy could not be loaded and verified.",
140
+ ],
141
+ [
142
+ "runtime_apparmor_policy_disable_failed",
143
+ "The nested-private-procfs AppArmor policy could not be disabled and restored safely.",
144
+ ],
145
+ [
146
+ "runtime_apparmor_policy_rollback_failed",
147
+ "The AppArmor policy transaction could not restore its prior state; operator recovery is required.",
148
+ ],
149
+ [
150
+ "runtime_apparmor_policy_recovery_failed",
151
+ "A prior interrupted AppArmor policy transaction could not be recovered; operator recovery is required.",
152
+ ],
80
153
  ]);
81
154
 
155
+ const RECOVERY_ERROR_PRIORITY = [
156
+ "runtime_apparmor_policy_rollback_failed",
157
+ "runtime_apparmor_policy_recovery_failed",
158
+ ];
159
+
82
160
  function mappedInstallerError(result) {
83
161
  const lines = `${result.stderr}\n${result.stdout}`
84
162
  .split(/\r?\n/)
85
163
  .map((line) => line.trim());
164
+ for (const code of RECOVERY_ERROR_PRIORITY) {
165
+ if (lines.includes(code)) {
166
+ const message = INSTALLER_ERROR_MESSAGES.get(code);
167
+ return { code, message: `${message} (${code})` };
168
+ }
169
+ }
86
170
  for (const [code, message] of INSTALLER_ERROR_MESSAGES) {
87
171
  if (lines.includes(code)) return { code, message: `${message} (${code})` };
88
172
  }
@@ -240,7 +324,7 @@ async function spawnChecked(
240
324
  return result;
241
325
  }
242
326
 
243
- function sshBase(identity, sshUser, host) {
327
+ function sshBase(identity, sshUser, host, knownHostsFile) {
244
328
  if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(sshUser)) {
245
329
  throw new CliError("--ssh-user is invalid.", { exitCode: 2 });
246
330
  }
@@ -250,16 +334,7 @@ function sshBase(identity, sshUser, host) {
250
334
  });
251
335
  }
252
336
  return [
253
- "-i",
254
- resolve(identity),
255
- "-o",
256
- "IdentitiesOnly=yes",
257
- "-o",
258
- "StrictHostKeyChecking=yes",
259
- "-o",
260
- "ForwardAgent=no",
261
- "-o",
262
- "ClearAllForwardings=yes",
337
+ ...strictSshOptions(resolve(identity), knownHostsFile),
263
338
  `${sshUser}@${host}`,
264
339
  ];
265
340
  }
@@ -270,7 +345,10 @@ export async function installRuntime({
270
345
  token,
271
346
  identity,
272
347
  sshUser,
348
+ knownHostsFile,
349
+ trustedPublicIp,
273
350
  bootstrap,
351
+ nestedPrivateProcfs = "preserve",
274
352
  fetchImpl = globalThis.fetch,
275
353
  spawnImpl = nodeSpawn,
276
354
  }) {
@@ -283,7 +361,27 @@ export async function installRuntime({
283
361
  exitCode: 5,
284
362
  });
285
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
+ }
286
370
  const metadata = validateArtifact(bootstrap?.artifact);
371
+ if (!new Set(["preserve", "enable", "disable"]).has(nestedPrivateProcfs)) {
372
+ throw new CliError("The nested private procfs action is invalid.", {
373
+ exitCode: 2,
374
+ });
375
+ }
376
+ if (
377
+ nestedPrivateProcfs !== "preserve" &&
378
+ !supportsNestedPrivateProcfs(metadata.version)
379
+ ) {
380
+ throw new CliError(
381
+ `Agent Runtime ${metadata.version} does not support nested private procfs actions.`,
382
+ { exitCode: 4 },
383
+ );
384
+ }
287
385
  const bootstrapToken = bootstrap?.bootstrapToken;
288
386
  if (
289
387
  typeof bootstrapToken !== "string" ||
@@ -302,7 +400,7 @@ export async function installRuntime({
302
400
  const remoteID = randomUUID().replaceAll("-", "");
303
401
  const remoteArchive = `/tmp/warpmetal-runtime-${remoteID}.tar.gz`;
304
402
  const remoteBundle = `/tmp/warpmetal-runtime-${remoteID}`;
305
- const ssh = sshBase(identity, sshUser, server.publicIp);
403
+ const ssh = sshBase(identity, sshUser, server.publicIp, knownHostsFile);
306
404
  let remoteTouched = false;
307
405
  let operationFailed = false;
308
406
  try {
@@ -316,16 +414,26 @@ export async function installRuntime({
316
414
  spawnImpl,
317
415
  },
318
416
  );
319
- for (const file of REQUIRED_FILES) await access(join(extractPath, file));
320
- const unexpected = (await readdir(extractPath)).filter(
321
- (name) => !REQUIRED_FILES.includes(name),
322
- );
323
- if (unexpected.length > 0) {
417
+ const expectedFiles = requiredFiles(metadata.version);
418
+ const actualFiles = await readdir(extractPath);
419
+ const exactNames =
420
+ actualFiles.length === expectedFiles.length &&
421
+ expectedFiles.every((name) => actualFiles.includes(name));
422
+ if (!exactNames) {
324
423
  throw new CliError(
325
- "The signed runtime bundle contains unsupported files.",
424
+ "The signed runtime bundle files do not match this CLI version.",
326
425
  { exitCode: 4 },
327
426
  );
328
427
  }
428
+ for (const file of expectedFiles) {
429
+ const metadata = await lstat(join(extractPath, file));
430
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
431
+ throw new CliError(
432
+ "The signed runtime bundle files do not match this CLI version.",
433
+ { exitCode: 4 },
434
+ );
435
+ }
436
+ }
329
437
  remoteTouched = true;
330
438
  await spawnChecked(
331
439
  "scp",
@@ -351,19 +459,23 @@ export async function installRuntime({
351
459
  ],
352
460
  { spawnImpl },
353
461
  );
462
+ const installerArguments = [
463
+ ...ssh,
464
+ "sudo",
465
+ `${remoteBundle}/install.sh`,
466
+ "--api",
467
+ client.baseUrl,
468
+ "--server",
469
+ serverId,
470
+ "--bundle",
471
+ remoteBundle,
472
+ ];
473
+ if (nestedPrivateProcfs !== "preserve") {
474
+ installerArguments.push("--nested-private-procfs", nestedPrivateProcfs);
475
+ }
354
476
  await spawnChecked(
355
477
  "ssh",
356
- [
357
- ...ssh,
358
- "sudo",
359
- `${remoteBundle}/install.sh`,
360
- "--api",
361
- client.baseUrl,
362
- "--server",
363
- serverId,
364
- "--bundle",
365
- remoteBundle,
366
- ],
478
+ installerArguments,
367
479
  {
368
480
  stdin: `${bootstrapToken}\n`,
369
481
  spawnImpl,
@@ -378,7 +490,12 @@ export async function installRuntime({
378
490
  spawnImpl,
379
491
  },
380
492
  );
381
- return { serverId, supervisorVersion: metadata.version, installed: true };
493
+ return {
494
+ serverId,
495
+ supervisorVersion: metadata.version,
496
+ installed: true,
497
+ nestedPrivateProcfsAction: nestedPrivateProcfs,
498
+ };
382
499
  } catch (error) {
383
500
  operationFailed = true;
384
501
  throw error;
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