warpmetal 0.8.5 → 0.8.7

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
@@ -141,6 +141,18 @@ confirmation is pending. As soon as `confirmed` is true, stop all payment
141
141
  submission—even when `finalized` is false—and let WarpMetal continue signed-
142
142
  receipt finality while provisioning or renewal proceeds.
143
143
 
144
+ For an interactive initial purchase, `checkout challenge` may also return a
145
+ short-lived `humanCheckout` object. Its `url` and `qrPayload` are the same
146
+ `https://pay.x402api.com/c/...` bearer capability for the exact charge; encode
147
+ that URL as the QR, never the WarpMetal recipient address. The buyer connects
148
+ their own wallet and needs only the advertised USDC/USDT balance because
149
+ x402api sponsors the native gas. This is an alternative to the agent-wallet
150
+ workflow, not a second payment. After browser payment, run the exact
151
+ `humanCheckout.afterPayment.argv` status command. When the ready result asks
152
+ for `ask_human_for_notification_email`, ask the owner and add the optional
153
+ lifecycle-notification address they provide. Autonomous purchases and renewals
154
+ continue to use the bounded agent-wallet path.
155
+
144
156
  - When a human is actively chatting with the agent, show the exact live terms
145
157
  and ask for confirmation immediately before authorizing and submitting.
146
158
  - In an unattended run, a pre-funded dedicated wallet is standing spend
@@ -337,6 +349,36 @@ warpmetal sandbox access keygen \
337
349
  --json
