@volter/world-runtime 3.0.38 → 3.0.39

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.
Files changed (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
package/src/runtime.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from './error-message.ts';
1
2
  import { materializeBrowserFiles, clearBrowserFiles } from './browsers.ts';
2
3
  import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
3
4
  import { offSignal } from './signals.ts';
@@ -11,21 +12,27 @@ import { get as httpGet, type ClientRequest } from 'node:http';
11
12
  import { siblingScript } from './sibling.ts';
12
13
  import { commandLifetime, commandProcessGroup, CommandRetirementError } from './command-lifetime.ts';
13
14
  import { superviseWorldRun } from './run-task.ts';
14
- import { survivingOwnedGroups } from './process-groups.ts';
15
+ import { processStarts, survivingOwnedGroups } from './process-groups.ts';
16
+ import { spawnIdentity, signalOwnedProcess, signalChild, signalHeldChild, processBirth, childIdentity } from './process-signal.mjs';
17
+ import type { SpawnIdentity } from './process-receipt.ts';
18
+ import { readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
19
+
15
20
  import { randomUUID, createHash } from 'node:crypto';
16
21
  import { createRequire } from 'node:module';
17
22
  import { isIP } from 'node:net';
18
23
  import { allocatePort } from './tcp-port.ts';
19
24
  import { constants as osConstants, hostname as osHostname } from 'node:os';
20
- import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync } from 'node:fs';
25
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync, existsSync, fchmodSync, ftruncateSync } from 'node:fs';
21
26
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
22
27
  import { packFacts } from './pack-facts.ts';
28
+ import { assertOwnedRealPath, ownedPath } from './owned-path.ts';
29
+ import { isEnvironmentName } from './schema.ts';
23
30
  import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
24
31
  import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
25
32
  import { Socket } from 'node:net';
26
33
  import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
27
34
  import { loadWorldConfig } from './configs.ts';
28
- import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
35
+ import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, type WorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
29
36
  import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
30
37
  import { fakeEnvValue } from './fixture-env.ts';
31
38
  import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
@@ -35,7 +42,23 @@ import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnv
35
42
  import { CaTrustError, sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
36
43
  import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldRunOutcome, WorldRunRecord, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
37
44
  import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from './consumers.ts';
38
- import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from './lifecycle-record.ts';
45
+ import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle, exitedWorldLifecyclePids } from './lifecycle-record.ts';
46
+
47
+ // Births captured at actual spawn; retries never remeasure a retained PID.
48
+ const spawnedIdentities = new Map<number, SpawnIdentity>();
49
+ const spawnedChildren = new Map<number, ReturnType<typeof spawn>>();
50
+ function retainSpawn(child: ReturnType<typeof spawn>): Promise<SpawnIdentity | undefined> {
51
+ if (!child.pid) return Promise.resolve(undefined);
52
+ const pid = child.pid;
53
+ spawnedChildren.set(pid, child);
54
+ return childIdentity(child).then(identity => {
55
+ if (identity && spawnedChildren.get(pid) === child) spawnedIdentities.set(pid, identity);
56
+ return identity;
57
+ });
58
+ }
59
+ function heldStarts(): Record<string, string | undefined> {
60
+ return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
61
+ }
39
62
 
40
63
  /** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
41
64
  * shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
@@ -62,8 +85,8 @@ export function worldEventsPath(logs: string): string {
62
85
  * its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
63
86
  * file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
64
87
  * `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
65
- function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string): { child: ReturnType<typeof spawn>; ended: Promise<void> } {
66
- const logDir = `${log.replace(/\.log$/, '')}.d`;
88
+ function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
89
+ const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
67
90
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
68
91
  const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
69
92
  const recorderEnv: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
@@ -75,9 +98,13 @@ function spawnRecorded(serviceId: string, command: string, args: string[], cwd:
75
98
  detached: true,
76
99
  stdio: ['ignore', out, out],
77
100
  });
78
- closeSync(out);
79
101
  const ended = new Promise<void>((resolveEnded) => { child.once('exit', () => resolveEnded()); child.once('error', () => resolveEnded()); });
80
- return { child, ended };
102
+ const identity = retainSpawn(child);
103
+ if (child.pid) { boot.pids.add(child.pid); boot.handles.set(child.pid, child); }
104
+ child.once('error', error => boot.fail(error));
105
+ boot.recordPids();
106
+ closeSync(out);
107
+ return { child, ended, identity };
81
108
  }
82
109
 
83
110
  /** A startup wait that fails as soon as the service ends, with the service's own account of its end (exit code or
@@ -218,6 +245,8 @@ export type UpWorldOptions = {
218
245
  * episodes survive the boot; logs and the pid file start clean. Never the default: `up` starts
219
246
  * clean, and the world dir is the reproducible story. */
220
247
  keepState?: boolean;
248
+ /** A branch's base permission: intersects both the config and the launching process. */
249
+ networkCeiling?: WorldNetwork;
221
250
  };
222
251
 
