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 +57 -6
- package/package.json +2 -2
- package/skills/warpmetal/SKILL.md +25 -5
- package/skills/warpmetal/references/cli-reference.md +22 -1
- package/skills/warpmetal/references/runtime.md +49 -3
- package/skills/warpmetal/references/safety.md +12 -3
- package/src/cli.js +61 -5
- package/src/host-trust.js +524 -0
- package/src/installer.js +149 -32
- package/src/state.js +54 -1
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
|
|
304
|
-
|
|
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.
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
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.
|
|
270
|
-
only
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
103
|
-
|
|
104
|
-
|
|
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?.
|
|
2046
|
-
?
|
|
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
|
-
? "
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|