338
350
  ```
339
351
 
352
+ WarpMetal CLI v0.8.7 with Agent Runtime v0.1.25 or newer can enable the narrowly
353
+ scoped AppArmor exception required by a verified workload that creates an inner
354
+ Bubblewrap PID namespace and private `/proc`:
355
+
356
+ ```sh
357
+ warpmetal runtime install \
358
+ --server <serverId> \
359
+ --ssh-user root \
360
+ --confirm INSTALL \
361
+ --nested-private-procfs enable \
362
+ --wait \
363
+ --json
364
+ ```
365
+
366
+ This is an explicit host-scoped opt-in for nested-Bubblewrap hosts, not a
367
+ requirement for ordinary VPS or Runtime workloads that use only the outer
368
+ sandbox. The default `preserve` action leaves the current policy state
369
+ unchanged. Use `disable` during an approved maintenance window to unload
370
+ WarpMetal's policy and restore the pre-install file and loaded-policy state.
371
+ Because Runtime sandboxes share one Unix owner, treat an enabled policy as
372
+ available to every sandbox on that Runtime host whose process matches the
373
+ signed, root-owned bwrap path; it is not a per-sandbox permission.
374
+
375
+ Common inner-boundary designs are planning with an exact read-only checkout,
376
+ coding with only one approved checkout and output directory writable, and QA
377
+ with an exact candidate plus isolated test processes and scratch space. The
378
+ boundary protects a persistent trusted runner from repository-controlled
379
+ commands and sibling attempts. GitHub access, installing Codex or another AI
380
+ CLI, and delegating to subagents do not by themselves require this capability.
381
+
340
382
  Installation gives pre-existing Docker containers exact liveness checks and
341
383
  tracks common container-runtime processes without collecting application
342
384
  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.5",
3
+ "version": "0.8.7",
4
4
  "description": "Agent-safe CLI and skill for purchasing, renewing, and managing WarpMetal VPS servers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -88,6 +88,18 @@ returns `paymentTerms` plus exact `paymentWorkflow.authorize.argv` and
88
88
  `paymentWorkflow.submit.argv` arrays. Do not reconstruct those commands or
89
89
  open either file.
90
90
 
91
+ In an interactive initial purchase, the result may also contain
92
+ `humanCheckout`. Offer it as an alternative to the local agent wallet. Show
93
+ `humanCheckout.url` as clickable text and render only the identical
94
+ `humanCheckout.qrPayload` URL as the purchase QR; never render the recipient
95
+ address as this QR. The URL is an expiring bearer capability, so do not log,
96
+ save, or send it anywhere except to the buyer who requested this purchase. If
97
+ the buyer uses it, do not authorize or submit through the agent wallet. Run the
98
+ exact `humanCheckout.afterPayment.argv` command, wait for the server to become
99
+ ready, then follow `ask_human_for_notification_email`: ask the owner for the
100
+ optional lifecycle-notification address and add only the address they provide.
101
+ Ignore this path in unattended purchasing and renewal automation.
102
+
91
103
  Determine payment authority from the current execution context. In an
92
104
  interactive conversation, show the human the exact amount, asset, network,
93
105
  recipient, profile, and maximum authorization lifetime from `paymentTerms`,
@@ -271,6 +283,16 @@ sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
271
283
  `warpmetal runtime enable` after the VPS is ready. Supervisor installation is
272
284
  separate and requires approval plus `--confirm INSTALL`.
273
285
 
286
+ For any verified workload that creates an inner Bubblewrap PID namespace and
287
+ private `/proc`, CLI 0.8.7 with Runtime 0.1.25 or newer may add
288
+ `--nested-private-procfs enable` to the approved Runtime install. Planning with
289
+ a read-only checkout, coding in one writable checkout, and independent QA are
290
+ common uses. GitHub access, an AI CLI, or subagent delegation alone does not
291
+ require it. Omission means `preserve`, which leaves policy state unchanged;
292
+ `disable` is an explicit maintenance action. The exception is host-scoped
293
+ rather than sandbox-scoped, so separate workloads onto different VPS hosts when
294
+ they must not share it.
295
+
274
296
  Omitted lifetime means persistent. A temporary sandbox requires
275
297
  `--confirm TEMPORARY`, expires 15 minutes to 24 hours after first reaching
276
298
  running, and permanently deletes its workspace at expiry. Never describe a
@@ -72,6 +72,15 @@ retire that attempt and return a new `paymentAttemptId`. The CLI replaces the
72
72
  saved challenge and stale wallet-attempt metadata; use only the newly returned
73
73
  workflow.
74
74
 
75
+ For interactive initial purchases, the response may additionally include
76
+ `humanCheckout.url`, the identical `qrPayload`, `expiresAt`, and an exact
77
+ `afterPayment.argv` command. The URL is a short-lived bearer capability for the
78
+ same charge, not a recipient address. If the buyer pays in the hosted checkout,
79
+ do not submit an agent-wallet artifact; run the returned status command and,
80
+ after ready, follow `ask_human_for_notification_email` to offer lifecycle
81
+ notices. The CLI does not persist the hosted URL. Renewal commands never expose
82
+ or use this interactive option.
83
+
75
84
  The current integration targets `@x402api/agent-wallet-cli@0.2.9`. A compatible
76
85
  live term is marked `agentWalletSupported: true` and must use the sponsored
77
86
  Base USDC or Solana USDC/USDT launch profile with buyer native fees disabled.
@@ -203,7 +212,9 @@ warpmetal runtime enable --server <serverId> [--idempotency-key <key>] --json
203
212
  warpmetal runtime get --server <serverId> [--wait] [--timeout-seconds <n>] --json
204
213
  warpmetal runtime install \
205
214
  --server <serverId> [--identity <owner-key>] --ssh-user root \
206
- --confirm INSTALL [--wait] [--timeout-seconds <n>] --json
215
+ --confirm INSTALL \
216
+ [--nested-private-procfs <preserve|enable|disable>] \
217
+ [--wait] [--timeout-seconds <n>] --json
207
218
 
208
219
  warpmetal sandbox create \
209
220
  --server <serverId> --name <name> --size <small|medium|large|xlarge> \
@@ -221,6 +232,15 @@ warpmetal sandbox delete \
221
232
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
222
233
  ```
223
234
 
235
+ The nested-private-procfs action requires CLI 0.8.7 and Runtime 0.1.25 or
236
+ newer. It defaults to `preserve`. `enable` is a host-level opt-in for any
237
+ verified workload that creates an inner Bubblewrap PID namespace and private
238
+ `/proc`; read-only planning, single-workspace coding, and independent QA are
239
+ common examples. GitHub use, an AI CLI, or subagent delegation alone does not
240
+ require it. `disable` unloads WarpMetal's policy and restores the recorded
241
+ pre-enable state. It is not a per-sandbox capability because Runtime sandboxes
242
+ share one Unix owner.
243
+
224
244
  See [runtime.md](runtime.md) for capacity, lifetime, cleanup, polling, and