223
252
  export type ShareWorldOptions = {
@@ -249,6 +278,8 @@ export type WorldDoctorReport = {
249
278
  };
250
279
 
251
280
  export type RunWorldOptions = UpWorldOptions & {
281
+ /** the command's working directory: the caller's, as `attach` runs its command (default: the World's root) */
282
+ cwd?: string;
252
283
  keep?: boolean;
253
284
  verbose?: boolean;
254
285
  };
@@ -290,7 +321,8 @@ export function clockFile(root: string, name: string): string {
290
321
  }
291
322
 
292
323
  export function instanceDir(root: string, name: string): string {
293
- return join(worldBaseDir(root), name);
324
+ assertSafeWorldName(name);
325
+ return ownedPath(worldBaseDir(root), name);
294
326
  }
295
327
 
296
328
  function instanceFile(root: string, name: string): string {
@@ -306,11 +338,13 @@ function foregroundRunFile(root: string, name: string): string {
306
338
  * a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
307
339
  * collide with (or be wiped as) a world's instance dir. */
308
340
  function instanceLockFile(root: string, name: string): string {
309
- return join(worldBaseDir(root), '.locks', `${name}.lock`);
341
+ assertSafeWorldName(name);
342
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.lock`);
310
343
  }
311
344
 
312
345
  function stoppingFile(root: string, name: string): string {
313
- return join(worldBaseDir(root), '.locks', `${name}.stop.json`);
346
+ assertSafeWorldName(name);
347
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.stop.json`);
314
348
  }
315
349
 
316
350
  const SHARE_CLAIM_STALE_MS = 60_000;
@@ -332,7 +366,8 @@ export type SharingClaim = {
332
366
  // liveness; this is host physics, not world time). Nothing here reads a process table.
333
367
 
334
368
  function sharingClaimFile(root: string, name: string): string {
335
- return join(worldBaseDir(root), '.locks', `${name}.share.json`);
369
+ assertSafeWorldName(name);
370
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.share.json`);
336
371
  }
337
372
 
338
373
  function readSharingClaim(path: string): SharingClaim | undefined {
@@ -443,13 +478,13 @@ function prepareBootForDown(root: string, name: string): void {
443
478
  claim = JSON.parse(readFileSync(path, 'utf8')) as BootingClaim;
444
479
  if (!claim || !Number.isInteger(claim.pid) || claim.pid <= 0 || typeof claim.at !== 'string'
445
480
  || !Number.isFinite(Date.parse(claim.at))) throw new Error('invalid boot claim');
446
- } catch {
447
- throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`);
481
+ } catch (error) {
482
+ throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`, { cause: error });
448
483
  }
449
484
  try { process.kill(claim.pid, 0); }
450
485
  catch (error) {
451
486
  if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
452
- throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`);
487
+ throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`, { cause: error });
453
488
  }
454
489
  rmSync(path); // the recorded booter cannot publish anything after this point
455
490
  return;
@@ -488,7 +523,7 @@ function proxyEnvFile(root: string, name: string): string {
488
523
  return join(instanceDir(root, name), 'proxy-env.json');
489
524
  }
490
525
 
491
- type ProxyState = { pid: number; url: string; caCertPath: string; guestUrl?: string };
526
+ type ProxyState = { pid: number; birth?: string; url: string; caCertPath: string; guestUrl?: string };
492
527
 
493
528
  function readProxyState(root: string, name: string): ProxyState | null {
494
529
  const file = proxyStateFile(root, name);
@@ -583,16 +618,127 @@ export function withoutVaultSignIn<T extends Record<string, string | undefined>>
583
618
  * the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
584
619
  * so a vendor the inner World does not twin reaches the parent's twin of it.
585
620
  */
586
- function networkOf(name: string, own: { egress: readonly string[] } | undefined, parentEnv: NodeJS.ProcessEnv = process.env): Record<string, string> | undefined {
621
+ function networkOf(name: string, own: WorldNetwork | undefined, recorded: WorldNetwork | undefined, parentEnv: NodeJS.ProcessEnv = process.env): Record<string, string> | undefined {
587
622
  const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
588
- let ceiling: readonly string[] | undefined;
623
+ let ceiling: WorldNetwork | undefined;
589
624
  if (serialized !== undefined) {
590
- // an unreadable parent policy closes the child entirely, as it closes the parent
591
- try { ceiling = (JSON.parse(serialized) as { egress?: string[] }).egress ?? []; } catch { ceiling = []; }
625
+ // An unreadable parent policy closes the child, including opaque transport permission.
626
+ try { ceiling = networkPermission(parseWorldNetworkPolicy(JSON.parse(serialized))); } catch { ceiling = { egress: [] }; }
627
+ }
628
+ let network = own;
629
+ for (const limit of [recorded, ceiling]) if (limit !== undefined) network = network === undefined ? limit : intersectWorldNetwork(network, limit);
630
+ if (network === undefined) return undefined;
631
+ return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
632
+ }
633
+
634
+ /** Strip the publication envelope before combining permissions (the config shape admits only these keys). */
635
+ function networkPermission(policy: WorldNetwork): WorldNetwork {
636
+ return { egress: policy.egress, phase: policy.phase, passthrough: policy.passthrough };
637
+ }
638
+
639
+ /** The boot permission, never a fallback to a mutable config. */
640
+ export function recordedWorldNetwork(instance: WorldInstance): WorldNetwork | undefined {
641
+ try {
642
+ if (!Object.hasOwn(instance, 'networkCeiling')) throw new Error();
643
+ const published = instance.env?.[WORLD_NETWORK_POLICY_ENV];
644
+ const bound = published === undefined ? undefined : parseWorldNetworkPolicy(JSON.parse(published));
645
+ if (bound && bound.world !== instance.name) throw new Error();
646
+ const saved = instance.networkCeiling === 'unrestricted' ? undefined : normalizeWorldNetwork(instance.networkCeiling);
647
+ const permission = bound === undefined ? undefined : networkPermission(bound);
648
+ return saved === undefined ? permission : permission === undefined ? saved : intersectWorldNetwork(saved, permission);
649
+ } catch { throw new Error(`World boot record is incomplete or invalid: ${instanceFile(instance.root, instance.name)}`); }
650
+ }
651
+
652
+ /** An absent stored ceiling is derived from this World's own config and retained bound environment. */
653
+ function ceilingFromConfig(record: WorldInstance, config: WorldConfig): WorldNetwork | 'unrestricted' {
654
+ let ceiling = record.mode === 'sealed' ? { egress: [] } : config.network;
655
+ const published = record.env?.[WORLD_NETWORK_POLICY_ENV];
656
+ if (published !== undefined) {
657
+ const bound = parseWorldNetworkPolicy(JSON.parse(published));
658
+ if (bound.world !== record.name) throw new Error();
659
+ const permission = networkPermission(bound);
660
+ ceiling = ceiling === undefined ? permission : intersectWorldNetwork(ceiling, permission);
592
661
  }
593
- if (own === undefined && ceiling === undefined) return undefined;
594
- const egress = own === undefined ? ceiling! : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling!.includes(origin));
595
- return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
662
+ return ceiling === undefined ? 'unrestricted' : normalizeWorldNetwork(ceiling);
663
+ }
664
+
665
+ /** A stopped receipt checks byte consistency before fields are read; it is not authentication. */
666
+ export function readWorldBootRecord(name: string, root: string): WorldInstance {
667
+ return withFileLock(instanceLockFile(root, name), () => readWorldBootRecordLocked(name, root));
668
+ }
669
+
670
+ /** Ordinary reading completes derivable metadata and its receipt together under the instance lock. */
671
+ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
672
+ const path = instanceFile(root, name);
673
+ const raw = getActiveWorldStore().read(path);
674
+ if (raw === null) throw new Error(`World instance not found: ${name}`);
675
+ try {
676
+ const receiptPath = join(instanceDir(root, name), 'stopped.json');
677
+ const stopped = existsSync(receiptPath);
678
+ const receipt = stopped ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
679
+ if (stopped && (receipt.instanceHash !== createHash('sha256').update(raw).digest('hex')
680
+ || typeof receipt.stoppedAt !== 'string' || !Number.isFinite(Date.parse(receipt.stoppedAt)))) throw new Error();
681
+ const record = JSON.parse(raw) as WorldInstance;
682
+ if (record.name !== name || typeof record.root !== 'string' || resolve(record.root) !== resolve(root)
683
+ || typeof record.createdAt !== 'string' || !Number.isFinite(Date.parse(record.createdAt))
684
+ || typeof record.config !== 'string' || !record.config || typeof record.configPath !== 'string' || !record.configPath
685
+ || !record.env || record.env.VOLTER_WORLD !== name || record.env.VOLTER_WORLD_MODE !== record.mode
686
+ || !record.dirs || record.dirs.instance !== instanceDir(root, name)
687
+ || record.dirs.data !== join(instanceDir(root, name), 'data') || record.dirs.logs !== join(instanceDir(root, name), 'logs')
688
+ || typeof record.envFile !== 'string' || !record.envFile || record.pidsFile !== join(instanceDir(root, name), 'pids')
689
+ || !record.services || typeof record.services !== 'object' || Array.isArray(record.services)) throw new Error();
690
+ assertMode(record.mode);
691
+ if (!stopped && !isWorldLifecycleRunning(root, name, record.createdAt)) throw new Error();
692
+ let completed = false;
693
+ if (!Object.hasOwn(record, 'instance')) { record.instance = randomUUID(); completed = true; }
694
+ if (typeof record.instance !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(record.instance)) throw new Error();
695
+ const needsConfig = !Object.hasOwn(record, 'networkCeiling')
696
+ || Object.values(record.services).some(service => service && !Object.hasOwn(service, 'branchState'));
697
+ const config = needsConfig ? loadWorldConfig(record.configPath, root).config : undefined;
698
+ if (config && config.id !== record.config) throw new Error();
699
+ if (!Object.hasOwn(record, 'networkCeiling')) {
700
+ record.networkCeiling = ceilingFromConfig(record, config!);
701
+ completed = true;
702
+ }
703
+ if (!Object.hasOwn(record, 'bootOwner')) {
704
+ if (!record.lifecycle && !record.resources) throw new Error();
705
+ record.bootOwner = record.lifecycle?.owner ?? record.resources?.owner ?? null;
706
+ completed = true;
707
+ }
708
+ if (!(record.bootOwner === null || typeof record.bootOwner === 'string')) throw new Error();
709
+ checkedOwner(record.bootOwner ?? undefined);
710
+ if (!record.lifecycle) {
711
+ record.lifecycle = { log: lifecycleLogPath(root, name), ...(record.bootOwner === null ? {} : { owner: record.bootOwner }) };
712
+ completed = true;
713
+ }
714
+ if (!Object.hasOwn(record.lifecycle, 'log')) { record.lifecycle.log = lifecycleLogPath(root, name); completed = true; }
715
+ if (!Object.hasOwn(record.lifecycle, 'owner') && record.bootOwner !== null) { record.lifecycle.owner = record.bootOwner; completed = true; }
716
+ if (record.lifecycle.log !== lifecycleLogPath(root, name) || (record.lifecycle.owner ?? null) !== record.bootOwner) throw new Error();
717
+ if (!Object.hasOwn(record, 'bootServices')) {
718
+ record.bootServices = Object.keys(record.services);
719
+ completed = true;
720
+ }
721
+ if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
722
+ || new Set(record.bootServices).size !== record.bootServices.length
723
+ || record.bootServices.length !== Object.keys(record.services).length
724
+ || record.bootServices.some(id => !Object.hasOwn(record.services, id))) throw new Error();
725
+ for (const [id, service] of Object.entries(record.services)) {
726
+ if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string') throw new Error();
727
+ if (!Object.hasOwn(service, 'branchState')) {
728
+ const declared = config!.services.find(item => item.id === id);
729
+ if (!declared) throw new Error();
730
+ service.branchState = declared.branchState ?? 'supported'; completed = true;
731
+ }
732
+ if (!['supported', 'unsupported'].includes(service.branchState ?? '')) throw new Error();
733
+ }
734
+ recordedWorldNetwork(record);
735
+ if (completed) {
736
+ saveWorldInstance(record);
737
+ if (stopped) writeTextAtomic(receiptPath, JSON.stringify({ ...receipt,
738
+ instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
739
+ }
740
+ return record;
741
+ } catch { throw new Error(`World boot record is incomplete or invalid: ${path}`); }
596
742
  }
597
743
 
598
744
  export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
@@ -690,22 +836,16 @@ async function waitForTcp(port: number, timeoutMs = 15_000, abort?: AbortSignal)
690
836
 
691
837
  function readPids(path: string): number[] {
692
838
  const held = getActiveWorldStore().read(path);
693
- if (held === null) return [];
694
- return held
839
+ const guests = readGuestProcesses(dirname(path)).map(receipt => receipt.record.pid);
840
+ return [...new Set([...(held ?? '')
695
841
  .split(/\r?\n/)
696
842
  .map((line) => Number(line.trim()))
697
- .filter((pid) => Number.isInteger(pid) && pid > 0);
843
+ .filter((pid) => Number.isInteger(pid) && pid > 0), ...guests])];
698
844
  }
699
845
 
700
846
  function livePids(pids: number[]): number[] {
701
- return pids.filter((pid) => {
702
- try {
703
- process.kill(pid, 0);
704
- return true;
705
- } catch (error) {
706
- return (error as NodeJS.ErrnoException).code !== 'ESRCH';
707
- }
708
- });
847
+ // A recorded leader may have exited while its detached descendants still run.
848
+ return survivingOwnedGroups(pids);
709
849
  }
710
850
 
711
851
  function tunnelPids(instance: WorldInstance): number[] {
@@ -730,13 +870,13 @@ function openPrivateLog(path: string, flags: 'a' | 'w'): number {
730
870
  /** Record `pids` as the World's. `fresh` are the processes this very call has spawned: only their start times are
731
871
  * measured anew; every other pid keeps the start time recorded when it was spawned (re-measuring it would bless
732
872
  * whatever process holds that number now). */
733
- function writeOwnedPids(root: string, name: string, pids: number[], fresh: number[] = []): void {
873
+ function writeOwnedPids(root: string, name: string, pids: number[], fresh: number[] = [], births = heldStarts()): void {
734
874
  const path = join(instanceDir(root, name), 'pids');
735
875
  withFileLock(`${instanceLockFile(root, name)}.pids`, () => {
736
876
  const retained = undeadPids(readPids(path));
737
877
  const merged = [...new Set([...retained, ...pids].filter(pid => Number.isInteger(pid) && pid > 0))];
738
878
  writeTextAtomic(path, `${merged.join('\n')}\n`);
739
- recordIdentities(root, name, merged, fresh);
879
+ recordIdentities(root, name, merged, fresh, births);
740
880
  });
741
881
  }
742
882
 
@@ -747,40 +887,31 @@ function writeOwnedPids(root: string, name: string, pids: number[], fresh: numbe
747
887
  function identityPath(root: string, name: string): string {
748
888
  return join(instanceDir(root, name), 'pids.identity');
749
889
  }
750
- /** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
751
- * process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
752
- * given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
753
- * processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
754
- * paid. */
755
- function processStarts(pids: number[]): Map<number, string> {
756
- const starts = new Map<number, string>();
757
- if (!pids.length || process.platform === 'win32') return starts;
758
- if (process.platform === 'linux' && existsSync('/proc/self/stat')) {
759
- // Linux: the start tick since boot (field 22), which a wall-clock step does not move (lstart would).
760
- for (const pid of pids) {
761
- try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); starts.set(pid, `tick ${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`); } catch { /* no process */ }
762
- }
763
- return starts;
764
- }
765
- const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
766
- for (const pid of pids) {
767
- const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
768
- const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
769
- if (match && Number(match[1]) === pid) starts.set(pid, match[2]!);
770
- }
771
- return starts;
772
- }
773
890
  function readIdentities(root: string, name: string): Record<string, string> {
774
- try { return JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}') as Record<string, string>; } catch { return {}; }
891
+ let known: Record<string, string> = {};
892
+ try {
893
+ const value = JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}');
894
+ if (value && typeof value === 'object' && !Array.isArray(value)) known = Object.fromEntries(Object.entries(value).filter(([pid, start]) => Number.isInteger(Number(pid)) && Number(pid) > 0 && typeof start === 'string' && start.length > 0)) as Record<string, string>;
895
+ } catch { /* Guest receipts below remain independently authoritative. */ }
896
+ const births = new Map<number, string | undefined>();
897
+ for (const { record } of readGuestProcesses(instanceDir(root, name))) {
898
+ if (births.has(record.pid) && births.get(record.pid) !== record.start) throw new Error('Conflicting guest spawn identities; ownership retained');
899
+ births.set(record.pid, record.start);
900
+ }
901
+ for (const [pid, start] of births) { if (start) known[pid] = start; else delete known[pid]; }
902
+ return known;
775
903
  }
776
- function recordIdentities(root: string, name: string, pids: number[], fresh: number[] = []): void {
904
+ function recordIdentities(root: string, name: string, pids: number[], fresh: number[] = [], births = heldStarts()): void {
777
905
  try {
778
906
  // A pid the World has just spawned is measured anew, whatever an older record says of that number.
779
- const known = readIdentities(root, name), starts = processStarts(pids.filter((pid) => known[pid] === undefined || fresh.includes(pid)));
780
- for (const pid of fresh) if (starts.has(pid)) delete known[pid];
781
- const kept = Object.fromEntries(pids.flatMap((pid) => { const start = known[pid] ?? starts.get(pid); return start ? [[String(pid), start]] : []; }));
907
+ const known = readIdentities(root, name);
908
+ for (const pid of fresh) {
909
+ if (births[pid] !== undefined) known[pid] = births[pid]!;
910
+ else delete known[pid];
911
+ }
912
+ const kept = Object.fromEntries(pids.flatMap(pid => known[pid] === undefined ? [] : [[String(pid), known[pid]]]));
782
913
  writeTextAtomic(identityPath(root, name), JSON.stringify(kept));
783
- } catch { /* without a record a pid is trusted as before */ }
914
+ } catch { /* without a record signalling refuses and lifecycle evidence is retained */ }
784
915
  }
785
916
  /** Recorded pids whose running process started at another time than recorded: the number now names another
786
917
  * program. An unrecorded pid, and a pid with no process, are never in this list. */
@@ -906,13 +1037,15 @@ function getByJsonPath(value: unknown, jsonPath: string): unknown {
906
1037
  return current;
907
1038
  }
908
1039
 
1040
+ class ExternalCommandUnavailableError extends Error {}
1041
+
909
1042
  async function runExternalCommand(
910
1043
  command: string[], cwd: string, serviceId: string, phase: string,
911
1044
  env: NodeJS.ProcessEnv = process.env, abort?: AbortSignal, timeoutMs = 60_000,
912
1045
  ): Promise<{ stdout: string; stderr: string }> {
913
1046
  const [bin, ...args] = ownCommand(command);
914
1047
  if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
915
- if (!commandExists(bin)) throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
1048
+ if (!commandExists(bin)) throw new ExternalCommandUnavailableError(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
916
1049
  abort?.throwIfAborted();
917
1050
  const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
918
1051
  const lifetime = commandLifetime(child, abort, true);
@@ -927,8 +1060,15 @@ async function runExternalCommand(
927
1060
  const timer = setTimeout(() => { failure = new Error(`command timed out after ${timeoutMs}ms`); lifetime.cancel('SIGTERM'); }, timeoutMs);
928
1061
  try {
929
1062
  const result = await lifetime.finish();
930
- if (failure) throw new Error(`External service "${serviceId}": ${phase} command failed to run (${failure.message})`);
931
- if (result.code !== 0 || lifetime.forwarded) throw new Error(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
1063
+ if (failure) {
1064
+ const ErrorKind = ['ENOENT', 'ENOEXEC', 'EACCES'].includes((failure as NodeJS.ErrnoException).code ?? '') ? ExternalCommandUnavailableError : Error;
1065
+ throw new ErrorKind(`External service "${serviceId}": ${phase} command failed to run (${errorMessage(failure)})`);
1066
+ }
1067
+ if (result.code !== 0 || lifetime.forwarded) {
1068
+ const parserRefusal = /unknown (?:shorthand flag|flag|option|command)|unrecognized (?:option|argument)|command not found|no such file or directory/iu.test(`${stdout}${stderr}`);
1069
+ const ErrorKind = !lifetime.forwarded && ([126, 127].includes(result.code ?? -1) || (result.code === 125 && parserRefusal)) ? ExternalCommandUnavailableError : Error;
1070
+ throw new ErrorKind(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
1071
+ }
932
1072
  return { stdout, stderr };
933
1073
  } finally { clearTimeout(timer); }
934
1074
  }
@@ -981,8 +1121,8 @@ async function runExternalUp(
981
1121
  }
982
1122
 
983
1123
  /** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
984
- * names), with nothing on PATH answering the name: the phase then runs in this process (infra.ts) instead of in a
985
- * child. The services it starts (the PGlite host, the redis and MongoDB twins) are then children of the process that
1124
+ * names): the phase runs in this process (infra.ts) instead of in a
1125
+ * child. The services it starts (the PGlite host, the redis twin and mongod) are then children of the process that
986
1126
  * owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
987
1127
  * does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
988
1128
  * ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
@@ -1047,14 +1187,14 @@ async function awaitReadiness(
1047
1187
  throw new Error(`Service "${serviceId}": readiness command \`${ready.command}\` not found on PATH`);
1048
1188
  }
1049
1189
  try { await runExternalCommand([ready.command, ...(ready.args ?? [])], cwd, serviceId, 'readiness', env, abort, Math.max(1, timeoutMs - (Date.now() - started))); return; }
1050
- catch (error) { if (error instanceof CommandRetirementError) throw error; lastError = String(error); }
1190
+ catch (error) { if (error instanceof CommandRetirementError) throw error; lastError = errorMessage(error); }
1051
1191
  } else if (ready.httpUrl !== undefined) {
1052
1192
  try {
1053
1193
  const response = await fetch(ready.httpUrl, { signal: AbortSignal.any([AbortSignal.timeout(Math.max(1, timeoutMs - (Date.now() - started))), ...(abort ? [abort] : [])]) });
1054
1194
  if (response.status >= 200 && response.status < 400) return;
1055
1195
  lastError = `HTTP ${response.status}`;
1056
1196
  } catch (error) {
1057
- lastError = error instanceof Error ? error.message : String(error);
1197
+ lastError = errorMessage(error);
1058
1198
  }
1059
1199
  } else if (ready.stdoutMatch !== undefined) {
1060
1200
  const text = serviceOutput(readServiceText(logPath));
@@ -1066,17 +1206,14 @@ async function awaitReadiness(
1066
1206
  throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
1067
1207
  }
1068
1208
 
1069
- async function startExternalService(
1209
+ /** The environment an external service and its replacement infrastructure hosts run in. Shared with branching,
1210
+ * so both use the boot's admission, service overrides, lifecycle context and control-plane egress rules. */
1211
+ export function externalServiceEnv(
1070
1212
  service: WorldServiceConfig,
1071
- paths: { root: string; logs: string },
1213
+ paths: { logs: string },
1072
1214
  worldEnv: Record<string, string>,
1073
1215
  stripEnv?: string[],
1074
- boot?: BootContext,
1075
- ): Promise<WorldServiceInstance> {
1076
- const external = service.external;
1077
- if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
1078
- const log = join(paths.logs, `${service.id}.log`);
1079
- const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1216
+ ): NodeJS.ProcessEnv {
1080
1217
  // External commands see the accumulated world env (process.env + earlier services' discovered/
1081
1218
  // injected vars), so a later external can consume an earlier service's connection info.
1082
1219
  const env: NodeJS.ProcessEnv = {
@@ -1084,10 +1221,26 @@ async function startExternalService(
1084
1221
  ...worldEnv,
1085
1222
  ...(service.env ?? {}),
1086
1223
  VOLTER_WORLD_SERVICE_ID: service.id,
1087
- VOLTER_WORLD_SERVICE_LOG: log,
1224
+ VOLTER_WORLD_SERVICE_LOG: ownedPath(paths.logs, `${service.id}.log`),
1088
1225
  };
1089
1226
  if (service.controlPlane) for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
1090
1227
 
1228
+ return env;
1229
+ }
1230
+
1231
+ async function startExternalService(
1232
+ service: WorldServiceConfig,
1233
+ paths: { root: string; logs: string },
1234
+ worldEnv: Record<string, string>,
1235
+ stripEnv?: string[],
1236
+ boot?: BootContext,
1237
+ ): Promise<WorldServiceInstance> {
1238
+ const external = service.external;
1239
+ if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
1240
+ const log = ownedPath(paths.logs, `${service.id}.log`);
1241
+ const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1242
+ const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
1243
+
1091
1244
  // 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
1092
1245
  if (!external.up[0] || !commandExists(external.up[0])) {
1093
1246
  throw new Error(`External service "${service.id}": \`${external.up[0] ?? ''}\` not found on PATH (the up command's tool is not installed)`);
@@ -1124,7 +1277,7 @@ async function startExternalService(
1124
1277
  try {
1125
1278
  jsonCache[source] = JSON.parse(raw) as unknown;
1126
1279
  } catch (error) {
1127
- throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${error instanceof Error ? error.message : String(error)})\nOutput: ${raw.slice(0, 500)}`);
1280
+ throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${errorMessage(error)})\nOutput: ${raw.slice(0, 500)}`);
1128
1281
  }
1129
1282
  }
1130
1283
  const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
@@ -1157,12 +1310,18 @@ type BootContext = {
1157
1310
  signal: AbortSignal;
1158
1311
  fail: (error: Error) => void;
1159
1312
  pids: Set<number>;
1313
+ identities: Record<string, string | undefined>;
1314
+ handles: Map<number, ReturnType<typeof spawn>>;
1160
1315
  external: (service: WorldServiceInstance) => void;
1161
1316
  recordPids: () => void;
1162
1317
  };
1163
- function trackBootingChild(child: { pid?: number }, boot: BootContext): void {
1318
+ async function trackBootingChild(child: ReturnType<typeof spawn>, boot: BootContext, birth: Promise<SpawnIdentity | undefined>): Promise<void> {
1164
1319
  if (!child.pid) return;
1165
- boot.pids.add(child.pid);
1320
+ boot.pids.add(child.pid); boot.handles.set(child.pid, child);
1321
+ boot.recordPids(); // durable numeric uncertainty precedes the async birth capture
1322
+ const identity = await birth;
1323
+ if (!identity?.start) throw new Error('Service birth capture failed; retiring held child');
1324
+ boot.identities[child.pid] = identity.start;
1166
1325
  boot.recordPids();
1167
1326
  }
1168
1327
 
@@ -1177,7 +1336,7 @@ async function startService(
1177
1336
  if (service.type === 'external') return startExternalService(service, paths, worldEnv, config.stripEnv, boot);
1178
1337
  if (!service.command) throw new Error(`Service "${service.id}" must define command`);
1179
1338
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1180
- const serviceDataDir = join(paths.data, service.id);
1339
+ const serviceDataDir = ownedPath(paths.data, service.id);
1181
1340
  mkdirSync(serviceDataDir, { recursive: true });
1182
1341
  // R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
1183
1342
  // is checked against its pin and its protocol standing before it is spawned.
@@ -1194,7 +1353,7 @@ async function startService(
1194
1353
  if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
1195
1354
  if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
1196
1355
 
1197
- const log = join(paths.logs, `${service.id}.log`);
1356
+ const log = ownedPath(paths.logs, `${service.id}.log`);
1198
1357
  const env = serviceProcessEnv(config, service, worldEnv, port);
1199
1358
  const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1200
1359
 
@@ -1209,10 +1368,10 @@ async function startService(
1209
1368
  // The service runs under the service recorder, which outlives it to write its end into its own log: `up`
1210
1369
  // returns once the World is ready, so nothing else is left to see a service stop.
1211
1370
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1212
- const { child, ended } = spawnRecorded(service.id, service.command, args, cwd, env, log);
1371
+ const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot);
1213
1372
  child.once('error', error => boot.fail(error));
1214
1373
  child.unref();
1215
- trackBootingChild(child, boot);
1374
+ await trackBootingChild(child, boot, identity);
1216
1375
 
1217
1376
  // Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
1218
1377
  // child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
@@ -1229,19 +1388,19 @@ async function startService(
1229
1388
  // swapping, a docker build beside the boot) a heavy service takes longer than the 15s default
1230
1389
  // to answer on its port, and failing it there wastes the whole boot.
1231
1390
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1232
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch((error) => {
1391
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch(async (error) => {
1233
1392
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1234
1393
  try {
1235
- process.kill(-child.pid!, 'SIGTERM');
1394
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1236
1395
  } catch {
1237
1396
  // best effort cleanup
1238
1397
  }
1239
- throw new Error(`Service "${service.id}" failed to start: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1398
+ throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
1240
1399
  });
1241
1400
 
1242
- await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch((error) => {
1401
+ await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1243
1402
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1244
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1403
+ try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1245
1404
  throw error;
1246
1405
  });
1247
1406
  if (exitState.value !== null) {
@@ -1257,10 +1416,10 @@ async function startService(
1257
1416
  // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1258
1417
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1259
1418
  if (service.ready && service.ready !== 'tcp') {
1260
- await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch((error) => {
1419
+ await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1261
1420
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1262
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1263
- throw new Error(`Service "${service.id}" failed its readiness probe: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1421
+ try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1422
+ throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
1264
1423
  });
1265
1424
  }
1266
1425
 
@@ -1406,7 +1565,8 @@ const ISSUE_TIMEOUT_MS = 15_000;
1406
1565
  * asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
1407
1566
  * door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
1408
1567
  async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>): Promise<void> {
1409
- const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
1568
+ const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
1569
+ const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
1410
1570
  if (wanted.length === 0) return;
1411
1571
  for (const name of wanted) pending.delete(name);
1412
1572
  // the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
@@ -1420,26 +1580,92 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
1420
1580
  const answer = (await res.json()) as Record<string, unknown>;
1421
1581
  for (const name of wanted) {
1422
1582
  const spec = door.fill[name] ?? '';
1423
- // `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
1424
- // beside the World's instance file, the name set to its path
1425
- const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
1426
- const value = answer[field];
1427
- if (spec.startsWith('file:')) {
1583
+ if (spec.startsWith('directory:') || spec.startsWith('file:')) {
1584
+ if (direct.has(name)) throw new Error(`cannot give ${name}'s filesystem credential a direct URL`);
1585
+ if (!isEnvironmentName(name) || !/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error('Invalid credential destination name');
1586
+ if (!env.VOLTER_WORLD_INSTANCE) throw new Error('World credential custody is unknown');
1587
+ const instance = realpathSync(dirname(env.VOLTER_WORLD_INSTANCE));
1588
+ const custody = ownedPath(instance, 'credentials');
1589
+ const artifact = `${service.id}-${name.toLowerCase()}`;
1590
+ const isDirectory = spec.startsWith('directory:');
1591
+ const field = spec.slice(spec.indexOf(':') + 1), value = answer[field];
1428
1592
  if (value === undefined || value === null) throw new Error(`answered no ${field}`);
1429
- // beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
1430
- if (!env.VOLTER_WORLD_INSTANCE) throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
1431
- const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
1432
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1433
- const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
1434
- writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1435
- env[name] = path;
1593
+ if (isDirectory && (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0)) throw new Error('Invalid credential directory');
1594
+ const entries = isDirectory ? Object.entries(value as Record<string, unknown>) : [[`${artifact}.json`, value] as const];
1595
+ const files = entries.map(([file, contents]) => {
1596
+ if (isDirectory && (!file || file.includes('\\') || file.split('/').some(part => !/^[A-Za-z0-9._-]+$/.test(part) || part === '.' || part === '..'))) throw new Error('Invalid World-owned path');
1597
+ return { path: ownedPath(custody, isDirectory ? `${artifact}/${file}` : file), contents };
1598
+ });
1599
+ const directories = new Set([custody]);
1600
+ for (const { path } of files) {
1601
+ let parent = dirname(path);
1602
+ while (parent !== custody) { directories.add(parent); parent = dirname(parent); }
1603
+ const stat = lstatSync(path, { throwIfNoEntry: false });
1604
+ if (stat && (!stat.isFile() || stat.nlink !== 1)) throw new Error('Invalid World-owned path');
1605
+ }
1606
+ // Validate every destination and parent before creating or changing any custody artifact.
1607
+ for (const dir of directories) {
1608
+ ownedPath(instance, relative(instance, dir));
1609
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1610
+ if (stat && !stat.isDirectory()) throw new Error('Invalid World-owned path');
1611
+ }
1612
+ for (const dir of [...directories].sort((a, b) => a.length - b.length)) {
1613
+ if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 });
1614
+ assertOwnedRealPath(custody, dir);
1615
+ chmodSync(dir, 0o700);
1616
+ }
1617
+ for (const { path, contents } of files) {
1618
+ ownedPath(custody, relative(custody, path));
1619
+ assertOwnedRealPath(custody, dirname(path));
1620
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW, 0o600);
1621
+ let opened: ReturnType<typeof fstatSync> | undefined;
1622
+ try {
1623
+ const stat = fstatSync(fd);
1624
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('Invalid World-owned path');
1625
+ opened = stat;
1626
+ fchmodSync(fd, 0o600);
1627
+ assertOwnedRealPath(custody, dirname(path));
1628
+ ftruncateSync(fd, 0);
1629
+ writeFileSync(fd, typeof contents === 'string' ? contents : `${JSON.stringify(contents, null, 2)}\n`);
1630
+ } finally {
1631
+ closeSync(fd);
1632
+ let realFile: string | undefined;
1633
+ try {
1634
+ realFile = realpathSync(path);
1635
+ const current = statSync(realFile);
1636
+ if (!opened || current.dev !== opened.dev || current.ino !== opened.ino) throw new Error('Invalid World-owned path');
1637
+ assertOwnedRealPath(custody, realFile);
1638
+ } catch (error) {
1639
+ // Remove only the file whose descriptor we opened; a raced replacement is not ours to delete.
1640
+ const current = realFile ? lstatSync(realFile, { throwIfNoEntry: false }) : undefined;
1641
+ if (opened && current?.dev === opened.dev && current.ino === opened.ino) rmSync(realFile!, { force: true });
1642
+ throw error;
1643
+ }
1644
+ }
1645
+ }
1646
+ env[name] = isDirectory ? ownedPath(custody, artifact) : files[0]!.path;
1436
1647
  continue;
1437
1648
  }
1649
+ const field = spec;
1650
+ const value = answer[field];
1438
1651
  if (typeof value !== 'string') throw new Error(`answered no ${field}`);
1439
- env[name] = value;
1652
+ if (direct.has(name)) {
1653
+ const issued = new URL(value);
1654
+ if (!['http:', 'https:'].includes(issued.protocol)) throw new Error(`cannot route ${name}'s non-HTTP URL directly`);
1655
+ const endpoint = new URL(started.url);
1656
+ issued.protocol = endpoint.protocol;
1657
+ issued.host = endpoint.host;
1658
+ env[name] = issued.toString();
1659
+ } else env[name] = value;
1660
+ }
1661
+ if (started.url) {
1662
+ const endpoint = new URL(started.url);
1663
+ Object.assign(env, resolveServiceEnvTemplates(service.injectEnvTemplates, {
1664
+ host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), url: started.url,
1665
+ }));
1440
1666
  }
1441
1667
  } catch (error) {
1442
- process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
1668
+ process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
1443
1669
  for (const name of wanted) { delete env[name]; unissued.add(name); }
1444
1670
  }
1445
1671
  }
@@ -1502,7 +1728,7 @@ async function startColocatedServices(
1502
1728
  const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage; sides: Awaited<ReturnType<typeof sidePortsFor>> }> = [];
1503
1729
  for (const service of colocated) {
1504
1730
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1505
- const serviceDataDir = join(paths.data, service.id);
1731
+ const serviceDataDir = ownedPath(paths.data, service.id);
1506
1732
  mkdirSync(serviceDataDir, { recursive: true });
1507
1733
  // Relative module paths resolve from the world root (import() inside the host would
1508
1734
  // otherwise resolve them against the runtime's own source directory).
@@ -1529,10 +1755,10 @@ async function startColocatedServices(
1529
1755
  dropUnissued(env);
1530
1756
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1531
1757
  // Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
1532
- const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
1758
+ const { child, ended, identity } = spawnRecorded('host', process.execPath, args, paths.root, env, log, boot);
1533
1759
  child.once('error', error => boot.fail(error));
1534
1760
  child.unref();
1535
- trackBootingChild(child, boot);
1761
+ await trackBootingChild(child, boot, identity);
1536
1762
 
1537
1763
  const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
1538
1764
  child.on('exit', (code, signal) => {
@@ -1565,17 +1791,17 @@ async function startColocatedServices(
1565
1791
  });
1566
1792
  const ready = (async (): Promise<void> => {
1567
1793
  const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1568
- await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch((error) => {
1794
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch(async (error) => {
1569
1795
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1570
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1571
- throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1796
+ try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1797
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1572
1798
  });
1573
1799
  await parallelStartup(allocated, async ({ service, port }) => {
1574
1800
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1575
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1801
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
1576
1802
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1577
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1578
- throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1803
+ try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1804
+ throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1579
1805
  });
1580
1806
  });
1581
1807
  // The host's receipt (this boot, every requested port) is published only
@@ -1585,9 +1811,9 @@ async function startColocatedServices(
1585
1811
  // answers with another boot's identity is still refused.
1586
1812
  const hostSettle = Promise.resolve('alive' as const);
1587
1813
  await parallelStartup(allocated, async ({ service, port }) => {
1588
- await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1814
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
1589
1815
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1590
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1816
+ try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1591
1817
  throw error;
1592
1818
  });
1593
1819
  });
@@ -1659,6 +1885,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1659
1885
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1660
1886
  const instanceFile = join(instance, 'instance.json');
1661
1887
  let carriedOrigin: WorldInstance['origin'] | undefined;
1888
+ let carriedNetwork = options.networkCeiling;
1662
1889
  // the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
1663
1890
  // writes it), so a file any of them last wrote is the World's own, never a custody file
1664
1891
  const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
@@ -1677,6 +1904,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1677
1904
  let resourcesClaimed = false;
1678
1905
  let cleanupUncertain = false;
1679
1906
  const createdAt = new Date().toISOString();
1907
+ const instanceToken = randomUUID();
1680
1908
 
1681
1909
  // Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
1682
1910
  // two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
@@ -1709,23 +1937,36 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1709
1937
  );
1710
1938
  }
1711
1939
  if (!options.keepState) assertWorldStateRemovable(root, instance);
1712
- // A legacy record can have been removed by an older reader while its external effects
1713
- // remain. Require a receipt for the exact prior instance, not merely absent claims/PIDs.
1940
+ // Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
1714
1941
  if (existsSync(instanceFile)) {
1942
+ let prior: WorldInstance | undefined;
1943
+ if (options.keepState) prior = readWorldBootRecordLocked(name, root);
1715
1944
  const priorRaw = readFileSync(instanceFile, 'utf8');
1716
- const prior = JSON.parse(priorRaw) as WorldInstance;
1717
1945
  const receiptPath = join(instance, 'stopped.json');
1718
1946
  const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
1719
- if (prior.name !== name || resolve(prior.root) !== root || !receipt
1720
- || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
1947
+ if (!receipt || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
1721
1948
  || !Number.isFinite(Date.parse(receipt.stoppedAt))) {
1722
1949
  throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
1723
1950
  }
1951
+ if (!prior) { try { prior = JSON.parse(priorRaw) as WorldInstance; } catch { /* fresh boot needs no retained shape */ } }
1952
+ if (prior) {
1953
+ // A readable ceiling remains useful even when unrelated metadata is incomplete.
1954
+ try {
1955
+ const permissionRecord = { ...prior, name, root };
1956
+ if (!Object.hasOwn(permissionRecord, 'networkCeiling')) {
1957
+ let storedConfig = loaded.config;
1958
+ try { storedConfig = loadWorldConfig(prior.configPath, root).config; } catch { /* fresh boot can discard missing config metadata */ }
1959
+ permissionRecord.networkCeiling = ceilingFromConfig(permissionRecord, storedConfig);
1960
+ }
1961
+ const ceiling = recordedWorldNetwork(permissionRecord);
1962
+ if (ceiling !== undefined) carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
1963
+ } catch { if (options.keepState) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
1964
+ }
1724
1965
  // the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
1725
1966
  // same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
1726
1967
  // ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
1727
1968
  // only when its state (the mirror they count) is kept, as reset drops them
1728
- if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
1969
+ if (prior?.origin && typeof prior.origin.url === 'string' && typeof prior.origin.namespace === 'string' && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
1729
1970
  carriedOrigin = options.keepState ? prior.origin
1730
1971
  : { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
1731
1972
  }
@@ -1736,7 +1977,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1736
1977
  retainWorldLifecycleForCleanup(root, name);
1737
1978
  // The last run's logs survive this boot as logs/previous (one generation): the record of a crash must outlive the
1738
1979
  // restart that usually follows it. Moved aside before the wipe and back in after it.
1739
- const previousLogs = join(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
1980
+ const previousLogs = ownedPath(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
1740
1981
  if (existsSync(previousLogs) && !existsSync(logs)) { mkdirSync(instance, { recursive: true }); renameSync(previousLogs, logs); } // an earlier failed claim's, back first
1741
1982
  rmSync(previousLogs, { recursive: true, force: true });
1742
1983
  if (existsSync(logs)) { rmSync(join(logs, 'previous'), { recursive: true, force: true }); renameSync(logs, previousLogs); }
@@ -1762,7 +2003,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1762
2003
  // the failure that stopped the boot leads: a release that also fails is added to it, never put in its place
1763
2004
  if (resourcesClaimed) {
1764
2005
  try { releaseWorldLifecycle(root, name); }
1765
- catch (release) { if (error instanceof Error) error.message += `; releasing the World's claim failed too: ${release instanceof Error ? release.message : String(release)}`; }
2006
+ catch (release) { throw new AggregateError([error, release], 'World claim and release failed'); }
1766
2007
  }
1767
2008
  throw error;
1768
2009
  }
@@ -1772,8 +2013,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1772
2013
  // "already running" guard; on failure the world never came up, so the next upWorld must be
1773
2014
  // allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
1774
2015
  // point (a foreign live claim made the claim section above throw before this try).
2016
+ let bootFailed = false;
2017
+ let bootFailure: unknown;
1775
2018
  try {
1776
2019
  const services: Record<string, WorldServiceInstance> = {};
2020
+ const recordService = (started: WorldServiceInstance) => {
2021
+ const branchState = loaded.config.services.find(service => service.id === started.id)?.branchState;
2022
+ services[started.id] = { ...started, branchState: branchState ?? 'supported' };
2023
+ };
1777
2024
  const inheritedEnv = admittedEnv(loaded.config.stripEnv);
1778
2025
  const env: Record<string, string> = {
1779
2026
  ...(loaded.config.env ?? {}),
@@ -1784,10 +2031,11 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1784
2031
  // the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
1785
2032
  // on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
1786
2033
  VOLTER_WORLD: name,
1787
- ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
2034
+ ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network, carriedNetwork) ?? {}),
1788
2035
  VOLTER_WORLD_MODE: mode,
1789
2036
  VOLTER_WORLD_CONFIG: loaded.path,
1790
2037
  VOLTER_WORLD_INSTANCE: instanceFile,
2038
+ VOLTER_WORLD_GUEST_RUNNER: new URL(import.meta.url.endsWith('.ts') ? './guest-runner.ts' : './guest-runner.js', import.meta.url).href,
1791
2039
  // every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
1792
2040
  [WORLD_DATA_ENV]: data,
1793
2041
  // the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
@@ -1810,8 +2058,10 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1810
2058
  writeProxyEnv(root, name, env);
1811
2059
 
1812
2060
  const worldInstance: WorldInstance = {
1813
- name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
2061
+ name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
1814
2062
  dirs: { instance, logs, data }, services, env, envFile, pidsFile,
2063
+ networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]!))),
2064
+ bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
1815
2065
  ...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
1816
2066
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1817
2067
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
@@ -1832,9 +2082,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1832
2082
  abort?.removeEventListener('abort', onOwnerEnd);
1833
2083
  };
1834
2084
  const boot: BootContext = {
1835
- bootId: randomUUID(), signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(),
1836
- external: service => { services[service.id] = service; saveWorldInstance(worldInstance); },
1837
- recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids]),
2085
+ bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
2086
+ external: service => { recordService(service); saveWorldInstance(worldInstance); },
2087
+ recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
1838
2088
  };
1839
2089
  const recordPidsSoFar = boot.recordPids;
1840
2090
  try {
@@ -1854,7 +2104,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1854
2104
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
1855
2105
  const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
1856
2106
  for (const started of host.services) {
1857
- services[started.id] = started;
2107
+ recordService(started);
1858
2108
  Object.assign(env, started.env);
1859
2109
  }
1860
2110
  colocatedReady = async () => {
@@ -1902,7 +2152,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1902
2152
  attemptProxy();
1903
2153
  boot.signal.throwIfAborted();
1904
2154
  const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
1905
- services[started.id] = started;
2155
+ recordService(started);
1906
2156
  recordPidsSoFar();
1907
2157
  Object.assign(env, started.env);
1908
2158
  await issueCredentials(service, started, env, issuing, unissued);
@@ -1940,7 +2190,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1940
2190
  try {
1941
2191
  return await shareWorldServices(name, { root, ...(options.share ?? {}) });
1942
2192
  } catch (error) {
1943
- await downWorld(name, root);
2193
+ await downWorld(name, root, { expectedInstance: instanceToken });
1944
2194
  throw error;
1945
2195
  }
1946
2196
  }
@@ -1950,12 +2200,12 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1950
2200
  detachBootSignals();
1951
2201
  // Rollback never lets its own failure replace the boot's: each step's error is added to the original.
1952
2202
  const rollbackErrors: string[] = [];
1953
- try { stopWorldProxy(root, name); } catch (proxyError) { rollbackErrors.push(`proxy stop failed: ${proxyError instanceof Error ? proxyError.message : String(proxyError)}`); }
2203
+ try { await stopWorldProxy(root, name, boot.handles); } catch (proxyError) { rollbackErrors.push(`proxy stop failed: ${errorMessage(proxyError)}`); }
1954
2204
  const pids = [...new Set([...Object.values(services).map((service) => service.pid).filter(Boolean), ...boot.pids])];
1955
2205
  const rollbackAlive = undeadPids(Object.values(services).map((service) => service.pid).filter(Boolean));
1956
2206
  const rollbackLogs = Object.values(services).filter((service) => service.type !== 'external' && service.log && rollbackAlive.includes(service.pid)).map((service) => service.log);
1957
2207
  recordWorldStop(rollbackLogs, 'world.service.stop', 'boot rollback', 'SIGTERM');
1958
- signalPids(pids, 'SIGTERM');
2208
+ await signalPids(pids, 'SIGTERM', boot.identities, boot.handles);
1959
2209
  // Stop any self-managed externals that DID come up, so a failed `up` never leaves a
1960
2210
  // half-running external stack behind. Best-effort: the original error is what we throw —
1961
2211
  // but a `down` that ITSELF fails must not be silent (the operator would have no hint that
@@ -1969,28 +2219,28 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1969
2219
  try {
1970
2220
  await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
1971
2221
  } catch (downError) {
1972
- rollbackErrors.push(downError instanceof Error ? downError.message : String(downError));
2222
+ rollbackErrors.push(errorMessage(downError));
1973
2223
  }
1974
2224
  }
1975
2225
  }
1976
2226
  // Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
1977
2227
  // outlive a failed boot either. The poll exits as soon as everything is dead, so the
1978
2228
  // common case (services honor SIGTERM) adds no delay to the failure path.
1979
- const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
2229
+ const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT, boot.identities, boot.handles);
1980
2230
  recordWorldStop(Object.values(services).filter((service) => rollbackKilled.includes(service.pid) && service.log).map((service) => service.log), 'world.service.killed', 'boot rollback', 'SIGKILL');
1981
2231
  const surviving = undeadPids(pids);
1982
2232
  if (surviving.length) rollbackErrors.push('owned processes remain alive or uncertain');
1983
- if (error instanceof CommandRetirementError) rollbackErrors.push((error as CommandRetirementError & { retirement?: string }).retirement ?? error.message);
2233
+ if (error instanceof CommandRetirementError) rollbackErrors.push((error as CommandRetirementError & { retirement?: string }).retirement ?? errorMessage(error));
1984
2234
 
1985
2235
  if (rollbackErrors.length > 0) {
1986
2236
  cleanupUncertain = true;
1987
2237
  try { retainWorldLifecycleForCleanup(root, name); saveWorldInstance(worldInstance); }
1988
- catch (bookkeepingError) { rollbackErrors.push(`rollback bookkeeping failed: ${bookkeepingError instanceof Error ? bookkeepingError.message : String(bookkeepingError)}`); }
2238
+ catch (bookkeepingError) { rollbackErrors.push(`rollback bookkeeping failed: ${errorMessage(bookkeepingError)}`); }
1989
2239
  const suffix = `\n(rollback incomplete — processes or an external stack may still be running: ${rollbackErrors.join('; ')})`;
1990
2240
  // Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
1991
2241
  // leading) and the original stack/type stay intact.
1992
2242
  if (error instanceof Error) error.message += suffix;
1993
- else throw new Error(`${String(error)}${suffix}`);
2243
+ else throw new Error(`${errorMessage(error)}${suffix}`);
1994
2244
  }
1995
2245
  if (rollbackErrors.length === 0) {
1996
2246
  // Publication failure must retain ownership. Prune requires both a receipt and release.
@@ -2002,28 +2252,38 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
2002
2252
  releaseWorldLifecycle(root, name);
2003
2253
  cleanupUncertain = false;
2004
2254
  } catch (receiptError) {
2005
- const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${receiptError instanceof Error ? receiptError.message : String(receiptError)}; lifecycle retained)`;
2006
- if (error instanceof Error) error.message += note; else throw new Error(`${String(error)}${note}`);
2255
+ const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${errorMessage(receiptError)}; lifecycle retained)`;
2256
+ if (error instanceof Error) error.message += note; else throw new Error(`${errorMessage(error)}${note}`);
2007
2257
  }
2008
2258
  }
2009
2259
  throw error;
2010
2260
  }
2011
2261
 
2012
2262
  } catch (error) {
2263
+ bootFailed = true;
2264
+ bootFailure = error;
2013
2265
  if (resourcesClaimed) {
2014
- try { recordWorldLifecycleEvent(root, name, `boot failed: ${error instanceof Error ? error.message : String(error)}`); }
2015
- catch (diagnosticError) { process.stderr.write(`World "${name}": could not save boot failure: ${String(diagnosticError)}; original failure: ${String(error)}\n`); }
2016
- if (!cleanupUncertain) releaseWorldLifecycle(root, name);
2266
+ try { recordWorldLifecycleEvent(root, name, `boot failed: ${errorMessage(error)}`); }
2267
+ catch (diagnosticError) { process.stderr.write(`World "${name}": could not save boot failure: ${errorMessage(diagnosticError)}; original failure: ${errorMessage(error)}\n`); }
2268
+ if (!cleanupUncertain) {
2269
+ try { releaseWorldLifecycle(root, name); }
2270
+ catch (release) { bootFailure = new AggregateError([error, release], 'World startup and lifecycle release failed'); }
2271
+ }
2017
2272
  }
2018
- throw error;
2273
+ throw bootFailure;
2019
2274
  } finally {
2020
- rmSync(bootingFile(root, name), { force: true });
2275
+ try { rmSync(bootingFile(root, name), { force: true }); }
2276
+ catch (cleanup) {
2277
+ if (bootFailed) throw new AggregateError([bootFailure, cleanup], 'World startup and boot marker cleanup failed');
2278
+ throw cleanup;
2279
+ }
2021
2280
  }
