warpmetal 0.8.6 → 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
@@ -349,6 +349,36 @@ warpmetal sandbox access keygen \
349
349
  --json
350
350
  ```
351
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
+
352
382
  Installation gives pre-existing Docker containers exact liveness checks and
353
383
  tracks common container-runtime processes without collecting application
354
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.6",
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": {
@@ -283,6 +283,16 @@ sizes. Use `--runtime-file` to include sandbox intent in an unpaid order, or
283
283
  `warpmetal runtime enable` after the VPS is ready. Supervisor installation is
284
284
  separate and requires approval plus `--confirm INSTALL`.
285
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
+
286
296
  Omitted lifetime means persistent. A temporary sandbox requires
287
297
  `--confirm TEMPORARY`, expires 15 minutes to 24 hours after first reaching
288
298
  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,15 @@ warpmetal sandbox delete \
230
232
  --server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
231
233
  ```
232
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
+
233
244
  See [runtime.md](runtime.md) for capacity, lifetime, cleanup, polling, and
234
245
  installation safety. Exit 8 means accepted or pending, never applied.
235
246
 
@@ -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
@@ -2140,6 +2150,14 @@ async function handleRuntimeInstall(client, store, options, context) {
2140
2150
  stringOption(options, "identity"),
2141
2151
  );
2142
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
+ }
2143
2161
  if (stringOption(options, "confirm", { required: true }) !== "INSTALL") {
2144
2162
  throw new CliError(
2145
2163
  "Confirm supervisor installation with --confirm INSTALL.",
@@ -2167,6 +2185,7 @@ async function handleRuntimeInstall(client, store, options, context) {
2167
2185
  identity,
2168
2186
  sshUser,
2169
2187
  bootstrap: bootstrap.data,
2188
+ nestedPrivateProcfs,
2170
2189
  fetchImpl: context.fetchImpl,
2171
2190
  spawnImpl: context.spawnImpl,
2172
2191
  });
@@ -2975,6 +2994,7 @@ async function dispatch(positionals, options, passthrough, context) {
2975
2994
  "identity",
2976
2995
  "ssh-user",
2977
2996
  "confirm",
2997
+ "nested-private-procfs",
2978
2998
  "idempotency-key",
2979
2999
  "wait",
2980
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;