225
245
  installation safety. Exit 8 means accepted or pending, never applied.
226
246
 
@@ -123,6 +123,16 @@ warpmetal checkout challenge --task <taskId> --json
123
123
  Read only the returned safe JSON. Confirm `paymentTerms`, then use the exact
124
124
  argv arrays returned under `paymentWorkflow`:
125
125
 
126
+ For an interactive initial purchase, an optional `humanCheckout` object offers
127
+ a second presentation path for the same charge. Its `url` and `qrPayload` must
128
+ be identical hosted-checkout URLs; render that URL as the QR and copyable link,
129
+ never a recipient address. If the buyer completes this path, skip agent-wallet
130
+ authorization and run `humanCheckout.afterPayment.argv`. Continue bounded
131
+ status polling until ready, then ask for the optional lifecycle-notification
132
+ email when the result returns `ask_human_for_notification_email`. The hosted
133
+ URL expires at `humanCheckout.expiresAt`, is not persisted by the CLI, and is
134
+ never used for unattended purchases or renewals.
135
+
126
136
  ```text
127
137
  paymentWorkflow.authorize.argv
128
138
  x402api payment authorize --wallet <wallet-name>
@@ -58,6 +58,7 @@ 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
@@ -68,6 +69,29 @@ The CLI holds the one-time bootstrap only in memory, verifies the signed
68
69
  artifact, uploads it through OpenSSH without a shell-enabled local spawn, and
69
70
  does not print or store the bootstrap.
70
71
 
72
+ `--nested-private-procfs` is supported by `warpmetal` CLI 0.8.7 with Agent
73
+ Runtime 0.1.25 or newer. Its default is `preserve`, which makes no AppArmor
74
+ policy change. Select `enable` only when this VPS is intentionally dedicated to
75
+ a verified workload that creates an inner Bubblewrap PID namespace and private
76
+ procfs. Ordinary VPS users and Runtime workloads that rely only on the outer
77
+ sandbox do not need it. Select `disable` only during an approved maintenance
78
+ action to unload WarpMetal's policy and restore the exact file and loaded-policy
79
+ state that existed before enablement.
80
+
81
+ Examples include a planner with an exact read-only checkout, a coder with only
82
+ one approved checkout and output directory writable, and QA with an exact
83
+ candidate plus isolated test processes and scratch space. This protects a
84
+ persistent trusted runner from repository-controlled commands and sibling
85
+ attempts. GitHub access, installing an AI CLI, and subagent delegation alone do
86
+ not require nested private procfs.
87
+
88
+ This setting is host-scoped, not sandbox-scoped. Runtime sandboxes share one
89
+ Unix owner, so every sandbox on the host can use the exception only through the
90
+ signed, root-owned, fixed bwrap path matched by the policy. Enabling it does not
91
+ authorize arbitrary bwrap binaries or change the trust boundary for the
92
+ Runtime image selected by WarpMetal's authenticated backend. Isolate workloads
93
+ on separate VPS hosts if they must not share this host capability.
94
+
71
95
  The signed installer is designed to preserve container workloads already
72
96
  running on a supported host. It selects `crun` for WarpMetal's private rootless
73
97
  Podman service, refuses APT removals and DNF erasures, protects installed
@@ -92,6 +116,16 @@ host automatically:
92
116
  unexpectedly; stop and review the host package logs;
93
117
  - `runtime_legacy_migration_required`: preview Podman state needs a separate,
94
118
  explicitly reviewed migration and was not reset.
119
+ - `runtime_nested_private_procfs_architecture_unsupported`: the requested
120
+ coding-host policy is unsupported by this CPU architecture;
121
+ - `runtime_apparmor_state_unverifiable`: the host's current AppArmor state
122
+ cannot be established safely;
123
+ - `runtime_apparmor_policy_conflict`: an existing file or loaded-policy state
124
+ conflicts with WarpMetal's recorded transaction;
125
+ - `runtime_apparmor_policy_rollback_failed`: the installer could not restore
126
+ the exact prior AppArmor state; stop and perform operator recovery.
127
+ - `runtime_apparmor_policy_recovery_failed`: an interrupted prior policy
128
+ transaction could not be recovered; stop and perform operator recovery.
95
129
 
96
130
  There is no force bypass. If `runtime_reboot_required` is returned, schedule
97
131
  the reboot as a separate maintenance action and retry only after the host and
package/src/cli.js CHANGED
@@ -110,7 +110,7 @@ Usage:
110
110
  warpmetal operation get --operation <operationId> [--server <serverId>] [--wait]
111
111
  warpmetal runtime enable|get --server <serverId> [--wait]
112
112
  warpmetal runtime install --server <serverId> [--identity <owner-key>] --ssh-user <user>
113
- --confirm INSTALL [--wait]
113
+ --confirm INSTALL [--nested-private-procfs <preserve|enable|disable>] [--wait]
114
114
  warpmetal sandbox create --server <serverId> --name <name> --size <size>
115
115
  [--lifetime temporary] [--expires-in-seconds <n>] [--confirm TEMPORARY] [--wait]
116
116
  warpmetal sandbox create --server <serverId> --file <batch.json> [--confirm TEMPORARY]
@@ -132,6 +132,16 @@ Global options:
132
132
  --help Show help
133
133
  --version Show the CLI version
134
134
 
135
+ Nested private procfs (CLI 0.8.7+, Runtime 0.1.25+):
136
+ preserve Default; do not inspect or change AppArmor policy state
137
+ enable Enable the exact-path Bubblewrap policy on an amd64 host
138
+ disable Remove it and restore the recorded pre-enable policy state
139
+
140
+ Use enable once per dedicated Runtime host when a verified workload creates an
141
+ inner Bubblewrap PID namespace and private /proc. Planning, coding, and QA are
142
+ common examples. GitHub access, an AI CLI, and subagent delegation alone do not
143
+ require it. The capability is host-scoped, not per-sandbox.
144
+
135
145
  Credential environment variables:
136
146
  WARPMETAL_OWNER_TOKEN Recovery/bootstrap credential for one explicit command
137
147
  WARPMETAL_ACCESS_TOKEN Short-lived SSH-derived credential for one explicit command
@@ -427,7 +437,11 @@ function challengeResult(
427
437
  taskId,
428
438
  checkoutBody,
429
439
  response,
430
- { requireChallenge = true } = {},
440
+ {
441
+ requireChallenge = true,
442
+ allowHumanCheckout = false,
443
+ humanCheckoutContext = undefined,
444
+ } = {},
431
445
  ) {
432
446
  const status = response.data?.status;
433
447
  const rejected = response.status === 402 && status === "payment_rejected";
@@ -459,6 +473,13 @@ function challengeResult(
459
473
  "WarpMetal returned PAYMENT-REQUIRED without X-X402API-Challenge-Digest.",
460
474
  );
461
475
  }
476
+ const humanCheckout = allowHumanCheckout
477
+ ? humanCheckoutResult(
478
+ response.data?.humanCheckout,
479
+ taskId,
480
+ humanCheckoutContext,
481
+ )
482
+ : undefined;
462
483
  return {
463
484
  status,
464
485
  taskId,
@@ -469,6 +490,7 @@ function challengeResult(
469
490
  challengeHandle,
470
491
  challengeDigest,
471
492
  checkoutBodySha256: createHash("sha256").update(checkoutBody).digest("hex"),
493
+ ...(humanCheckout ? { humanCheckout } : {}),
472
494
  ...(rejected
473
495
  ? {
474
496
  errorCode: response.data?.errorCode,
@@ -479,6 +501,74 @@ function challengeResult(
479
501
  };
480
502
  }
481
503
 
504
+ function humanCheckoutResult(value, taskId, { baseUrl, stateDirectory } = {}) {
505
+ if (value === undefined) return undefined;
506
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
507
+ throw new CliError("WarpMetal returned malformed hosted checkout details.");
508
+ }
509
+ const keys = Object.keys(value).sort();
510
+ if (keys.join(",") !== "expiresAt,qrPayload,url") {
511
+ throw new CliError("WarpMetal returned malformed hosted checkout details.");
512
+ }
513
+ if (
514
+ typeof value.url !== "string" ||
515
+ typeof value.qrPayload !== "string" ||
516
+ value.url !== value.qrPayload ||
517
+ typeof value.expiresAt !== "string"
518
+ ) {
519
+ throw new CliError("WarpMetal returned malformed hosted checkout details.");
520
+ }
521
+ let url;
522
+ try {
523
+ url = new URL(value.url);
524
+ } catch {
525
+ throw new CliError("WarpMetal returned an invalid hosted checkout URL.");
526
+ }
527
+ if (
528
+ url.protocol !== "https:" ||
529
+ url.origin !== "https://pay.x402api.com" ||
530
+ url.username ||
531
+ url.password ||
532
+ url.search ||
533
+ url.hash ||
534
+ !/^\/c\/chk_[A-Za-z0-9_-]{32}$/.test(url.pathname) ||
535
+ url.toString() !== value.url
536
+ ) {
537
+ throw new CliError("WarpMetal returned an invalid hosted checkout URL.");
538
+ }
539
+ const rfc3339 =
540
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
541
+ const expiresAt = Date.parse(value.expiresAt);
542
+ if (
543
+ !rfc3339.test(value.expiresAt) ||
544
+ !Number.isFinite(expiresAt) ||
545
+ expiresAt <= Date.now()
546
+ ) {
547
+ throw new CliError(
548
+ "WarpMetal returned an invalid or expired hosted checkout expiry.",
549
+ );
550
+ }
551
+ return {
552
+ url: value.url,
553
+ qrPayload: value.qrPayload,
554
+ expiresAt: value.expiresAt,
555
+ afterPayment: {
556
+ argv: [
557
+ "warpmetal",
558
+ "order",
559
+ "status",
560
+ "--task",
561
+ taskId,
562
+ "--wait",
563
+ ...(baseUrl ? ["--base-url", baseUrl] : []),
564
+ ...(stateDirectory ? ["--state-dir", stateDirectory] : []),
565
+ "--json",
566
+ ],
567
+ notificationNextAction: "ask_human_for_notification_email",
568
+ },
569
+ };
570
+ }
571
+
482
572
  async function attachPaymentWorkflow(
483
573
  client,
484
574
  store,
@@ -517,7 +607,8 @@ async function attachPaymentWorkflow(
517
607
  paymentChallengeDigest: request.challengeDigest,
518
608
  paymentWorkflow: workflow,
519
609
  });
520
- await store.savePaymentChallenge(taskId, safe);
610
+ const { humanCheckout: _ephemeralHostedCheckout, ...persisted } = safe;
611
+ await store.savePaymentChallenge(taskId, persisted);
521
612
  return safe;
522
613
  }
523
614
 
@@ -773,9 +864,20 @@ async function handleCheckoutChallenge(client, store, options, context) {
773
864
  store,
774
865
  taskId,
775
866
  order,
776
- challengeResult(taskId, order.checkoutBody, response),
867
+ challengeResult(taskId, order.checkoutBody, response, {
868
+ // Initial checkout is the only interactive direct-payment surface.
869
+ // Autonomous renewal continues to use the bounded agent-wallet policy.
870
+ allowHumanCheckout: true,
871
+ humanCheckoutContext: {
872
+ baseUrl: client.baseUrl,
873
+ stateDirectory: store.directory,
874
+ },
875
+ }),
777
876
  stringOption(options, "request-envelope-out"),
778
877
  );
878
+ const directCheckoutInstructions = safe.humanCheckout
879
+ ? `\nDirect wallet checkout (optional, expires ${safe.humanCheckout.expiresAt}): ${safe.humanCheckout.url}\nEncode only that URL as the purchase QR. After payment, continue with: ${shellCommand(safe.humanCheckout.afterPayment.argv)}. When provisioning is ready, ask the owner for the optional lifecycle-notification email before adding it.`
880
+ : "";
779
881
  const paymentInstructions = safe.paymentWorkflow
780
882
  ? `\nWallet package: ${safe.paymentWorkflow.signerPackage.spec} (Node ${safe.paymentWorkflow.signerNodeRequirement})\nInstall: ${shellCommand(safe.paymentWorkflow.signerPackage.install.argv)}\nVerify: ${shellCommand(safe.paymentWorkflow.signerContract.probe.argv)}\n${walletWorkflowInstructions(safe.paymentWorkflow)}\nRequest envelope: ${safe.paymentWorkflow.requestEnvelopePath}\nAuthorize only after selecting/funding one wallet: ${shellCommand(safe.paymentWorkflow.authorize.argv)}\nSubmit with WarpMetal: ${shellCommand(safe.paymentWorkflow.submit.argv)}`
781
883
  : "";
@@ -784,7 +886,7 @@ async function handleCheckoutChallenge(client, store, options, context) {
784
886
  safe,
785
887
  context.json,
786
888
  response.status === 402
787
- ? `Payment authorization required for ${taskId}.${paymentInstructions}`
889
+ ? `Payment authorization required for ${taskId}.${directCheckoutInstructions}${paymentInstructions}`
788
890
  : `Checkout status for ${taskId}: ${safe.status}`,
789
891
  );
790
892
  return response.status === 409 ? 6 : response.status === 402 ? 7 : 0;
@@ -2048,6 +2150,14 @@ async function handleRuntimeInstall(client, store, options, context) {
2048
2150
  stringOption(options, "identity"),
2049
2151
  );
2050
2152
  const sshUser = stringOption(options, "ssh-user", { required: true });
2153
+ const nestedPrivateProcfs =
2154
+ stringOption(options, "nested-private-procfs") || "preserve";
2155
+ if (!["preserve", "enable", "disable"].includes(nestedPrivateProcfs)) {
2156
+ throw new CliError(
2157
+ "--nested-private-procfs must be preserve, enable, or disable.",
2158
+ { exitCode: 2 },
2159
+ );
2160
+ }
2051
2161
  if (stringOption(options, "confirm", { required: true }) !== "INSTALL") {
2052
2162
  throw new CliError(
2053
2163
  "Confirm supervisor installation with --confirm INSTALL.",
@@ -2075,6 +2185,7 @@ async function handleRuntimeInstall(client, store, options, context) {
2075
2185
  identity,
2076
2186
  sshUser,
2077
2187
  bootstrap: bootstrap.data,
2188
+ nestedPrivateProcfs,
2078
2189
  fetchImpl: context.fetchImpl,
2079
2190
  spawnImpl: context.spawnImpl,
2080
2191
  });
@@ -2883,6 +2994,7 @@ async function dispatch(positionals, options, passthrough, context) {
2883
2994
  "identity",
2884
2995
  "ssh-user",
2885
2996
  "confirm",
2997
+ "nested-private-procfs",
2886
2998
  "idempotency-key",
2887
2999
  "wait",
2888
3000
  "timeout-seconds",
package/src/installer.js CHANGED
@@ -5,14 +5,14 @@ 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
13
 
14
14
  const MAX_ARTIFACT_BYTES = 256 * 1024 * 1024;
15
- const REQUIRED_FILES = [
15
+ const BASE_REQUIRED_FILES = [
16
16
  "install.sh",
17
17
  "warpmetal-agentctl",
18
18
  "warpmetal-sandbox-gateway",
@@ -24,6 +24,26 @@ const REQUIRED_FILES = [
24
24
  "warpmetal-sandbox.conf",
25
25
  ];
26
26
 
27
+ const NESTED_PRIVATE_PROCFS_FILES = [
28
+ "nested-private-procfs-oracle.sh",
29
+ "warpmetal-agent-runtime-bwrap",
30
+ "warpmetal-apparmor-policy.sh",
31
+ "warpmetal-policy-metadata",
32
+ ];
33
+
34
+ function supportsNestedPrivateProcfs(version) {
35
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
36
+ if (!match) return false;
37
+ const [, major, minor, patch] = match.map(Number);
38
+ return major > 0 || minor > 1 || (minor === 1 && patch >= 25);
39
+ }
40
+
41
+ function requiredFiles(version) {
42
+ return supportsNestedPrivateProcfs(version)
43
+ ? [...BASE_REQUIRED_FILES, ...NESTED_PRIVATE_PROCFS_FILES]
44
+ : BASE_REQUIRED_FILES;
45
+ }
46
+
27
47
  const INSTALLER_ERROR_MESSAGES = new Map([
28
48
  [
29
49
  "runtime_install_lock_unavailable",
@@ -77,12 +97,75 @@ const INSTALLER_ERROR_MESSAGES = new Map([
77
97
  "runtime_legacy_migration_required",
78
98
  "Existing preview Agent Runtime state requires a separately reviewed migration; it was not reset.",
79
99
  ],
100
+ [
101
+ "runtime_nested_private_procfs_architecture_unsupported",
102
+ "Nested private procfs is unavailable on this host architecture.",
103
+ ],
104
+ [
105
+ "runtime_nested_private_procfs_mode_invalid",
106
+ "The Agent Runtime installer rejected the nested-private-procfs action.",
107
+ ],
108
+ [
109
+ "runtime_apparmor_policy_bundle_invalid",
110
+ "The signed Runtime bundle does not contain a valid nested-private-procfs policy set.",
111
+ ],
112
+ [
113
+ "runtime_apparmor_state_unverifiable",
114
+ "The server's AppArmor state could not be verified safely.",
115
+ ],
116
+ [
117
+ "runtime_apparmor_policy_unsupported",
118
+ "The server cannot safely enable the nested-private-procfs AppArmor policy.",
119
+ ],
120
+ [
121
+ "runtime_apparmor_policy_backup_failed",
122
+ "The existing AppArmor policy state could not be preserved exactly.",
123
+ ],
124
+ [
125
+ "runtime_apparmor_policy_conflict",
126
+ "The server has a conflicting nested-private-procfs AppArmor policy state.",
127
+ ],
128
+ [
129
+ "runtime_apparmor_policy_parse_failed",
130
+ "The signed nested-private-procfs AppArmor policy did not pass host validation.",
131
+ ],
132
+ [
133
+ "runtime_apparmor_policy_install_failed",
134
+ "The nested-private-procfs AppArmor policy could not be installed safely.",
135
+ ],
136
+ [
137
+ "runtime_apparmor_policy_load_failed",
138
+ "The nested-private-procfs AppArmor policy could not be loaded and verified.",
139
+ ],
140
+ [
141
+ "runtime_apparmor_policy_disable_failed",
142
+ "The nested-private-procfs AppArmor policy could not be disabled and restored safely.",
143
+ ],
144
+ [
145
+ "runtime_apparmor_policy_rollback_failed",
146
+ "The AppArmor policy transaction could not restore its prior state; operator recovery is required.",
147
+ ],
148
+ [
149
+ "runtime_apparmor_policy_recovery_failed",
150
+ "A prior interrupted AppArmor policy transaction could not be recovered; operator recovery is required.",
151
+ ],
80
152
  ]);
81
153
 
154
+ const RECOVERY_ERROR_PRIORITY = [
155
+ "runtime_apparmor_policy_rollback_failed",
156
+ "runtime_apparmor_policy_recovery_failed",
157
+ ];
158
+
82
159
  function mappedInstallerError(result) {
83
160
  const lines = `${result.stderr}\n${result.stdout}`
84
161
  .split(/\r?\n/)
85
162
  .map((line) => line.trim());
163
+ for (const code of RECOVERY_ERROR_PRIORITY) {
164
+ if (lines.includes(code)) {
165
+ const message = INSTALLER_ERROR_MESSAGES.get(code);
166
+ return { code, message: `${message} (${code})` };
167
+ }
168
+ }
86
169
  for (const [code, message] of INSTALLER_ERROR_MESSAGES) {
87
170
  if (lines.includes(code)) return { code, message: `${message} (${code})` };
88
171
  }
@@ -271,6 +354,7 @@ export async function installRuntime({
271
354
  identity,
272
355
  sshUser,
273
356
  bootstrap,
357
+ nestedPrivateProcfs = "preserve",
274
358
  fetchImpl = globalThis.fetch,
275
359
  spawnImpl = nodeSpawn,
276
360
  }) {
@@ -284,6 +368,20 @@ export async function installRuntime({
284
368
  });
285
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" ||
@@ -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;