2022
2281
  }
2023
2282
 
2024
- async function teardownExternalServices(instance: WorldInstance): Promise<{ stopped: string[]; errors: string[] }> {
2283
+ async function teardownExternalServices(instance: WorldInstance): Promise<{ stopped: string[]; errors: string[]; unavailable: string[] }> {
2025
2284
  const stopped: string[] = [];
2026
2285
  const errors: string[] = [];
2286
+ const unavailable: string[] = [];
2027
2287
  for (const service of Object.values(instance.services)) {
2028
2288
  if (service.type !== 'external') continue;
2029
2289
  if (!service.external || !Array.isArray(service.external.down) || !service.external.down.length
@@ -2036,10 +2296,12 @@ async function teardownExternalServices(instance: WorldInstance): Promise<{ stop
2036
2296
  await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
2037
2297
  stopped.push(service.id);
2038
2298
  } catch (error) {
2039
- errors.push(error instanceof Error ? error.message : String(error));
2299
+ const message = errorMessage(error);
2300
+ errors.push(message);
2301
+ if (error instanceof ExternalCommandUnavailableError) unavailable.push(message);
2040
2302
  }
2041
2303
  }
2042
- return { stopped, errors };
2304
+ return { stopped, errors, unavailable };
2043
2305
  }
2044
2306
 
2045
2307
  /** SIGTERM contract (documented in docs/concepts/worlds.md § Teardown): services get SIGTERM to their process
@@ -2050,18 +2312,16 @@ const DOWN_GRACE_MS_DEFAULT = 5_000;
2050
2312
  /** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
2051
2313
  * leader), falling back to the single pid when the group signal fails. Returns the pids that
2052
2314
  * accepted the signal. */
2053
- function signalPids(pids: number[], signal: NodeJS.Signals): number[] {
2315
+ async function signalPids(pids: number[], signal: NodeJS.Signals, identities: Record<string, string | undefined>, handles?: Map<number, ReturnType<typeof spawn>>): Promise<number[]> {
2054
2316
  const signalled: number[] = [];
2055
2317
  for (const pid of pids) {
2318
+ if (!undeadPids([pid]).length) continue;
2319
+ const identity = Object.hasOwn(identities, pid) ? { pid, start: identities[pid] } : undefined;
2056
2320
  try {
2057
- process.kill(-pid, signal);
2058
- signalled.push(pid);
2059
- } catch {
2060
- try {
2061
- process.kill(pid, signal);
2062
- signalled.push(pid);
2063
- } catch {}
2064
- }
2321
+ const child = handles?.get(pid);
2322
+ if (child ? await signalHeldChild(child, signal, true, Promise.resolve(identity))
2323
+ : await signalOwnedProcess(identity, signal, true) || await signalOwnedProcess(identity, signal)) signalled.push(pid);
2324
+ } catch (error) { process.stderr.write(`${errorMessage(error)}\n`); }
2065
2325
  }
2066
2326
  return signalled;
2067
2327
  }
@@ -2082,13 +2342,13 @@ const KILL_CONFIRM_MS = 2_000;
2082
2342
  * exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
2083
2343
  * actually gone (not just signalled) by the time this returns. Returns the pids that needed
2084
2344
  * escalation. */
2085
- async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise<number[]> {
2345
+ async function killSurvivorsAfterGrace(pids: number[], graceMs: number, identities: Record<string, string | undefined>, handles?: Map<number, ReturnType<typeof spawn>>): Promise<number[]> {
2086
2346
  const deadline = Date.now() + graceMs;
2087
2347
  while (undeadPids(pids).length > 0 && Date.now() < deadline) {
2088
2348
  await new Promise((resolveWait) => setTimeout(resolveWait, 50));
2089
2349
  }
2090
2350
  const survivors = undeadPids(pids);
2091
- signalPids(survivors, 'SIGKILL');
2351
+ await signalPids(survivors, 'SIGKILL', identities, handles);
2092
2352
  const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
2093
2353
  while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
2094
2354
  await new Promise((resolveWait) => setTimeout(resolveWait, 20));
@@ -2096,31 +2356,35 @@ async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise
2096
2356
  return survivors;
2097
2357
  }
2098
2358
 
2099
- /** Synchronous twin of `killSurvivorsAfterGrace` for callers (`unshareWorld`) that must stay
2100
- * synchronous — same "poll until dead or grace expires, then SIGKILL survivors, then confirm they
2101
- * are actually gone" contract, using an `Atomics.wait` backoff (already this file's pattern for
2102
- * synchronous polling — see `ensureWorldProxy`) instead of `setTimeout`, since a sync function has
2103
- * no event loop to yield to. */
2104
- function killSurvivorsAfterGraceSync(pids: number[], graceMs: number): number[] {
2105
- const deadline = Date.now() + graceMs;
2106
- while (undeadPids(pids).length > 0 && Date.now() < deadline) {
2107
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
2108
- }
2109
- const survivors = undeadPids(pids);
2110
- signalPids(survivors, 'SIGKILL');
2359
+ /** The same bounded, identity-checked retirement used by World down, for provider-owned groups.
2360
+ * No caller may release a durable receipt when inspection or signal delivery is uncertain. */
2361
+ export async function retireOwnedProcessGroups(records: SpawnIdentity[], graceMs = DOWN_GRACE_MS_DEFAULT): Promise<void> {
2362
+ const pids = [...new Set(records.map(record => record.pid))];
2363
+ const identities = Object.fromEntries(records.map(record => [record.pid, record.start]));
2364
+ await signalPids(pids, 'SIGCONT', identities);
2365
+ await signalPids(pids, 'SIGTERM', identities);
2366
+ await killSurvivorsAfterGrace(pids, graceMs, identities);
2367
+ const remaining = undeadPids(pids);
2368
+ if (remaining.length) throw new CommandRetirementError(`Guest process groups still alive or uninspectable: ${remaining.join(', ')}; ownership retained`);
2369
+ }
2370
+
2371
+ /** The synchronous proxy starter can retire only its still-held child/group. Numeric
2372
+ * recovery belongs to async downWorld, never to a blocking identity lookup. */
2373
+ function killHeldSurvivorsSync(child: ReturnType<typeof spawn>, graceMs: number): void {
2374
+ if (!child.pid) return;
2375
+ const pids = [child.pid], deadline = Date.now() + graceMs;
2376
+ while (undeadPids(pids).length && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
2377
+ if (undeadPids(pids).length) signalChild(child, 'SIGKILL', true);
2111
2378
  const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
2112
- while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
2113
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
2114
- }
2115
- return survivors;
2379
+ while (undeadPids(pids).length && Date.now() < confirmDeadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
2116
2380
  }
2117
2381
 
2118
- /** Async solely for the SIGTERM→SIGKILL grace poll — everything else (proxy stop, SIGTERM,
2119
- * external teardown) still runs in the first synchronous slice, so a legacy caller that fires
2120
- * without awaiting gets exactly the old behavior plus the deferred escalation. A blocking
2121
- * sleep-loop would keep the sync signature but stall the caller's event loop for up to the whole
2122
- * grace; a detached watchdog child would escalate too but leak orphan processes — the await is
2123
- * the honest shape (upWorld/runWorld/the CLI are already async). */
2382
+ /** Retirement awaits birth verification for recorded numbers and the SIGTERM→SIGKILL
2383
+ * grace/confirmation polls. Callers await downWorld before releasing ownership or purging
2384
+ * instance state; held children can receive their initial signal without a birth lookup.
2385
+ * External teardown and proxy retirement are joined in that same lifecycle.
2386
+ * A blocking sleep-loop would stall the caller's event loop through the grace, and a detached
2387
+ * watchdog would leave an unjoined process; lifecycle owners await retirement instead. */
2124
2388
  /**
2125
2389
  * Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
2126
2390
  * twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
@@ -2156,20 +2420,27 @@ class WorldConsumersActiveError extends Error {}
2156
2420
  export async function downWorld(
2157
2421
  name: string,
2158
2422
  root = process.cwd(),
2159
- options: { graceMs?: number; purge?: boolean; expectedCreatedAt?: string } = {},
2160
- ): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; notSignalled?: string[]; purged?: string }> {
2423
+ options: { graceMs?: number; purge?: boolean; expectedInstance?: string } = {},
2424
+ ): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; externalAbandoned?: string[]; notSignalled?: string[]; purged?: string }> {
2425
+ if (Object.hasOwn(options, 'expectedCreatedAt')) throw new Error('World teardown requires expectedInstance from the original boot');
2161
2426
  assertSafeWorldName(name);
2162
2427
  const resolvedRoot = resolve(root);
2163
2428
  const token = randomUUID();
2164
2429
  // Like boot's claim: a short synchronous lock protects an async lifecycle transition.
2165
2430
  // Do not hold a filesystem lock while external shutdown or the grace period waits.
2166
2431
  withFileLock(instanceLockFile(resolvedRoot, name), () => withFileLock(`${sharingClaimFile(resolvedRoot, name)}.lock`, () => {
2432
+ if (options.expectedInstance !== undefined) {
2433
+ try {
2434
+ const raw = getActiveWorldStore().read(instanceFile(resolvedRoot, name));
2435
+ const recorded = raw === null ? undefined : JSON.parse(raw);
2436
+ if (recorded?.name !== name || typeof recorded.root !== 'string' || resolve(recorded.root) !== resolvedRoot
2437
+ || typeof options.expectedInstance !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(options.expectedInstance)
2438
+ || recorded.instance !== options.expectedInstance) throw new Error();
2439
+ } catch { throw new Error(`World ownership mismatch: ${name}`); }
2440
+ }
2167
2441
  assertProxyOwnershipResolved(resolvedRoot, name);
2168
2442
  const prior = existsSync(instanceFile(resolvedRoot, name)) ? readWorldInstance(name, resolvedRoot, true) : undefined;
2169
2443
  if (prior?.resources && !prior.lifecycle) throw new Error(`World "${name}" belongs to a legacy runtime; use its pinned runtime for teardown before migration`);
2170
- if (options.expectedCreatedAt !== undefined && readWorldInstance(name, resolvedRoot, true).createdAt !== options.expectedCreatedAt) {
2171
- throw new Error(`World "${name}" generation changed; teardown refused`);
2172
- }
2173
2444
  if (options.purge) assertWorldStateRemovable(resolvedRoot, instanceDir(resolvedRoot, name));
2174
2445
  prepareBootForDown(resolvedRoot, name);
2175
2446
  if (liveBootingClaim(stoppingFile(resolvedRoot, name))) throw new Error(`World "${name}" is already being stopped`);
@@ -2200,7 +2471,7 @@ async function downWorldClaimed(
2200
2471
  name: string,
2201
2472
  root: string,
2202
2473
  options: { graceMs?: number; purge?: boolean },
2203
- ): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; notSignalled?: string[]; purged?: string }> {
2474
+ ): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; externalAbandoned?: string[]; notSignalled?: string[]; purged?: string }> {
2204
2475
  assertSafeWorldName(name);
2205
2476
  const resolvedRoot = resolve(root);
2206
2477
  const dir = instanceDir(resolvedRoot, name);
@@ -2217,6 +2488,7 @@ async function downWorldClaimed(
2217
2488
  } catch {
2218
2489
  instance = undefined;
2219
2490
  }
2491
+ readGuestProcesses(dir, instance?.instance); // missing World metadata never erases guest ownership
2220
2492
  // Nothing to tear down when the metadata is gone and every process the lifecycle recorded has exited.
2221
2493
  if (!instance && before === undefined && releaseAbandonedWorldLifecycle(resolvedRoot, name)) {
2222
2494
  return { name, stopped: [], escalated: [], externalStopped: [], externalErrors: [] };
@@ -2225,28 +2497,36 @@ async function downWorldClaimed(
2225
2497
  return { name, stopped: [], escalated: [], externalStopped: [],
2226
2498
  externalErrors: ['World instance or external teardown metadata missing or unreadable; cleanup refused; lifecycle evidence retained'] };
2227
2499
  }
2228
- const proxyPid = readProxyState(resolvedRoot, name)?.pid;
2229
- stopWorldProxy(resolvedRoot, name);
2500
+ const proxyState = readProxyState(resolvedRoot, name);
2501
+ const proxyPid = proxyState?.pid;
2502
+ await stopWorldProxy(resolvedRoot, name, spawnedChildren);
2230
2503
  const foregroundPid = instance?.lastRun?.state !== 'completed' ? instance?.lastRun?.consumerPid : undefined;
2231
2504
  const holderPid = instance?.lifecycle?.holderPid ?? instance?.resources?.holderPid;
2232
2505
  const recordedPids = [...new Set([...readPids(pidsFile), ...(foregroundPid ? [foregroundPid] : []), ...(proxyPid ? [proxyPid] : [])])].filter(pid => pid !== holderPid);
2233
- const foreign = foreignPids(resolvedRoot, name, recordedPids);
2506
+ const identities = readIdentities(resolvedRoot, name);
2507
+ if (proxyState?.birth) identities[proxyState.pid] = proxyState.birth;
2508
+ const foreign = (await Promise.all(recordedPids.map(async pid => {
2509
+ const now = identities[pid] ? await processBirth(pid) : undefined;
2510
+ return now && now !== identities[pid] ? { pid, recorded: identities[pid]!, now } : undefined;
2511
+ }))).filter((entry): entry is { pid: number; recorded: string; now: string } => !!entry);
2234
2512
  const pids = recordedPids.filter((pid) => !foreign.some((entry) => entry.pid === pid));
2235
2513
  const processServices = Object.values(instance?.services ?? {}).filter((service) => service.type !== 'external' && service.log);
2236
2514
  // Only a service still alive is stopped by this `down`; a dead one's end was not the World's.
2237
2515
  const stillAlive = undeadPids(processServices.map((service) => service.pid).filter((pid) => pids.includes(pid)));
2238
2516
  recordWorldStop(processServices.filter((service) => stillAlive.includes(service.pid)).map((service) => service.log), 'world.service.stop', 'down', 'SIGTERM');
2239
- const stopped = signalPids(pids, 'SIGTERM');
2517
+ const stopped = await signalPids(pids, 'SIGTERM', identities, spawnedChildren);
2240
2518
 
2241
2519
  // Stop self-managed externals via their declared `down` command. A missing instance is
2242
2520
  // safe only without retained lifecycle ownership, checked before any signals above.
2243
2521
  let externalStopped: string[] = [];
2244
2522
  let externalErrors: string[] = [];
2523
+ let unavailable: string[] = [];
2245
2524
  try {
2246
2525
  if (!instance) throw new Error('World instance unavailable');
2247
2526
  const result = await teardownExternalServices(instance);
2248
2527
  externalStopped = result.stopped;
2249
2528
  externalErrors = result.errors;
2529
+ unavailable = result.unavailable;
2250
2530
  } catch {
2251
2531
  // no instance.json (never fully came up) — only owned processes needed stopping.
2252
2532
  if (before !== undefined) externalErrors.push('World instance or external teardown metadata unreadable; cleanup refused');
@@ -2255,12 +2535,18 @@ async function downWorldClaimed(
2255
2535
  // Escalate AFTER the external teardown so externals never wait on the grace, and
2256
2536
  // poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
2257
2537
  // already dead, and livePids() re-checks anyway.
2258
- const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
2538
+ const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren);
2259
2539
  recordWorldStop(processServices.filter((service) => escalated.includes(service.pid)).map((service) => service.log), 'world.service.killed', 'down', 'SIGKILL');
2540
+ // A launch which had already entered its spawn seam may publish while TERM is delivered.
2541
+ // Once services/agents retire, join every late receipt before certifying this generation stopped.
2542
+ const lateGuests = readGuestProcesses(dir, instance?.instance).filter(receipt => !pids.includes(receipt.record.pid));
2543
+ try { await retireOwnedProcessGroups(lateGuests.map(receipt => receipt.record), options.graceMs ?? DOWN_GRACE_MS_DEFAULT); }
2544
+ catch (error) { externalErrors.push(errorMessage(error)); }
2545
+ pids.push(...lateGuests.map(receipt => receipt.record.pid));
2260
2546
  if (holderPid && !externalErrors.length && !undeadPids(pids).length) {
2261
2547
  pids.push(holderPid);
2262
- stopped.push(...signalPids([holderPid], 'SIGTERM'));
2263
- escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT));
2548
+ stopped.push(...await signalPids([holderPid], 'SIGTERM', identities, spawnedChildren));
2549
+ escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren));
2264
2550
  }
2265
2551
 
2266
2552
  // Finalization belongs to this exact generation, not just the reusable name. A new up may
@@ -2278,20 +2564,36 @@ async function downWorldClaimed(
2278
2564
  externalErrors.push('Proxy or sharing lifecycle changed during teardown; cleanup refused');
2279
2565
  return result;
2280
2566
  }
2281
- const surviving = undeadPids(pids);
2567
+ const guestReceipts = readGuestProcesses(dir, instance?.instance);
2568
+ const surviving = undeadPids([...pids, ...guestReceipts.map(receipt => receipt.record.pid)]);
2282
2569
  if (surviving.length) externalErrors.push(`World processes still alive or uninspectable after teardown: ${surviving.join(', ')}; cleanup refused`);
2570
+ if (guestReceipts.some(receipt => foreign.some(entry => entry.pid === receipt.record.pid))) externalErrors.push('Guest spawn identity changed; receipt retained and cleanup refused');
2571
+ let externalAbandoned: string[] | undefined;
2572
+ if (options.purge && instance && externalErrors.length && externalErrors.length === unavailable.length && !foreign.length) {
2573
+ const inventory = exitedWorldLifecyclePids(resolvedRoot, name, instance.createdAt);
2574
+ if (inventory && !undeadPids([...pids, ...inventory, ...(holderPid ? [holderPid] : [])]).length) {
2575
+ const consumers = worldConsumers(resolvedRoot, name);
2576
+ if (!consumers.warnings.length && consumers.consumers.every(c => c.state === 'exited')) {
2577
+ externalAbandoned = [...externalErrors];
2578
+ recordWorldLifecycleEvent(resolvedRoot, name, `explicit purge surrendered state; unavailable backend not certified stopped:\n${externalAbandoned.join('\n')}`);
2579
+ externalErrors.splice(0);
2580
+ }
2581
+ }
2582
+ }
2283
2583
  if (externalErrors.length) return result;
2584
+ releaseGuestProcesses(guestReceipts);
2284
2585
  rmSync(pidsFile, { force: true });
2285
2586
  if (before) {
2286
2587
  writeTextAtomic(join(dir, 'stopped.json'), `${JSON.stringify({
2287
2588
  instanceHash: createHash('sha256').update(before).digest('hex'), stoppedAt: new Date().toISOString(),
2589
+ ...(externalAbandoned ? { externalAbandoned } : {}),
2288
2590
  })}\n`);
2289
2591
  }
2290
- releaseWorldLifecycle(resolvedRoot, name);
2592
+ releaseWorldLifecycle(resolvedRoot, name, externalAbandoned ? 'recorded processes exited; explicit purge surrendered state; ownership released' : undefined);
2291
2593
  if (!options.purge) return result;
2292
2594
  const purged = purgeInstanceDir(resolvedRoot, name);
2293
2595
  if (purged && instance) clearBrowserFiles(instance.dirs.data, resolvedRoot);
2294
- return { ...result, purged };
2596
+ return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
2295
2597
  }));
2296
2598
  }
2297
2599
 
@@ -2397,7 +2699,7 @@ function worldCleanupEntry(name: string, root: string): WorldCleanupEntry {
2397
2699
  return { ...entry, reason: 'invalid or outdated teardown receipt' };
2398
2700
  }
2399
2701
  try { assertWorldStateRemovable(root, path); }
2400
- catch (error) { return { ...entry, reason: error instanceof Error ? error.message : 'Branch ancestry unreadable; retained' }; }
2702
+ catch (error) { return { ...entry, reason: errorMessage(error) }; }
2401
2703
  return { ...entry, eligible: true, reason: 'successfully stopped; disposable instance data only', stoppedAt: receipt.stoppedAt, logicalBytes: logicalTreeBytes(path) };
2402
2704
  } catch (error) {
2403
2705
  // Do not print malformed metadata: it can contain env secrets.
@@ -2434,7 +2736,7 @@ export function pruneWorlds(options: { root?: string; name?: string; apply?: boo
2434
2736
  function worldServiceControlRoot(name: string, service: string, root = process.cwd()): string {
2435
2737
  const instance = readWorldInstance(name, resolve(root));
2436
2738
  if (!instance.services[service]) throw new Error(`World ${name} has no service "${service}"`);
2437
- return join(instance.dirs.data, service);
2739
+ return ownedPath(instance.dirs.data, service);
2438
2740
  }
2439
2741
 
2440
2742
  function worldServiceStateService(controlRoot: string, requestedService: string): string {
@@ -2526,7 +2828,7 @@ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string
2526
2828
  });
2527
2829
  child.once('error', (error) => {
2528
2830
  exited = true;
2529
- exitSummary = error.message;
2831
+ exitSummary = errorMessage(error);
2530
2832
  });
2531
2833
 
2532
2834
  const started = Date.now();
@@ -2558,17 +2860,17 @@ function commandExists(command: string): boolean {
2558
2860
  return OWN_COMMANDS[command] !== undefined || onPath(command);
2559
2861
  }
2560
2862
 
2561
- /** A lifecycle command naming this runtime's own bin (`volter-world-infra`, which `init` names in every managed
2562
- * infrastructure's lifecycle) runs this runtime's own script with the executable running it, when nothing on PATH
2563
- * answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
2564
- * PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
2565
- * command on PATH still wins, as before. */
2863
+ /** Built-in lifecycle names belong to the executing runtime. PATH may contain another
2864
+ * checkout's package-manager bin links; an operator override names an explicit executable path. */
2566
2865
  const OWN_COMMANDS: Record<string, string> = { 'volter-world-infra': 'infra-cli' };
2567
2866
  export function ownCommand(command: string[]): string[] {
2568
2867
  const [bin, ...args] = command;
2569
2868
  const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
2570
- if (stem === undefined || onPath(bin!)) return command;
2571
- return [process.execPath, siblingScript(import.meta.url, stem), ...args];
2869
+ if (stem === undefined) return command;
2870
+ const script = siblingScript(import.meta.url, stem);
2871
+ const loader = typeof (globalThis as { Bun?: unknown }).Bun === 'undefined' && script.endsWith('.ts')
2872
+ ? ['--experimental-transform-types', '--no-warnings'] : [];
2873
+ return [process.execPath, ...loader, script, ...args];
2572
2874
  }
2573
2875
 
2574
2876
  /** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
@@ -2716,7 +3018,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
2716
3018
  }
2717
3019
 
2718
3020
  const tunnel = shareCommand(options, service.url);
2719
- const log = join(instance.dirs.logs, `${serviceId}.tunnel.log`);
3021
+ const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
2720
3022
  // Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
2721
3023
  // share from accepting the previous process's URL handshake as the new process's endpoint.
2722
3024
  const out = openPrivateLog(log, 'w');
@@ -2726,12 +3028,18 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
2726
3028
  detached: true,
2727
3029
  stdio: ['ignore', out, out],
2728
3030
  });
2729
- closeSync(out);
3031
+ const birth = retainSpawn(child);
3032
+ const identities: Record<string, string | undefined> = {};
3033
+ const handles = new Map(child.pid ? [[child.pid, child]] : []);
2730
3034
 
2731
3035
  let publicUrl: string;
2732
3036
  let publicVerification: WorldServiceInstance['publicVerification'];
2733
3037
  let pidsWritten = false;
2734
3038
  try {
3039
+ closeSync(out);
3040
+ const identity = await birth;
3041
+ if (!identity?.start) throw new Error('Tunnel birth capture failed; retiring held child');
3042
+ identities[identity.pid] = identity.start;
2735
3043
  publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
2736
3044
  if (options.verifyPath !== false) {
2737
3045
  publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
@@ -2764,8 +3072,8 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
2764
3072
  // gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
2765
3073
  // failed/rejected `share`).
2766
3074
  if (child.pid) {
2767
- signalPids([child.pid], 'SIGTERM');
2768
- await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
3075
+ await signalPids([child.pid], 'SIGTERM', identities, handles);
3076
+ await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT, identities, handles);
2769
3077
  }
2770
3078
  const cleanupProblems: string[] = [];
2771
3079
  if (child.pid && undeadPids([child.pid]).length > 0) cleanupProblems.push(`tunnel pid ${child.pid} survived cleanup`);
@@ -2774,10 +3082,10 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
2774
3082
  // file intact; this compensates the one successful write in a two-file persistence attempt.
2775
3083
  if (pidsWritten) {
2776
3084
  try { writePidsFromInstance(instance); }
2777
- catch (restoreError) { cleanupProblems.push(`could not restore pids file: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`); }
3085
+ catch (restoreError) { cleanupProblems.push(`could not restore pids file: ${errorMessage(restoreError)}`); }
2778
3086
  }
2779
3087
  if (cleanupProblems.length > 0) {
2780
- throw new Error(`${error instanceof Error ? error.message : String(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
3088
+ throw new Error(`${errorMessage(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
2781
3089
  }
2782
3090
  throw error;
2783
3091
  }
@@ -2832,11 +3140,9 @@ export async function shareWorldServices(name: string, options: ShareWorldServic
2832
3140
  * services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
2833
3141
  * tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
2834
3142
  * ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
2835
- * nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist. Stays
2836
- * synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
2837
- * (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
3143
+ * nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist. Retirement awaits asynchronous birth verification. Returns the instance plus the tunnel pids that needed escalating,
2838
3144
  * for callers that want to report it. */
2839
- export function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): WorldInstance & { escalated: number[] } {
3145
+ export async function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): Promise<WorldInstance & { escalated: number[] }> {
2840
3146
  const root = resolve(options.root ?? process.cwd());
2841
3147
  const instance = readWorldInstance(name, root);
2842
3148
  const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
@@ -2847,12 +3153,13 @@ export function unshareWorld(name: string, options: { root?: string; service?: s
2847
3153
  if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
2848
3154
  if (service.tunnel?.pid) {
2849
3155
  const pid = service.tunnel.pid;
2850
- signalPids([pid], 'SIGTERM');
2851
- const survivors = killSurvivorsAfterGraceSync([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
3156
+ const identities = readIdentities(root, name);
3157
+ await signalPids([pid], 'SIGTERM', identities);
3158
+ const survivors = await killSurvivorsAfterGrace([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities);
2852
3159
  if (survivors.length > 0) escalated.push(...survivors);
2853
3160
  // `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
2854
- // (exited but unreaped — likely since we just polled it synchronously via `Atomics.wait`,
2855
- // which blocks the event loop that would otherwise let this process auto-reap its child) still
3161
+ // (exited but unreaped, possibly awaiting its parent's reap) still
3162
+ // occupies a process-table entry and
2856
3163
  // answers `kill(pid, 0)` yet is dead for every practical purpose — see `survivingOwnedGroups`' own
2857
3164
  // comment. Using plain `livePids` here would treat every zombie as "still shared".
2858
3165
  if (undeadPids([pid]).length > 0) {
@@ -2908,7 +3215,7 @@ async function probeExternalOnce(
2908
3215
  if (response.status >= 200 && response.status < 400) return { ok: true, message: `probe ok (HTTP ${response.status})` };
2909
3216
  return { ok: false, message: `probe returned HTTP ${response.status}` };
2910
3217
  } catch (error) {
2911
- return { ok: false, message: `probe failed: ${error instanceof Error ? error.message : String(error)}` };
3218
+ return { ok: false, message: `probe failed: ${errorMessage(error)}` };
2912
3219
  }
2913
3220
  }
2914
3221
  if (ready.stdoutMatch !== undefined) {
@@ -2935,7 +3242,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
2935
3242
  instance = readWorldInstance(name, root);
2936
3243
  checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
2937
3244
  } catch (error) {
2938
- const message = error instanceof Error ? error.message : String(error);
3245
+ const message = errorMessage(error);
2939
3246
  return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
2940
3247
  }
2941
3248
 
@@ -2993,7 +3300,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
2993
3300
  await runExternalCommand(configuredExternal.status, probeCwd, service.id, 'doctor-probe', probeEnv);
2994
3301
  checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
2995
3302
  } catch (error) {
2996
- checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${error instanceof Error ? error.message : String(error)}` });
3303
+ checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${errorMessage(error)}` });
2997
3304
  }
2998
3305
  } else {
2999
3306
  checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; no probe declared — health unknown` });
@@ -3035,7 +3342,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3035
3342
  checks.push({
3036
3343
  id: `service:${service.id}:tcp`,
3037
3344
  ok: false,
3038
- message: error instanceof Error ? error.message : String(error),
3345
+ message: errorMessage(error),
3039
3346
  });
3040
3347
  }
3041
3348
  if (service.tunnel || service.publicUrl) {
@@ -3064,7 +3371,7 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3064
3371
  checks.push({
3065
3372
  id: `service:${service.id}:public-health`,
3066
3373
  ok: false,
3067
- message: error instanceof Error ? error.message : String(error),
3374
+ message: errorMessage(error),
3068
3375
  });
3069
3376
  }
3070
3377
  }
@@ -3155,7 +3462,7 @@ export async function runWorldOwned(configId: string, command: string[], options
3155
3462
  let failed = false;
3156
3463
  let retired = true;
3157
3464
  try {
3158
- outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { abort, verbose: options.verbose });
3465
+ outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { cwd: options.cwd, abort, verbose: options.verbose });
3159
3466
  instance.lastRun = outcome;
3160
3467
  saveWorldInstance(instance);
3161
3468
  } catch (error) {
@@ -3169,7 +3476,7 @@ export async function runWorldOwned(configId: string, command: string[], options
3169
3476
  if (!options.keep && retired) {
3170
3477
  try { await finishTaskWorld(instance); }
3171
3478
  catch (error) {
3172
- if (failed) throw new AggregateError([failure, error], `World execution and cleanup failed: ${String(failure)}; ${String(error)}`);
3479
+ if (failed) throw new AggregateError([failure, error], `World execution and cleanup failed: ${errorMessage(failure)}; ${errorMessage(error)}`);
3173
3480
  throw error;
3174
3481
  }
3175
3482
  }
@@ -3182,7 +3489,7 @@ async function finishTaskWorld(instance: WorldInstance): Promise<void> {
3182
3489
  let uncertainSince: number | undefined;
3183
3490
  for (;;) {
3184
3491
  try {
3185
- const result = await downWorld(instance.name, instance.root, { expectedCreatedAt: instance.createdAt });
3492
+ const result = await downWorld(instance.name, instance.root, { expectedInstance: instance.instance });
3186
3493
  if (result.externalErrors.length) {
3187
3494
  const message = `World "${instance.name}" cleanup incomplete: ${result.externalErrors.join('; ')}. Reservation and diagnostics retained.`;
3188
3495
  // The caller that would have read this on stderr may be gone; the lifecycle log keeps it.
@@ -3245,7 +3552,7 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false): {
3245
3552
  } else if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
3246
3553
  } catch (error) {
3247
3554
  if (error instanceof CaTrustError) throw error;
3248
- if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${error instanceof Error ? error.message : String(error)})`);
3555
+ if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
3249
3556
  // Keep env-only redirect if the ambient proxy cannot start.
3250
3557
  }
3251
3558
  return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
@@ -3279,7 +3586,10 @@ export function runWithWorldEnv(name: string, command: string[], root = process.
3279
3586
  env: { ...attached.env, ...(options.env ?? {}) },
3280
3587
  stdio: 'inherit',
3281
3588
  });
3282
- const forward = (signal: NodeJS.Signals) => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
3589
+ const forward = (signal: NodeJS.Signals) => {
3590
+ try { if (child.pid && child.exitCode === null && child.signalCode === null) signalChild(child, signal); }
3591
+ catch (error) { process.stderr.write(`${errorMessage(error)}\n`); }
3592
+ };
3283
3593
  process.on('SIGINT', forward);
3284
3594
  process.on('SIGTERM', forward);
3285
3595
  child.once('error', () => { offSignal('SIGINT', forward); offSignal('SIGTERM', forward); resolveExit(1); });
@@ -3421,7 +3731,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
3421
3731
  try {
3422
3732
  attached = worldAttachedCommandEnv(name, root, options.verbose);
3423
3733
  } catch (cause) {
3424
- return finish(1, undefined, cause instanceof Error ? cause.message : String(cause));
3734
+ return finish(1, undefined, errorMessage(cause));
3425
3735
  }
3426
3736
 
3427
3737
  return await new Promise<WorldRunOutcome>((resolveOutcome, rejectOutcome) => {
@@ -3435,21 +3745,21 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
3435
3745
  detached: commandProcessGroup,
3436
3746
  });
3437
3747
  } catch (cause) {
3438
- resolveOutcome(finish(1, undefined, cause instanceof Error ? cause.message : String(cause)));
3748
+ resolveOutcome(finish(1, undefined, errorMessage(cause)));
3439
3749
  return;
3440
3750
  }
3441
3751
 
3442
3752
  const lifetime = commandLifetime(child, options.abort);
3443
3753
  child.stdout.on('data', (chunk: Buffer) => {
3444
3754
  try { process.stdout.write(chunk); writeSync(logFd, chunk); }
3445
- catch (error) { spawnError = String(error); lifetime.cancel('SIGTERM'); }
3755
+ catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
3446
3756
  });
3447
3757
  child.stderr.on('data', (chunk: Buffer) => {
3448
3758
  try { process.stderr.write(chunk); writeSync(logFd, chunk); }
3449
- catch (error) { spawnError = String(error); lifetime.cancel('SIGTERM'); }
3759
+ catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
3450
3760
  });
3451
3761
  child.once('error', (cause) => {
3452
- spawnError = cause.message;
3762
+ spawnError = errorMessage(cause);
3453
3763
  });
3454
3764
  const activeRecord: WorldRunRecord = {
3455
3765
  state: 'running',
@@ -3459,7 +3769,7 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
3459
3769
  log,
3460
3770
  };
3461
3771
  try { writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(activeRecord, null, 2)}\n`); }
3462
- catch (error) { spawnError = String(error); lifetime.cancel('SIGTERM'); }
3772
+ catch (error) { spawnError = errorMessage(error); lifetime.cancel('SIGTERM'); }
3463
3773
  void lifetime.finish().then(({ code, signal }) => {
3464
3774
  const effectiveSignal = signal ?? lifetime.forwarded;
3465
3775
  const exitCode = effectiveSignal ? signalExitCode(effectiveSignal) : (code ?? 1);
@@ -3504,8 +3814,8 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
3504
3814
  process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (ambient proxy ${proxy.url}). type 'exit' to leave.\n`);
3505
3815
  } catch (error) {
3506
3816
  if (error instanceof CaTrustError) throw error;
3507
- if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${error instanceof Error ? error.message : String(error)})`);
3508
- process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${error instanceof Error ? error.message : String(error)}); falling back to env-only redirect.\n`);
3817
+ if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${errorMessage(error)})`);
3818
+ process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${errorMessage(error)}); falling back to env-only redirect.\n`);
3509
3819
  }
3510
3820
  } else {
3511
3821
  if (sealed) throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
@@ -3554,10 +3864,7 @@ function currentProxyState(root: string, name: string): ProxyState | null {
3554
3864
  const existing = readProxyState(root, name);
3555
3865
  if (!existing || !isAlive(existing.pid)) return null;
3556
3866
  if (existing.guestUrl) return existing;
3557
- try { process.kill(existing.pid, 'SIGTERM'); } catch { /* gone already */ }
3558
- for (let i = 0; i < 50 && isAlive(existing.pid); i += 1) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
3559
- try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
3560
- return null;
3867
+ throw new Error('World proxy lacks its guest listener; stop this World before restarting its proxy');
3561
3868
  }
3562
3869
 
3563
3870
  function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInputs): { url: string; caCertPath: string; env: Record<string, string> } | null {
@@ -3608,9 +3915,20 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
3608
3915
  child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], { windowsHide: true,
3609
3916
  cwd: root, env: process.env, detached: true, stdio: ['ignore', proxyOut, proxyOut],
3610
3917
  });
3918
+ if (child.pid) {
3919
+ spawnedChildren.set(child.pid, child);
3920
+ if (boot) { boot.pids.add(child.pid); boot.handles.set(child.pid, child); }
3921
+ }
3611
3922
  } finally { closeSync(proxyOut); }
3612
3923
  } catch (error) {
3613
- try { rmSync(marker, { force: true }); } catch { /* the spawn's failure is the one to report */ } // no child was returned by spawn
3924
+ if (child!?.pid) {
3925
+ try {
3926
+ signalChild(child!, 'SIGTERM', true);
3927
+ killHeldSurvivorsSync(child!, DOWN_GRACE_MS_DEFAULT);
3928
+ if (undeadPids([child!.pid!]).length) throw new CommandRetirementError('Proxy retirement unconfirmed; startup intent retained');
3929
+ } catch (cleanup) { throw new AggregateError([error, cleanup], 'Proxy spawn/descriptor close and retirement failed'); }
3930
+ }
3931
+ try { rmSync(marker, { force: true }); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Proxy spawn and intent cleanup failed'); }
3614
3932
  throw error;
3615
3933
  }
3616
3934
  child.on('error', () => {}); // a failed spawn can emit after this synchronous operation throws
@@ -3619,16 +3937,16 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
3619
3937
  rmSync(marker, { force: true });
3620
3938
  throw new Error(`World "${name}": ambient proxy process did not start`);
3621
3939
  }
3622
- boot?.pids.add(pid); // before any fallible filesystem operation
3940
+ if (boot) { boot.pids.add(pid); boot.handles.set(pid, child); } // before any fallible filesystem operation
3623
3941
  child.unref();
3624
3942
  let retirementAttempted = false;
3625
3943
  const retire = () => {
3626
3944
  retirementAttempted = true;
3627
- signalPids([pid], 'SIGTERM');
3628
- killSurvivorsAfterGraceSync([pid], DOWN_GRACE_MS_DEFAULT);
3945
+ signalChild(child, 'SIGTERM', true);
3946
+ killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
3629
3947
  if (undeadPids([pid]).length) {
3630
3948
  const error = new CommandRetirementError(`World "${name}": proxy process ${pid} retirement is unconfirmed; ownership retained`);
3631
- try { retainWorldLifecycleForCleanup(root, name); } catch (cause) { error.message += `; claim update failed: ${String(cause)}`; }
3949
+ try { retainWorldLifecycleForCleanup(root, name); } catch (cause) { error.message += `; claim update failed: ${errorMessage(cause)}`; }
3632
3950
  throw error;
3633
3951
  }
3634
3952
  // Publication can happen during the grace period. Erase only this retired child's state.
@@ -3638,12 +3956,16 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
3638
3956
  try {
3639
3957
  if (boot) boot.recordPids();
3640
3958
  else writeOwnedPids(root, name, [pid], [pid]);
3641
- rmSync(marker, { force: true }); // canonical ownership is now durable
3642
3959
  const deadlineMs = proxyDaemonDeadlineMs();
3643
3960
  const deadline = Date.now() + deadlineMs;
3644
3961
  while (Date.now() < deadline) {
3645
3962
  const state = readProxyState(root, name);
3646
3963
  if (state?.pid === pid) {
3964
+ if (!state.birth) throw new Error('Proxy birth token missing; retiring held child');
3965
+ spawnedIdentities.set(pid, { pid, start: state.birth });
3966
+ if (boot) { boot.identities[pid] = state.birth; boot.recordPids(); }
3967
+ else writeOwnedPids(root, name, [pid], [pid]);
3968
+ rmSync(marker, { force: true }); // both numeric ownership and birth are durable
3647
3969
  if (!boot) handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
3648
3970
  return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath, caller, state.guestUrl) };
3649
3971
  }
@@ -3661,13 +3983,13 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
3661
3983
  // a retirement error is still what is thrown (its type keeps the survivor's ownership), with the cause it hid leading its
3662
3984
  // message; any other cleanup failure is added to the original, which is thrown
3663
3985
  try { retire(); } catch (retirement) {
3664
- const cause = error instanceof Error ? error.message : String(error);
3986
+ const cause = errorMessage(error);
3665
3987
  if (retirement instanceof CommandRetirementError) {
3666
- Object.assign(retirement, { retirement: retirement.message, cause: error });
3667
- retirement.message = `${cause}; ${retirement.message}`;
3988
+ Object.assign(retirement, { retirement: errorMessage(retirement), cause: error });
3989
+ retirement.message = `${cause}; ${errorMessage(retirement)}`;
3668
3990
  throw retirement;
3669
3991
  }
3670
- if (error instanceof Error) error.message += `; retiring the proxy failed too: ${retirement instanceof Error ? retirement.message : String(retirement)}`;
3992
+ throw new AggregateError([error, retirement], 'Proxy startup and retirement failed');
3671
3993
  }
3672
3994
  }
3673
3995
  throw error;
@@ -3703,8 +4025,10 @@ export async function runProxyDaemon(name: string, root = process.cwd(), envFile
3703
4025
  env = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
3704
4026
  }
3705
4027
  const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name), note: (line) => process.stderr.write(`${line}\n`) });
3706
- const state: ProxyState = { pid: process.pid, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
3707
- writeFileSync(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
4028
+ const identity = await spawnIdentity(process.pid);
4029
+ if (!identity.start) { await proxy.close(); throw new Error('Proxy birth capture failed'); }
4030
+ const state: ProxyState = { pid: process.pid, birth: identity.start, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
4031
+ writeTextAtomic(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
3708
4032
  // Not a recorded service, so it records its own start and end in the World's events (a crash included; a SIGKILL
3709
4033
  // leaves none, which doctor's proxy check then names).
3710
4034
  const events = worldEventsPath(join(instanceDir(resolvedRoot, name), 'logs'));
@@ -3724,10 +4048,17 @@ export async function runProxyDaemon(name: string, root = process.cwd(), envFile
3724
4048
 
3725
4049
  /** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
3726
4050
  * called from `downWorld` so the trusted CA never outlives the world. */
3727
- function stopWorldProxy(root: string, name: string): void {
4051
+ async function stopWorldProxy(root: string, name: string, handles?: Map<number, ReturnType<typeof spawn>>): Promise<void> {
3728
4052
  const state = readProxyState(root, name);
3729
4053
  if (state && isAlive(state.pid)) {
3730
- try { process.kill(state.pid, 'SIGTERM'); } catch { /* ignore */ }
4054
+ const identities = readIdentities(root, name);
4055
+ const identity = { pid: state.pid, start: state.birth ?? identities[state.pid] };
4056
+ const child = handles?.get(state.pid);
4057
+ if (child) await signalHeldChild(child, 'SIGTERM', false, Promise.resolve(identity));
4058
+ else {
4059
+ if (!state.birth && !Object.hasOwn(identities, state.pid)) throw new Error('Proxy spawn identity missing; retirement refused; ownership retained');
4060
+ await signalOwnedProcess(identity, 'SIGTERM');
4061
+ }
3731
4062
  }
3732
4063
  try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
3733
4064
  try { rmSync(proxyEnvFile(root, name), { force: true }); } catch { /* ignore */ }
@@ -3766,7 +4097,7 @@ export function activateScript(name: string, root = process.cwd()): string {
3766
4097
  } else if (sealed && hostHasRawEgress()) throw new Error('attachment proxy is unavailable');
3767
4098
  } catch (error) {
3768
4099
  if (error instanceof CaTrustError) throw error;
3769
- if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
4100
+ if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${errorMessage(error)})`);
3770
4101
  // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
3771
4102
  }
3772
4103
  env = withAppUrl(name, root, env);