@volter/world-runtime 3.0.38 → 3.0.40

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
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { materializeBrowserFiles, clearBrowserFiles } from "./browsers.js";
2
3
  import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
3
4
  import { offSignal } from "./signals.js";
@@ -11,21 +12,25 @@ import { get as httpGet } from 'node:http';
11
12
  import { siblingScript } from "./sibling.js";
12
13
  import { commandLifetime, commandProcessGroup, CommandRetirementError } from "./command-lifetime.js";
13
14
  import { superviseWorldRun } from "./run-task.js";
14
- import { survivingOwnedGroups } from "./process-groups.js";
15
+ import { processStarts, survivingOwnedGroups } from "./process-groups.js";
16
+ import { spawnIdentity, signalOwnedProcess, signalChild, signalHeldChild, processBirth, childIdentity } from './process-signal.mjs';
17
+ import { readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
15
18
  import { randomUUID, createHash } from 'node:crypto';
16
19
  import { createRequire } from 'node:module';
17
20
  import { isIP } from 'node:net';
18
21
  import { allocatePort } from "./tcp-port.js";
19
22
  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';
23
+ 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
24
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
22
25
  import { packFacts } from "./pack-facts.js";
26
+ import { assertOwnedRealPath, ownedPath } from "./owned-path.js";
27
+ import { isEnvironmentName } from "./schema.js";
23
28
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
24
29
  import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
25
30
  import { Socket } from 'node:net';
26
31
  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
32
  import { loadWorldConfig } from "./configs.js";
28
- import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
33
+ import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
29
34
  import { overlayPackFactsFile, resolveServicePackage } from "./catalog.js";
30
35
  import { fakeEnvValue } from "./fixture-env.js";
31
36
  import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from "./schema.js";
@@ -34,7 +39,24 @@ import { inertInjectEnvWarnings } from "./inject-map.js";
34
39
  import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
35
40
  import { CaTrustError, sessionTrustEnv } from "./ca-trust.js";
36
41
  import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from "./consumers.js";
37
- import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from "./lifecycle-record.js";
42
+ import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle, exitedWorldLifecyclePids } from "./lifecycle-record.js";
43
+ // Births captured at actual spawn; retries never remeasure a retained PID.
44
+ const spawnedIdentities = new Map();
45
+ const spawnedChildren = new Map();
46
+ function retainSpawn(child) {
47
+ if (!child.pid)
48
+ return Promise.resolve(undefined);
49
+ const pid = child.pid;
50
+ spawnedChildren.set(pid, child);
51
+ return childIdentity(child).then(identity => {
52
+ if (identity && spawnedChildren.get(pid) === child)
53
+ spawnedIdentities.set(pid, identity);
54
+ return identity;
55
+ });
56
+ }
57
+ function heldStarts() {
58
+ return Object.fromEntries([...spawnedIdentities].map(([pid, identity]) => [pid, identity.start]));
59
+ }
38
60
  /** The latest `limit` bytes of a service log, reaching into its one rotation (`<log>.1`) when the current file is
39
61
  * shorter: a readiness line printed just before a rotation is still found, and a poll never reads 64 MiB files. */
40
62
  function readServiceText(log, limit = 4 * 1024 * 1024) {
@@ -62,8 +84,8 @@ export function worldEventsPath(logs) {
62
84
  * its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
63
85
  * file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
64
86
  * `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
65
- function spawnRecorded(serviceId, command, args, cwd, env, log) {
66
- const logDir = `${log.replace(/\.log$/, '')}.d`;
87
+ function spawnRecorded(serviceId, command, args, cwd, env, log, boot) {
88
+ const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
67
89
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
68
90
  const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
69
91
  const recorderEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
@@ -78,9 +100,16 @@ function spawnRecorded(serviceId, command, args, cwd, env, log) {
78
100
  detached: true,
79
101
  stdio: ['ignore', out, out],
80
102
  });
81
- closeSync(out);
82
103
  const ended = new Promise((resolveEnded) => { child.once('exit', () => resolveEnded()); child.once('error', () => resolveEnded()); });
83
- return { child, ended };
104
+ const identity = retainSpawn(child);
105
+ if (child.pid) {
106
+ boot.pids.add(child.pid);
107
+ boot.handles.set(child.pid, child);
108
+ }
109
+ child.once('error', error => boot.fail(error));
110
+ boot.recordPids();
111
+ closeSync(out);
112
+ return { child, ended, identity };
84
113
  }
85
114
  /** A startup wait that fails as soon as the service ends, with the service's own account of its end (exit code or
86
115
  * signal, last output) instead of a timeout that names neither. */
@@ -266,7 +295,8 @@ export function clockFile(root, name) {
266
295
  return join(instanceDir(root, name), 'clock');
267
296
  }
268
297
  export function instanceDir(root, name) {
269
- return join(worldBaseDir(root), name);
298
+ assertSafeWorldName(name);
299
+ return ownedPath(worldBaseDir(root), name);
270
300
  }
271
301
  function instanceFile(root, name) {
272
302
  return join(instanceDir(root, name), 'instance.json');
@@ -279,10 +309,12 @@ function foregroundRunFile(root, name) {
279
309
  * a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
280
310
  * collide with (or be wiped as) a world's instance dir. */
281
311
  function instanceLockFile(root, name) {
282
- return join(worldBaseDir(root), '.locks', `${name}.lock`);
312
+ assertSafeWorldName(name);
313
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.lock`);
283
314
  }
284
315
  function stoppingFile(root, name) {
285
- return join(worldBaseDir(root), '.locks', `${name}.stop.json`);
316
+ assertSafeWorldName(name);
317
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.stop.json`);
286
318
  }
287
319
  const SHARE_CLAIM_STALE_MS = 60_000;
288
320
  const SHARE_CLAIM_HEARTBEAT_MS = 5_000;
@@ -292,7 +324,8 @@ const SHARE_CLAIM_HEARTBEAT_MS = 5_000;
292
324
  // heartbeat, a REAL instant refreshed by the holder (a world's frozen clock cannot measure
293
325
  // liveness; this is host physics, not world time). Nothing here reads a process table.
294
326
  function sharingClaimFile(root, name) {
295
- return join(worldBaseDir(root), '.locks', `${name}.share.json`);
327
+ assertSafeWorldName(name);
328
+ return ownedPath(ownedPath(worldBaseDir(root), '.locks'), `${name}.share.json`);
296
329
  }
297
330
  function readSharingClaim(path) {
298
331
  if (!existsSync(path))
@@ -409,15 +442,15 @@ function prepareBootForDown(root, name) {
409
442
  || !Number.isFinite(Date.parse(claim.at)))
410
443
  throw new Error('invalid boot claim');
411
444
  }
412
- catch {
413
- throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`);
445
+ catch (error) {
446
+ throw new Error(`World "${name}" has unreadable boot ownership; instance and lifecycle retained`, { cause: error });
414
447
  }
415
448
  try {
416
449
  process.kill(claim.pid, 0);
417
450
  }
418
451
  catch (error) {
419
452
  if (error.code !== 'ESRCH') {
420
- throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`);
453
+ throw new Error(`World "${name}" boot owner is uninspectable; instance and lifecycle retained`, { cause: error });
421
454
  }
422
455
  rmSync(path); // the recorded booter cannot publish anything after this point
423
456
  return;
@@ -538,22 +571,163 @@ export function withoutVaultSignIn(env) {
538
571
  * the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
539
572
  * so a vendor the inner World does not twin reaches the parent's twin of it.
540
573
  */
541
- function networkOf(name, own, parentEnv = process.env) {
574
+ function networkOf(name, own, recorded, parentEnv = process.env) {
542
575
  const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
543
576
  let ceiling;
544
577
  if (serialized !== undefined) {
545
- // an unreadable parent policy closes the child entirely, as it closes the parent
578
+ // An unreadable parent policy closes the child, including opaque transport permission.
546
579
  try {
547
- ceiling = JSON.parse(serialized).egress ?? [];
580
+ ceiling = networkPermission(parseWorldNetworkPolicy(JSON.parse(serialized)));
548
581
  }
549
582
  catch {
550
- ceiling = [];
583
+ ceiling = { egress: [] };
551
584
  }
552
585
  }
553
- if (own === undefined && ceiling === undefined)
586
+ let network = own;
587
+ for (const limit of [recorded, ceiling])
588
+ if (limit !== undefined)
589
+ network = network === undefined ? limit : intersectWorldNetwork(network, limit);
590
+ if (network === undefined)
554
591
  return undefined;
555
- const egress = own === undefined ? ceiling : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling.includes(origin));
556
- return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
592
+ return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
593
+ }
594
+ /** Strip the publication envelope before combining permissions (the config shape admits only these keys). */
595
+ function networkPermission(policy) {
596
+ return { egress: policy.egress, phase: policy.phase, passthrough: policy.passthrough };
597
+ }
598
+ /** The boot permission, never a fallback to a mutable config. */
599
+ export function recordedWorldNetwork(instance) {
600
+ try {
601
+ if (!Object.hasOwn(instance, 'networkCeiling'))
602
+ throw new Error();
603
+ const published = instance.env?.[WORLD_NETWORK_POLICY_ENV];
604
+ const bound = published === undefined ? undefined : parseWorldNetworkPolicy(JSON.parse(published));
605
+ if (bound && bound.world !== instance.name)
606
+ throw new Error();
607
+ const saved = instance.networkCeiling === 'unrestricted' ? undefined : normalizeWorldNetwork(instance.networkCeiling);
608
+ const permission = bound === undefined ? undefined : networkPermission(bound);
609
+ return saved === undefined ? permission : permission === undefined ? saved : intersectWorldNetwork(saved, permission);
610
+ }
611
+ catch {
612
+ throw new Error(`World boot record is incomplete or invalid: ${instanceFile(instance.root, instance.name)}`);
613
+ }
614
+ }
615
+ /** An absent stored ceiling is derived from this World's own config and retained bound environment. */
616
+ function ceilingFromConfig(record, config) {
617
+ let ceiling = record.mode === 'sealed' ? { egress: [] } : config.network;
618
+ const published = record.env?.[WORLD_NETWORK_POLICY_ENV];
619
+ if (published !== undefined) {
620
+ const bound = parseWorldNetworkPolicy(JSON.parse(published));
621
+ if (bound.world !== record.name)
622
+ throw new Error();
623
+ const permission = networkPermission(bound);
624
+ ceiling = ceiling === undefined ? permission : intersectWorldNetwork(ceiling, permission);
625
+ }
626
+ return ceiling === undefined ? 'unrestricted' : normalizeWorldNetwork(ceiling);
627
+ }
628
+ /** A stopped receipt checks byte consistency before fields are read; it is not authentication. */
629
+ export function readWorldBootRecord(name, root) {
630
+ return withFileLock(instanceLockFile(root, name), () => readWorldBootRecordLocked(name, root));
631
+ }
632
+ /** Ordinary reading completes derivable metadata and its receipt together under the instance lock. */
633
+ function readWorldBootRecordLocked(name, root) {
634
+ const path = instanceFile(root, name);
635
+ const raw = getActiveWorldStore().read(path);
636
+ if (raw === null)
637
+ throw new Error(`World instance not found: ${name}`);
638
+ try {
639
+ const receiptPath = join(instanceDir(root, name), 'stopped.json');
640
+ const stopped = existsSync(receiptPath);
641
+ const receipt = stopped ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
642
+ if (stopped && (receipt.instanceHash !== createHash('sha256').update(raw).digest('hex')
643
+ || typeof receipt.stoppedAt !== 'string' || !Number.isFinite(Date.parse(receipt.stoppedAt))))
644
+ throw new Error();
645
+ const record = JSON.parse(raw);
646
+ if (record.name !== name || typeof record.root !== 'string' || resolve(record.root) !== resolve(root)
647
+ || typeof record.createdAt !== 'string' || !Number.isFinite(Date.parse(record.createdAt))
648
+ || typeof record.config !== 'string' || !record.config || typeof record.configPath !== 'string' || !record.configPath
649
+ || !record.env || record.env.VOLTER_WORLD !== name || record.env.VOLTER_WORLD_MODE !== record.mode
650
+ || !record.dirs || record.dirs.instance !== instanceDir(root, name)
651
+ || record.dirs.data !== join(instanceDir(root, name), 'data') || record.dirs.logs !== join(instanceDir(root, name), 'logs')
652
+ || typeof record.envFile !== 'string' || !record.envFile || record.pidsFile !== join(instanceDir(root, name), 'pids')
653
+ || !record.services || typeof record.services !== 'object' || Array.isArray(record.services))
654
+ throw new Error();
655
+ assertMode(record.mode);
656
+ if (!stopped && !isWorldLifecycleRunning(root, name, record.createdAt))
657
+ throw new Error();
658
+ let completed = false;
659
+ if (!Object.hasOwn(record, 'instance')) {
660
+ record.instance = randomUUID();
661
+ completed = true;
662
+ }
663
+ 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))
664
+ throw new Error();
665
+ const needsConfig = !Object.hasOwn(record, 'networkCeiling')
666
+ || Object.values(record.services).some(service => service && !Object.hasOwn(service, 'branchState'));
667
+ const config = needsConfig ? loadWorldConfig(record.configPath, root).config : undefined;
668
+ if (config && config.id !== record.config)
669
+ throw new Error();
670
+ if (!Object.hasOwn(record, 'networkCeiling')) {
671
+ record.networkCeiling = ceilingFromConfig(record, config);
672
+ completed = true;
673
+ }
674
+ if (!Object.hasOwn(record, 'bootOwner')) {
675
+ if (!record.lifecycle && !record.resources)
676
+ throw new Error();
677
+ record.bootOwner = record.lifecycle?.owner ?? record.resources?.owner ?? null;
678
+ completed = true;
679
+ }
680
+ if (!(record.bootOwner === null || typeof record.bootOwner === 'string'))
681
+ throw new Error();
682
+ checkedOwner(record.bootOwner ?? undefined);
683
+ if (!record.lifecycle) {
684
+ record.lifecycle = { log: lifecycleLogPath(root, name), ...(record.bootOwner === null ? {} : { owner: record.bootOwner }) };
685
+ completed = true;
686
+ }
687
+ if (!Object.hasOwn(record.lifecycle, 'log')) {
688
+ record.lifecycle.log = lifecycleLogPath(root, name);
689
+ completed = true;
690
+ }
691
+ if (!Object.hasOwn(record.lifecycle, 'owner') && record.bootOwner !== null) {
692
+ record.lifecycle.owner = record.bootOwner;
693
+ completed = true;
694
+ }
695
+ if (record.lifecycle.log !== lifecycleLogPath(root, name) || (record.lifecycle.owner ?? null) !== record.bootOwner)
696
+ throw new Error();
697
+ if (!Object.hasOwn(record, 'bootServices')) {
698
+ record.bootServices = Object.keys(record.services);
699
+ completed = true;
700
+ }
701
+ if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
702
+ || new Set(record.bootServices).size !== record.bootServices.length
703
+ || record.bootServices.length !== Object.keys(record.services).length
704
+ || record.bootServices.some(id => !Object.hasOwn(record.services, id)))
705
+ throw new Error();
706
+ for (const [id, service] of Object.entries(record.services)) {
707
+ if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string')
708
+ throw new Error();
709
+ if (!Object.hasOwn(service, 'branchState')) {
710
+ const declared = config.services.find(item => item.id === id);
711
+ if (!declared)
712
+ throw new Error();
713
+ service.branchState = declared.branchState ?? 'supported';
714
+ completed = true;
715
+ }
716
+ if (!['supported', 'unsupported'].includes(service.branchState ?? ''))
717
+ throw new Error();
718
+ }
719
+ recordedWorldNetwork(record);
720
+ if (completed) {
721
+ saveWorldInstance(record);
722
+ if (stopped)
723
+ writeTextAtomic(receiptPath, JSON.stringify({ ...receipt,
724
+ instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
725
+ }
726
+ return record;
727
+ }
728
+ catch {
729
+ throw new Error(`World boot record is incomplete or invalid: ${path}`);
730
+ }
557
731
  }
558
732
  export function admittedEnv(strip, env = process.env) {
559
733
  const out = {};
@@ -665,23 +839,15 @@ async function waitForTcp(port, timeoutMs = 15_000, abort) {
665
839
  }
666
840
  function readPids(path) {
667
841
  const held = getActiveWorldStore().read(path);
668
- if (held === null)
669
- return [];
670
- return held
671
- .split(/\r?\n/)
672
- .map((line) => Number(line.trim()))
673
- .filter((pid) => Number.isInteger(pid) && pid > 0);
842
+ const guests = readGuestProcesses(dirname(path)).map(receipt => receipt.record.pid);
843
+ return [...new Set([...(held ?? '')
844
+ .split(/\r?\n/)
845
+ .map((line) => Number(line.trim()))
846
+ .filter((pid) => Number.isInteger(pid) && pid > 0), ...guests])];
674
847
  }
675
848
  function livePids(pids) {
676
- return pids.filter((pid) => {
677
- try {
678
- process.kill(pid, 0);
679
- return true;
680
- }
681
- catch (error) {
682
- return error.code !== 'ESRCH';
683
- }
684
- });
849
+ // A recorded leader may have exited while its detached descendants still run.
850
+ return survivingOwnedGroups(pids);
685
851
  }
686
852
  function tunnelPids(instance) {
687
853
  return Object.values(instance.services)
@@ -702,13 +868,13 @@ function openPrivateLog(path, flags) {
702
868
  /** Record `pids` as the World's. `fresh` are the processes this very call has spawned: only their start times are
703
869
  * measured anew; every other pid keeps the start time recorded when it was spawned (re-measuring it would bless
704
870
  * whatever process holds that number now). */
705
- function writeOwnedPids(root, name, pids, fresh = []) {
871
+ function writeOwnedPids(root, name, pids, fresh = [], births = heldStarts()) {
706
872
  const path = join(instanceDir(root, name), 'pids');
707
873
  withFileLock(`${instanceLockFile(root, name)}.pids`, () => {
708
874
  const retained = undeadPids(readPids(path));
709
875
  const merged = [...new Set([...retained, ...pids].filter(pid => Number.isInteger(pid) && pid > 0))];
710
876
  writeTextAtomic(path, `${merged.join('\n')}\n`);
711
- recordIdentities(root, name, merged, fresh);
877
+ recordIdentities(root, name, merged, fresh, births);
712
878
  });
713
879
  }
714
880
  // ---- which process a recorded pid names -------------------------------------------------------------------------
@@ -718,54 +884,42 @@ function writeOwnedPids(root, name, pids, fresh = []) {
718
884
  function identityPath(root, name) {
719
885
  return join(instanceDir(root, name), 'pids.identity');
720
886
  }
721
- /** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
722
- * process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
723
- * given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
724
- * processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
725
- * paid. */
726
- function processStarts(pids) {
727
- const starts = new Map();
728
- if (!pids.length || process.platform === 'win32')
729
- return starts;
730
- if (process.platform === 'linux' && existsSync('/proc/self/stat')) {
731
- // Linux: the start tick since boot (field 22), which a wall-clock step does not move (lstart would).
732
- for (const pid of pids) {
733
- try {
734
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
735
- starts.set(pid, `tick ${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`);
736
- }
737
- catch { /* no process */ }
738
- }
739
- return starts;
740
- }
741
- const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
742
- for (const pid of pids) {
743
- const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
744
- const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
745
- if (match && Number(match[1]) === pid)
746
- starts.set(pid, match[2]);
747
- }
748
- return starts;
749
- }
750
887
  function readIdentities(root, name) {
888
+ let known = {};
751
889
  try {
752
- return JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}');
753
- }
754
- catch {
755
- return {};
890
+ const value = JSON.parse(getActiveWorldStore().read(identityPath(root, name)) ?? '{}');
891
+ if (value && typeof value === 'object' && !Array.isArray(value))
892
+ known = Object.fromEntries(Object.entries(value).filter(([pid, start]) => Number.isInteger(Number(pid)) && Number(pid) > 0 && typeof start === 'string' && start.length > 0));
893
+ }
894
+ catch { /* Guest receipts below remain independently authoritative. */ }
895
+ const births = new Map();
896
+ for (const { record } of readGuestProcesses(instanceDir(root, name))) {
897
+ if (births.has(record.pid) && births.get(record.pid) !== record.start)
898
+ throw new Error('Conflicting guest spawn identities; ownership retained');
899
+ births.set(record.pid, record.start);
900
+ }
901
+ for (const [pid, start] of births) {
902
+ if (start)
903
+ known[pid] = start;
904
+ else
905
+ delete known[pid];
756
906
  }
907
+ return known;
757
908
  }
758
- function recordIdentities(root, name, pids, fresh = []) {
909
+ function recordIdentities(root, name, pids, fresh = [], births = heldStarts()) {
759
910
  try {
760
911
  // A pid the World has just spawned is measured anew, whatever an older record says of that number.
761
- const known = readIdentities(root, name), starts = processStarts(pids.filter((pid) => known[pid] === undefined || fresh.includes(pid)));
762
- for (const pid of fresh)
763
- if (starts.has(pid))
912
+ const known = readIdentities(root, name);
913
+ for (const pid of fresh) {
914
+ if (births[pid] !== undefined)
915
+ known[pid] = births[pid];
916
+ else
764
917
  delete known[pid];
765
- const kept = Object.fromEntries(pids.flatMap((pid) => { const start = known[pid] ?? starts.get(pid); return start ? [[String(pid), start]] : []; }));
918
+ }
919
+ const kept = Object.fromEntries(pids.flatMap(pid => known[pid] === undefined ? [] : [[String(pid), known[pid]]]));
766
920
  writeTextAtomic(identityPath(root, name), JSON.stringify(kept));
767
921
  }
768
- catch { /* without a record a pid is trusted as before */ }
922
+ catch { /* without a record signalling refuses and lifecycle evidence is retained */ }
769
923
  }
770
924
  /** Recorded pids whose running process started at another time than recorded: the number now names another
771
925
  * program. An unrecorded pid, and a pid with no process, are never in this list. */
@@ -902,12 +1056,14 @@ function getByJsonPath(value, jsonPath) {
902
1056
  }
903
1057
  return current;
904
1058
  }
1059
+ class ExternalCommandUnavailableError extends Error {
1060
+ }
905
1061
  async function runExternalCommand(command, cwd, serviceId, phase, env = process.env, abort, timeoutMs = 60_000) {
906
1062
  const [bin, ...args] = ownCommand(command);
907
1063
  if (!bin)
908
1064
  throw new Error(`External service "${serviceId}": ${phase} command is empty`);
909
1065
  if (!commandExists(bin))
910
- throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
1066
+ throw new ExternalCommandUnavailableError(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
911
1067
  abort?.throwIfAborted();
912
1068
  const child = spawn(bin, args, { windowsHide: true, cwd, env, detached: commandProcessGroup, stdio: ['ignore', 'pipe', 'pipe'] });
913
1069
  const lifetime = commandLifetime(child, abort, true);
@@ -930,10 +1086,15 @@ async function runExternalCommand(command, cwd, serviceId, phase, env = process.
930
1086
  const timer = setTimeout(() => { failure = new Error(`command timed out after ${timeoutMs}ms`); lifetime.cancel('SIGTERM'); }, timeoutMs);
931
1087
  try {
932
1088
  const result = await lifetime.finish();
933
- if (failure)
934
- throw new Error(`External service "${serviceId}": ${phase} command failed to run (${failure.message})`);
935
- if (result.code !== 0 || lifetime.forwarded)
936
- throw new Error(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
1089
+ if (failure) {
1090
+ const ErrorKind = ['ENOENT', 'ENOEXEC', 'EACCES'].includes(failure.code ?? '') ? ExternalCommandUnavailableError : Error;
1091
+ throw new ErrorKind(`External service "${serviceId}": ${phase} command failed to run (${errorMessage(failure)})`);
1092
+ }
1093
+ if (result.code !== 0 || lifetime.forwarded) {
1094
+ const parserRefusal = /unknown (?:shorthand flag|flag|option|command)|unrecognized (?:option|argument)|command not found|no such file or directory/iu.test(`${stdout}${stderr}`);
1095
+ const ErrorKind = !lifetime.forwarded && ([126, 127].includes(result.code ?? -1) || (result.code === 125 && parserRefusal)) ? ExternalCommandUnavailableError : Error;
1096
+ throw new ErrorKind(`External service "${serviceId}": ${phase} command exited ${result.code ?? 'null'}\n${stdout}${stderr}`);
1097
+ }
937
1098
  return { stdout, stderr };
938
1099
  }
939
1100
  finally {
@@ -990,8 +1151,8 @@ async function runExternalUp(command, cwd, serviceId, env, logPath, abort) {
990
1151
  return chunks.join('');
991
1152
  }
992
1153
  /** Whether an external `up` is this runtime's own managed-infrastructure tool (`volter-world-infra up`, which `init`
993
- * names), with nothing on PATH answering the name: the phase then runs in this process (infra.ts) instead of in a
994
- * child. The services it starts (the PGlite host, the redis and MongoDB twins) are then children of the process that
1154
+ * names): the phase runs in this process (infra.ts) instead of in a
1155
+ * child. The services it starts (the PGlite host, the redis twin and mongod) are then children of the process that
995
1156
  * owns the World's lifetime, as every service recorder is, not of a tool that exits once they serve. Where a child
996
1157
  * does not outlive its parent (a browser tab's process layer, measured 2026-09-28: a detached, unref'd listener
997
1158
  * ended with the process that spawned it), the PGlite host ended the moment `volter-world-infra up` returned and
@@ -1055,7 +1216,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1055
1216
  catch (error) {
1056
1217
  if (error instanceof CommandRetirementError)
1057
1218
  throw error;
1058
- lastError = String(error);
1219
+ lastError = errorMessage(error);
1059
1220
  }
1060
1221
  }
1061
1222
  else if (ready.httpUrl !== undefined) {
@@ -1066,7 +1227,7 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1066
1227
  lastError = `HTTP ${response.status}`;
1067
1228
  }
1068
1229
  catch (error) {
1069
- lastError = error instanceof Error ? error.message : String(error);
1230
+ lastError = errorMessage(error);
1070
1231
  }
1071
1232
  }
1072
1233
  else if (ready.stdoutMatch !== undefined) {
@@ -1079,12 +1240,9 @@ async function awaitReadiness(ready, logPath, cwd, serviceId, env = process.env,
1079
1240
  }
1080
1241
  throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
1081
1242
  }
1082
- async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1083
- const external = service.external;
1084
- if (!external)
1085
- throw new Error(`External service "${service.id}" is missing its external config`);
1086
- const log = join(paths.logs, `${service.id}.log`);
1087
- const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1243
+ /** The environment an external service and its replacement infrastructure hosts run in. Shared with branching,
1244
+ * so both use the boot's admission, service overrides, lifecycle context and control-plane egress rules. */
1245
+ export function externalServiceEnv(service, paths, worldEnv, stripEnv) {
1088
1246
  // External commands see the accumulated world env (process.env + earlier services' discovered/
1089
1247
  // injected vars), so a later external can consume an earlier service's connection info.
1090
1248
  const env = {
@@ -1092,11 +1250,20 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1092
1250
  ...worldEnv,
1093
1251
  ...(service.env ?? {}),
1094
1252
  VOLTER_WORLD_SERVICE_ID: service.id,
1095
- VOLTER_WORLD_SERVICE_LOG: log,
1253
+ VOLTER_WORLD_SERVICE_LOG: ownedPath(paths.logs, `${service.id}.log`),
1096
1254
  };
1097
1255
  if (service.controlPlane)
1098
1256
  for (const key of CONTROL_PLANE_EGRESS_ENV)
1099
1257
  delete env[key];
1258
+ return env;
1259
+ }
1260
+ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1261
+ const external = service.external;
1262
+ if (!external)
1263
+ throw new Error(`External service "${service.id}" is missing its external config`);
1264
+ const log = ownedPath(paths.logs, `${service.id}.log`);
1265
+ const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1266
+ const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
1100
1267
  // 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
1101
1268
  if (!external.up[0] || !commandExists(external.up[0])) {
1102
1269
  throw new Error(`External service "${service.id}": \`${external.up[0] ?? ''}\` not found on PATH (the up command's tool is not installed)`);
@@ -1131,7 +1298,7 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1131
1298
  jsonCache[source] = JSON.parse(raw);
1132
1299
  }
1133
1300
  catch (error) {
1134
- 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)}`);
1301
+ 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)}`);
1135
1302
  }
1136
1303
  }
1137
1304
  const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
@@ -1157,10 +1324,16 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
1157
1324
  external: { down: [...external.down], cwd, discoveredEnv: injected },
1158
1325
  };
1159
1326
  }
1160
- function trackBootingChild(child, boot) {
1327
+ async function trackBootingChild(child, boot, birth) {
1161
1328
  if (!child.pid)
1162
1329
  return;
1163
1330
  boot.pids.add(child.pid);
1331
+ boot.handles.set(child.pid, child);
1332
+ boot.recordPids(); // durable numeric uncertainty precedes the async birth capture
1333
+ const identity = await birth;
1334
+ if (!identity?.start)
1335
+ throw new Error('Service birth capture failed; retiring held child');
1336
+ boot.identities[child.pid] = identity.start;
1164
1337
  boot.recordPids();
1165
1338
  }
1166
1339
  async function startService(config, service, worldEnv, paths, allocatedPorts, boot) {
@@ -1169,7 +1342,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1169
1342
  if (!service.command)
1170
1343
  throw new Error(`Service "${service.id}" must define command`);
1171
1344
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1172
- const serviceDataDir = join(paths.data, service.id);
1345
+ const serviceDataDir = ownedPath(paths.data, service.id);
1173
1346
  mkdirSync(serviceDataDir, { recursive: true });
1174
1347
  // R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
1175
1348
  // is checked against its pin and its protocol standing before it is spawned.
@@ -1189,7 +1362,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1189
1362
  args.push(service.portArg ?? '--port', String(port));
1190
1363
  if (service.rootArg !== false)
1191
1364
  args.push(service.rootArg ?? '--root', serviceDataDir);
1192
- const log = join(paths.logs, `${service.id}.log`);
1365
+ const log = ownedPath(paths.logs, `${service.id}.log`);
1193
1366
  const env = serviceProcessEnv(config, service, worldEnv, port);
1194
1367
  const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1195
1368
  // Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
@@ -1203,10 +1376,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1203
1376
  // The service runs under the service recorder, which outlives it to write its end into its own log: `up`
1204
1377
  // returns once the World is ready, so nothing else is left to see a service stop.
1205
1378
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1206
- const { child, ended } = spawnRecorded(service.id, service.command, args, cwd, env, log);
1379
+ const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot);
1207
1380
  child.once('error', error => boot.fail(error));
1208
1381
  child.unref();
1209
- trackBootingChild(child, boot);
1382
+ await trackBootingChild(child, boot, identity);
1210
1383
  // Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
1211
1384
  // child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
1212
1385
  // and dies — but waitForTcp would then see the OTHER process answering and the world would
@@ -1221,22 +1394,22 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1221
1394
  // swapping, a docker build beside the boot) a heavy service takes longer than the 15s default
1222
1395
  // to answer on its port, and failing it there wastes the whole boot.
1223
1396
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1224
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch((error) => {
1397
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the service', log).catch(async (error) => {
1225
1398
  if (undeadPids([child.pid]).length)
1226
1399
  recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1227
1400
  try {
1228
- process.kill(-child.pid, 'SIGTERM');
1401
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1229
1402
  }
1230
1403
  catch {
1231
1404
  // best effort cleanup
1232
1405
  }
1233
- throw new Error(`Service "${service.id}" failed to start: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1406
+ throw new Error(`Service "${service.id}" failed to start: ${errorMessage(error)}\nLog: ${log}`);
1234
1407
  });
1235
- await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch((error) => {
1408
+ await confirmOwnPort(port, boot, ended, (service.type ?? 'twin') === 'twin', `Service "${service.id}"`, log, true).catch(async (error) => {
1236
1409
  if (undeadPids([child.pid]).length)
1237
1410
  recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1238
1411
  try {
1239
- process.kill(-child.pid, 'SIGTERM');
1412
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1240
1413
  }
1241
1414
  catch { /* best effort cleanup */ }
1242
1415
  throw error;
@@ -1250,14 +1423,14 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1250
1423
  // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
1251
1424
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1252
1425
  if (service.ready && service.ready !== 'tcp') {
1253
- await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch((error) => {
1426
+ await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1254
1427
  if (undeadPids([child.pid]).length)
1255
1428
  recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1256
1429
  try {
1257
- process.kill(-child.pid, 'SIGTERM');
1430
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1258
1431
  }
1259
1432
  catch { /* best effort cleanup */ }
1260
- throw new Error(`Service "${service.id}" failed its readiness probe: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1433
+ throw new Error(`Service "${service.id}" failed its readiness probe: ${errorMessage(error)}\nLog: ${log}`);
1261
1434
  });
1262
1435
  }
1263
1436
  return {
@@ -1422,7 +1595,8 @@ const ISSUE_TIMEOUT_MS = 15_000;
1422
1595
  * asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
1423
1596
  * door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
1424
1597
  async function issueCredentials(service, started, env, pending, unissued) {
1425
- const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
1598
+ const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
1599
+ const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
1426
1600
  if (wanted.length === 0)
1427
1601
  return;
1428
1602
  for (const name of wanted)
@@ -1444,30 +1618,114 @@ async function issueCredentials(service, started, env, pending, unissued) {
1444
1618
  const answer = (await res.json());
1445
1619
  for (const name of wanted) {
1446
1620
  const spec = door.fill[name] ?? '';
1447
- // `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
1448
- // beside the World's instance file, the name set to its path
1449
- const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
1450
- const value = answer[field];
1451
- if (spec.startsWith('file:')) {
1621
+ if (spec.startsWith('directory:') || spec.startsWith('file:')) {
1622
+ if (direct.has(name))
1623
+ throw new Error(`cannot give ${name}'s filesystem credential a direct URL`);
1624
+ if (!isEnvironmentName(name) || !/^[a-z0-9][a-z0-9._-]*$/.test(service.id))
1625
+ throw new Error('Invalid credential destination name');
1626
+ if (!env.VOLTER_WORLD_INSTANCE)
1627
+ throw new Error('World credential custody is unknown');
1628
+ const instance = realpathSync(dirname(env.VOLTER_WORLD_INSTANCE));
1629
+ const custody = ownedPath(instance, 'credentials');
1630
+ const artifact = `${service.id}-${name.toLowerCase()}`;
1631
+ const isDirectory = spec.startsWith('directory:');
1632
+ const field = spec.slice(spec.indexOf(':') + 1), value = answer[field];
1452
1633
  if (value === undefined || value === null)
1453
1634
  throw new Error(`answered no ${field}`);
1454
- // beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
1455
- if (!env.VOLTER_WORLD_INSTANCE)
1456
- throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
1457
- const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
1458
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1459
- const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
1460
- writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1461
- env[name] = path;
1635
+ if (isDirectory && (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0))
1636
+ throw new Error('Invalid credential directory');
1637
+ const entries = isDirectory ? Object.entries(value) : [[`${artifact}.json`, value]];
1638
+ const files = entries.map(([file, contents]) => {
1639
+ if (isDirectory && (!file || file.includes('\\') || file.split('/').some(part => !/^[A-Za-z0-9._-]+$/.test(part) || part === '.' || part === '..')))
1640
+ throw new Error('Invalid World-owned path');
1641
+ return { path: ownedPath(custody, isDirectory ? `${artifact}/${file}` : file), contents };
1642
+ });
1643
+ const directories = new Set([custody]);
1644
+ for (const { path } of files) {
1645
+ let parent = dirname(path);
1646
+ while (parent !== custody) {
1647
+ directories.add(parent);
1648
+ parent = dirname(parent);
1649
+ }
1650
+ const stat = lstatSync(path, { throwIfNoEntry: false });
1651
+ if (stat && (!stat.isFile() || stat.nlink !== 1))
1652
+ throw new Error('Invalid World-owned path');
1653
+ }
1654
+ // Validate every destination and parent before creating or changing any custody artifact.
1655
+ for (const dir of directories) {
1656
+ ownedPath(instance, relative(instance, dir));
1657
+ const stat = lstatSync(dir, { throwIfNoEntry: false });
1658
+ if (stat && !stat.isDirectory())
1659
+ throw new Error('Invalid World-owned path');
1660
+ }
1661
+ for (const dir of [...directories].sort((a, b) => a.length - b.length)) {
1662
+ if (!existsSync(dir))
1663
+ mkdirSync(dir, { mode: 0o700 });
1664
+ assertOwnedRealPath(custody, dir);
1665
+ chmodSync(dir, 0o700);
1666
+ }
1667
+ for (const { path, contents } of files) {
1668
+ ownedPath(custody, relative(custody, path));
1669
+ assertOwnedRealPath(custody, dirname(path));
1670
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW, 0o600);
1671
+ let opened;
1672
+ try {
1673
+ const stat = fstatSync(fd);
1674
+ if (!stat.isFile() || stat.nlink !== 1)
1675
+ throw new Error('Invalid World-owned path');
1676
+ opened = stat;
1677
+ fchmodSync(fd, 0o600);
1678
+ assertOwnedRealPath(custody, dirname(path));
1679
+ ftruncateSync(fd, 0);
1680
+ writeFileSync(fd, typeof contents === 'string' ? contents : `${JSON.stringify(contents, null, 2)}\n`);
1681
+ }
1682
+ finally {
1683
+ closeSync(fd);
1684
+ let realFile;
1685
+ try {
1686
+ realFile = realpathSync(path);
1687
+ const current = statSync(realFile);
1688
+ if (!opened || current.dev !== opened.dev || current.ino !== opened.ino)
1689
+ throw new Error('Invalid World-owned path');
1690
+ assertOwnedRealPath(custody, realFile);
1691
+ }
1692
+ catch (error) {
1693
+ // Remove only the file whose descriptor we opened; a raced replacement is not ours to delete.
1694
+ const current = realFile ? lstatSync(realFile, { throwIfNoEntry: false }) : undefined;
1695
+ if (opened && current?.dev === opened.dev && current.ino === opened.ino)
1696
+ rmSync(realFile, { force: true });
1697
+ throw error;
1698
+ }
1699
+ }
1700
+ }
1701
+ env[name] = isDirectory ? ownedPath(custody, artifact) : files[0].path;
1462
1702
  continue;
1463
1703
  }
1704
+ const field = spec;
1705
+ const value = answer[field];
1464
1706
  if (typeof value !== 'string')
1465
1707
  throw new Error(`answered no ${field}`);
1466
- env[name] = value;
1708
+ if (direct.has(name)) {
1709
+ const issued = new URL(value);
1710
+ if (!['http:', 'https:'].includes(issued.protocol))
1711
+ throw new Error(`cannot route ${name}'s non-HTTP URL directly`);
1712
+ const endpoint = new URL(started.url);
1713
+ issued.protocol = endpoint.protocol;
1714
+ issued.host = endpoint.host;
1715
+ env[name] = issued.toString();
1716
+ }
1717
+ else
1718
+ env[name] = value;
1719
+ }
1720
+ if (started.url) {
1721
+ const endpoint = new URL(started.url);
1722
+ Object.assign(env, resolveServiceEnvTemplates(service.injectEnvTemplates, {
1723
+ host: endpoint.hostname, port: Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)), url: started.url,
1724
+ }));
1467
1725
  }
1468
1726
  }
1469
1727
  catch (error) {
1470
- process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
1728
+ process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
1471
1729
  for (const name of wanted) {
1472
1730
  delete env[name];
1473
1731
  unissued.add(name);
@@ -1528,7 +1786,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1528
1786
  const allocated = [];
1529
1787
  for (const service of colocated) {
1530
1788
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1531
- const serviceDataDir = join(paths.data, service.id);
1789
+ const serviceDataDir = ownedPath(paths.data, service.id);
1532
1790
  mkdirSync(serviceDataDir, { recursive: true });
1533
1791
  // Relative module paths resolve from the world root (import() inside the host would
1534
1792
  // otherwise resolve them against the runtime's own source directory).
@@ -1557,10 +1815,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1557
1815
  dropUnissued(env);
1558
1816
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1559
1817
  // Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
1560
- const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
1818
+ const { child, ended, identity } = spawnRecorded('host', process.execPath, args, paths.root, env, log, boot);
1561
1819
  child.once('error', error => boot.fail(error));
1562
1820
  child.unref();
1563
- trackBootingChild(child, boot);
1821
+ await trackBootingChild(child, boot, identity);
1564
1822
  const exitState = { value: null };
1565
1823
  child.on('exit', (code, signal) => {
1566
1824
  exitState.value = { code, signal };
@@ -1596,25 +1854,25 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1596
1854
  }
1597
1855
  catch {
1598
1856
  return 0;
1599
- } }), ended, boot, 'the co-located host', log).catch((error) => {
1857
+ } }), ended, boot, 'the co-located host', log).catch(async (error) => {
1600
1858
  if (undeadPids([child.pid]).length)
1601
1859
  recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1602
1860
  try {
1603
- process.kill(-child.pid, 'SIGTERM');
1861
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1604
1862
  }
1605
1863
  catch { /* best effort cleanup */ }
1606
- throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1864
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1607
1865
  });
1608
1866
  await parallelStartup(allocated, async ({ service, port }) => {
1609
1867
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1610
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1868
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch(async (error) => {
1611
1869
  if (undeadPids([child.pid]).length)
1612
1870
  recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1613
1871
  try {
1614
- process.kill(-child.pid, 'SIGTERM');
1872
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1615
1873
  }
1616
1874
  catch { /* best effort cleanup */ }
1617
- throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1875
+ throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
1618
1876
  });
1619
1877
  });
1620
1878
  // The host's receipt (this boot, every requested port) is published only
@@ -1624,11 +1882,11 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1624
1882
  // answers with another boot's identity is still refused.
1625
1883
  const hostSettle = Promise.resolve('alive');
1626
1884
  await parallelStartup(allocated, async ({ service, port }) => {
1627
- await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1885
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch(async (error) => {
1628
1886
  if (undeadPids([child.pid]).length)
1629
1887
  recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1630
1888
  try {
1631
- process.kill(-child.pid, 'SIGTERM');
1889
+ await signalHeldChild(child, 'SIGTERM', true, identity);
1632
1890
  }
1633
1891
  catch { /* best effort cleanup */ }
1634
1892
  throw error;
@@ -1701,6 +1959,7 @@ export async function upWorld(configId, options = {}, abort) {
1701
1959
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1702
1960
  const instanceFile = join(instance, 'instance.json');
1703
1961
  let carriedOrigin;
1962
+ let carriedNetwork = options.networkCeiling;
1704
1963
  // the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
1705
1964
  // writes it), so a file any of them last wrote is the World's own, never a custody file
1706
1965
  const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
@@ -1723,6 +1982,7 @@ export async function upWorld(configId, options = {}, abort) {
1723
1982
  let resourcesClaimed = false;
1724
1983
  let cleanupUncertain = false;
1725
1984
  const createdAt = new Date().toISOString();
1985
+ const instanceToken = randomUUID();
1726
1986
  // Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
1727
1987
  // two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
1728
1988
  // dir, and one clobbers the other's freshly-spawned services (orphaned processes + a corrupt
@@ -1754,23 +2014,50 @@ export async function upWorld(configId, options = {}, abort) {
1754
2014
  }
1755
2015
  if (!options.keepState)
1756
2016
  assertWorldStateRemovable(root, instance);
1757
- // A legacy record can have been removed by an older reader while its external effects
1758
- // remain. Require a receipt for the exact prior instance, not merely absent claims/PIDs.
2017
+ // Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
1759
2018
  if (existsSync(instanceFile)) {
2019
+ let prior;
2020
+ if (options.keepState)
2021
+ prior = readWorldBootRecordLocked(name, root);
1760
2022
  const priorRaw = readFileSync(instanceFile, 'utf8');
1761
- const prior = JSON.parse(priorRaw);
1762
2023
  const receiptPath = join(instance, 'stopped.json');
1763
2024
  const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
1764
- if (prior.name !== name || resolve(prior.root) !== root || !receipt
1765
- || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
2025
+ if (!receipt || receipt.instanceHash !== createHash('sha256').update(priorRaw).digest('hex')
1766
2026
  || !Number.isFinite(Date.parse(receipt.stoppedAt))) {
1767
2027
  throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
1768
2028
  }
2029
+ if (!prior) {
2030
+ try {
2031
+ prior = JSON.parse(priorRaw);
2032
+ }
2033
+ catch { /* fresh boot needs no retained shape */ }
2034
+ }
2035
+ if (prior) {
2036
+ // A readable ceiling remains useful even when unrelated metadata is incomplete.
2037
+ try {
2038
+ const permissionRecord = { ...prior, name, root };
2039
+ if (!Object.hasOwn(permissionRecord, 'networkCeiling')) {
2040
+ let storedConfig = loaded.config;
2041
+ try {
2042
+ storedConfig = loadWorldConfig(prior.configPath, root).config;
2043
+ }
2044
+ catch { /* fresh boot can discard missing config metadata */ }
2045
+ permissionRecord.networkCeiling = ceilingFromConfig(permissionRecord, storedConfig);
2046
+ }
2047
+ const ceiling = recordedWorldNetwork(permissionRecord);
2048
+ if (ceiling !== undefined)
2049
+ carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
2050
+ }
2051
+ catch {
2052
+ if (options.keepState)
2053
+ throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`);
2054
+ }
2055
+ }
1769
2056
  // the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
1770
2057
  // same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
1771
2058
  // ends it there; an instance under another World's config, a runtime branch, has only its own record); its cursors
1772
2059
  // only when its state (the mirror they count) is kept, as reset drops them
1773
- if (prior.origin && prior.config === loaded.config.id && (name !== loaded.config.id || namesOrigin(loaded.config.remotes?.origin, prior.origin, root))) {
2060
+ 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))) {
1774
2061
  carriedOrigin = options.keepState ? prior.origin
1775
2062
  : { url: prior.origin.url, namespace: prior.origin.namespace, ...(prior.origin.clonedAt ? { clonedAt: prior.origin.clonedAt } : {}) };
1776
2063
  }
@@ -1781,7 +2068,7 @@ export async function upWorld(configId, options = {}, abort) {
1781
2068
  retainWorldLifecycleForCleanup(root, name);
1782
2069
  // The last run's logs survive this boot as logs/previous (one generation): the record of a crash must outlive the
1783
2070
  // restart that usually follows it. Moved aside before the wipe and back in after it.
1784
- const previousLogs = join(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
2071
+ const previousLogs = ownedPath(dirname(instance), `.${name}.previous-logs`); // dot-prefixed: bookkeeping, never listed as a World
1785
2072
  if (existsSync(previousLogs) && !existsSync(logs)) {
1786
2073
  mkdirSync(instance, { recursive: true });
1787
2074
  renameSync(previousLogs, logs);
@@ -1829,8 +2116,7 @@ export async function upWorld(configId, options = {}, abort) {
1829
2116
  releaseWorldLifecycle(root, name);
1830
2117
  }
1831
2118
  catch (release) {
1832
- if (error instanceof Error)
1833
- error.message += `; releasing the World's claim failed too: ${release instanceof Error ? release.message : String(release)}`;
2119
+ throw new AggregateError([error, release], 'World claim and release failed');
1834
2120
  }
1835
2121
  }
1836
2122
  throw error;
@@ -1840,8 +2126,14 @@ export async function upWorld(configId, options = {}, abort) {
1840
2126
  // "already running" guard; on failure the world never came up, so the next upWorld must be
1841
2127
  // allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
1842
2128
  // point (a foreign live claim made the claim section above throw before this try).
2129
+ let bootFailed = false;
2130
+ let bootFailure;
1843
2131
  try {
1844
2132
  const services = {};
2133
+ const recordService = (started) => {
2134
+ const branchState = loaded.config.services.find(service => service.id === started.id)?.branchState;
2135
+ services[started.id] = { ...started, branchState: branchState ?? 'supported' };
2136
+ };
1845
2137
  const inheritedEnv = admittedEnv(loaded.config.stripEnv);
1846
2138
  const env = {
1847
2139
  ...(loaded.config.env ?? {}),
@@ -1852,10 +2144,11 @@ export async function upWorld(configId, options = {}, abort) {
1852
2144
  // the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
1853
2145
  // on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
1854
2146
  VOLTER_WORLD: name,
1855
- ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
2147
+ ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network, carriedNetwork) ?? {}),
1856
2148
  VOLTER_WORLD_MODE: mode,
1857
2149
  VOLTER_WORLD_CONFIG: loaded.path,
1858
2150
  VOLTER_WORLD_INSTANCE: instanceFile,
2151
+ VOLTER_WORLD_GUEST_RUNNER: new URL(import.meta.url.endsWith('.ts') ? './guest-runner.ts' : './guest-runner.js', import.meta.url).href,
1859
2152
  // every service's root is `<data>/<service id>`; the kernel finds another pack's store across them (A3, ownerStoreRoots)
1860
2153
  [WORLD_DATA_ENV]: data,
1861
2154
  // the live env file this `up` writes: what a service that runs apps elsewhere (world-machine's guest) carries there
@@ -1877,8 +2170,10 @@ export async function upWorld(configId, options = {}, abort) {
1877
2170
  const unissued = new Set();
1878
2171
  writeProxyEnv(root, name, env);
1879
2172
  const worldInstance = {
1880
- name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
2173
+ name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
1881
2174
  dirs: { instance, logs, data }, services, env, envFile, pidsFile,
2175
+ networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]))),
2176
+ bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
1882
2177
  ...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
1883
2178
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1884
2179
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
@@ -1902,9 +2197,9 @@ export async function upWorld(configId, options = {}, abort) {
1902
2197
  abort?.removeEventListener('abort', onOwnerEnd);
1903
2198
  };
1904
2199
  const boot = {
1905
- bootId: randomUUID(), signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(),
1906
- external: service => { services[service.id] = service; saveWorldInstance(worldInstance); },
1907
- recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids]),
2200
+ bootId: instanceToken, signal: cancelled.signal, fail: error => cancelled.abort(error), pids: new Set(), identities: {}, handles: new Map(),
2201
+ external: service => { recordService(service); saveWorldInstance(worldInstance); },
2202
+ recordPids: () => writeOwnedPids(root, name, [...boot.pids], [...boot.pids], boot.identities),
1908
2203
  };
1909
2204
  const recordPidsSoFar = boot.recordPids;
1910
2205
  try {
@@ -1924,7 +2219,7 @@ export async function upWorld(configId, options = {}, abort) {
1924
2219
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
1925
2220
  const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
1926
2221
  for (const started of host.services) {
1927
- services[started.id] = started;
2222
+ recordService(started);
1928
2223
  Object.assign(env, started.env);
1929
2224
  }
1930
2225
  colocatedReady = async () => {
@@ -1978,7 +2273,7 @@ export async function upWorld(configId, options = {}, abort) {
1978
2273
  attemptProxy();
1979
2274
  boot.signal.throwIfAborted();
1980
2275
  const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
1981
- services[started.id] = started;
2276
+ recordService(started);
1982
2277
  recordPidsSoFar();
1983
2278
  Object.assign(env, started.env);
1984
2279
  await issueCredentials(service, started, env, issuing, unissued);
@@ -2022,7 +2317,7 @@ export async function upWorld(configId, options = {}, abort) {
2022
2317
  return await shareWorldServices(name, { root, ...(options.share ?? {}) });
2023
2318
  }
2024
2319
  catch (error) {
2025
- await downWorld(name, root);
2320
+ await downWorld(name, root, { expectedInstance: instanceToken });
2026
2321
  throw error;
2027
2322
  }
2028
2323
  }
@@ -2034,16 +2329,16 @@ export async function upWorld(configId, options = {}, abort) {
2034
2329
  // Rollback never lets its own failure replace the boot's: each step's error is added to the original.
2035
2330
  const rollbackErrors = [];
2036
2331
  try {
2037
- stopWorldProxy(root, name);
2332
+ await stopWorldProxy(root, name, boot.handles);
2038
2333
  }
2039
2334
  catch (proxyError) {
2040
- rollbackErrors.push(`proxy stop failed: ${proxyError instanceof Error ? proxyError.message : String(proxyError)}`);
2335
+ rollbackErrors.push(`proxy stop failed: ${errorMessage(proxyError)}`);
2041
2336
  }
2042
2337
  const pids = [...new Set([...Object.values(services).map((service) => service.pid).filter(Boolean), ...boot.pids])];
2043
2338
  const rollbackAlive = undeadPids(Object.values(services).map((service) => service.pid).filter(Boolean));
2044
2339
  const rollbackLogs = Object.values(services).filter((service) => service.type !== 'external' && service.log && rollbackAlive.includes(service.pid)).map((service) => service.log);
2045
2340
  recordWorldStop(rollbackLogs, 'world.service.stop', 'boot rollback', 'SIGTERM');
2046
- signalPids(pids, 'SIGTERM');
2341
+ await signalPids(pids, 'SIGTERM', boot.identities, boot.handles);
2047
2342
  // Stop any self-managed externals that DID come up, so a failed `up` never leaves a
2048
2343
  // half-running external stack behind. Best-effort: the original error is what we throw —
2049
2344
  // but a `down` that ITSELF fails must not be silent (the operator would have no hint that
@@ -2058,20 +2353,20 @@ export async function upWorld(configId, options = {}, abort) {
2058
2353
  await runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
2059
2354
  }
2060
2355
  catch (downError) {
2061
- rollbackErrors.push(downError instanceof Error ? downError.message : String(downError));
2356
+ rollbackErrors.push(errorMessage(downError));
2062
2357
  }
2063
2358
  }
2064
2359
  }
2065
2360
  // Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
2066
2361
  // outlive a failed boot either. The poll exits as soon as everything is dead, so the
2067
2362
  // common case (services honor SIGTERM) adds no delay to the failure path.
2068
- const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
2363
+ const rollbackKilled = await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT, boot.identities, boot.handles);
2069
2364
  recordWorldStop(Object.values(services).filter((service) => rollbackKilled.includes(service.pid) && service.log).map((service) => service.log), 'world.service.killed', 'boot rollback', 'SIGKILL');
2070
2365
  const surviving = undeadPids(pids);
2071
2366
  if (surviving.length)
2072
2367
  rollbackErrors.push('owned processes remain alive or uncertain');
2073
2368
  if (error instanceof CommandRetirementError)
2074
- rollbackErrors.push(error.retirement ?? error.message);
2369
+ rollbackErrors.push(error.retirement ?? errorMessage(error));
2075
2370
  if (rollbackErrors.length > 0) {
2076
2371
  cleanupUncertain = true;
2077
2372
  try {
@@ -2079,7 +2374,7 @@ export async function upWorld(configId, options = {}, abort) {
2079
2374
  saveWorldInstance(worldInstance);
2080
2375
  }
2081
2376
  catch (bookkeepingError) {
2082
- rollbackErrors.push(`rollback bookkeeping failed: ${bookkeepingError instanceof Error ? bookkeepingError.message : String(bookkeepingError)}`);
2377
+ rollbackErrors.push(`rollback bookkeeping failed: ${errorMessage(bookkeepingError)}`);
2083
2378
  }
2084
2379
  const suffix = `\n(rollback incomplete — processes or an external stack may still be running: ${rollbackErrors.join('; ')})`;
2085
2380
  // Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
@@ -2087,7 +2382,7 @@ export async function upWorld(configId, options = {}, abort) {
2087
2382
  if (error instanceof Error)
2088
2383
  error.message += suffix;
2089
2384
  else
2090
- throw new Error(`${String(error)}${suffix}`);
2385
+ throw new Error(`${errorMessage(error)}${suffix}`);
2091
2386
  }
2092
2387
  if (rollbackErrors.length === 0) {
2093
2388
  // Publication failure must retain ownership. Prune requires both a receipt and release.
@@ -2100,36 +2395,52 @@ export async function upWorld(configId, options = {}, abort) {
2100
2395
  cleanupUncertain = false;
2101
2396
  }
2102
2397
  catch (receiptError) {
2103
- const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${receiptError instanceof Error ? receiptError.message : String(receiptError)}; lifecycle retained)`;
2398
+ const note = `\n(rollback stopped every process but could not publish its teardown receipt: ${errorMessage(receiptError)}; lifecycle retained)`;
2104
2399
  if (error instanceof Error)
2105
2400
  error.message += note;
2106
2401
  else
2107
- throw new Error(`${String(error)}${note}`);
2402
+ throw new Error(`${errorMessage(error)}${note}`);
2108
2403
  }
2109
2404
  }
2110
2405
  throw error;
2111
2406
  }
2112
2407
  }
2113
2408
  catch (error) {
2409
+ bootFailed = true;
2410
+ bootFailure = error;
2114
2411
  if (resourcesClaimed) {
2115
2412
  try {
2116
- recordWorldLifecycleEvent(root, name, `boot failed: ${error instanceof Error ? error.message : String(error)}`);
2413
+ recordWorldLifecycleEvent(root, name, `boot failed: ${errorMessage(error)}`);
2117
2414
  }
2118
2415
  catch (diagnosticError) {
2119
- process.stderr.write(`World "${name}": could not save boot failure: ${String(diagnosticError)}; original failure: ${String(error)}\n`);
2416
+ process.stderr.write(`World "${name}": could not save boot failure: ${errorMessage(diagnosticError)}; original failure: ${errorMessage(error)}\n`);
2417
+ }
2418
+ if (!cleanupUncertain) {
2419
+ try {
2420
+ releaseWorldLifecycle(root, name);
2421
+ }
2422
+ catch (release) {
2423
+ bootFailure = new AggregateError([error, release], 'World startup and lifecycle release failed');
2424
+ }
2120
2425
  }
2121
- if (!cleanupUncertain)
2122
- releaseWorldLifecycle(root, name);
2123
2426
  }
2124
- throw error;
2427
+ throw bootFailure;
2125
2428
  }
2126
2429
  finally {
2127
- rmSync(bootingFile(root, name), { force: true });
2430
+ try {
2431
+ rmSync(bootingFile(root, name), { force: true });
2432
+ }
2433
+ catch (cleanup) {
2434
+ if (bootFailed)
2435
+ throw new AggregateError([bootFailure, cleanup], 'World startup and boot marker cleanup failed');
2436
+ throw cleanup;
2437
+ }
2128
2438
  }
2129
2439
  }
2130
2440
  async function teardownExternalServices(instance) {
2131
2441
  const stopped = [];
2132
2442
  const errors = [];
2443
+ const unavailable = [];
2133
2444
  for (const service of Object.values(instance.services)) {
2134
2445
  if (service.type !== 'external')
2135
2446
  continue;
@@ -2144,10 +2455,13 @@ async function teardownExternalServices(instance) {
2144
2455
  stopped.push(service.id);
2145
2456
  }
2146
2457
  catch (error) {
2147
- errors.push(error instanceof Error ? error.message : String(error));
2458
+ const message = errorMessage(error);
2459
+ errors.push(message);
2460
+ if (error instanceof ExternalCommandUnavailableError)
2461
+ unavailable.push(message);
2148
2462
  }
2149
2463
  }
2150
- return { stopped, errors };
2464
+ return { stopped, errors, unavailable };
2151
2465
  }
2152
2466
  /** SIGTERM contract (documented in docs/concepts/worlds.md § Teardown): services get SIGTERM to their process
2153
2467
  * GROUP, then `downWorld` waits up to this grace for them to exit before SIGKILLing survivors.
@@ -2156,19 +2470,20 @@ const DOWN_GRACE_MS_DEFAULT = 5_000;
2156
2470
  /** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
2157
2471
  * leader), falling back to the single pid when the group signal fails. Returns the pids that
2158
2472
  * accepted the signal. */
2159
- function signalPids(pids, signal) {
2473
+ async function signalPids(pids, signal, identities, handles) {
2160
2474
  const signalled = [];
2161
2475
  for (const pid of pids) {
2476
+ if (!undeadPids([pid]).length)
2477
+ continue;
2478
+ const identity = Object.hasOwn(identities, pid) ? { pid, start: identities[pid] } : undefined;
2162
2479
  try {
2163
- process.kill(-pid, signal);
2164
- signalled.push(pid);
2165
- }
2166
- catch {
2167
- try {
2168
- process.kill(pid, signal);
2480
+ const child = handles?.get(pid);
2481
+ if (child ? await signalHeldChild(child, signal, true, Promise.resolve(identity))
2482
+ : await signalOwnedProcess(identity, signal, true) || await signalOwnedProcess(identity, signal))
2169
2483
  signalled.push(pid);
2170
- }
2171
- catch { }
2484
+ }
2485
+ catch (error) {
2486
+ process.stderr.write(`${errorMessage(error)}\n`);
2172
2487
  }
2173
2488
  }
2174
2489
  return signalled;
@@ -2187,43 +2502,51 @@ const KILL_CONFIRM_MS = 2_000;
2187
2502
  * exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
2188
2503
  * actually gone (not just signalled) by the time this returns. Returns the pids that needed
2189
2504
  * escalation. */
2190
- async function killSurvivorsAfterGrace(pids, graceMs) {
2505
+ async function killSurvivorsAfterGrace(pids, graceMs, identities, handles) {
2191
2506
  const deadline = Date.now() + graceMs;
2192
2507
  while (undeadPids(pids).length > 0 && Date.now() < deadline) {
2193
2508
  await new Promise((resolveWait) => setTimeout(resolveWait, 50));
2194
2509
  }
2195
2510
  const survivors = undeadPids(pids);
2196
- signalPids(survivors, 'SIGKILL');
2511
+ await signalPids(survivors, 'SIGKILL', identities, handles);
2197
2512
  const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
2198
2513
  while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
2199
2514
  await new Promise((resolveWait) => setTimeout(resolveWait, 20));
2200
2515
  }
2201
2516
  return survivors;
2202
2517
  }
2203
- /** Synchronous twin of `killSurvivorsAfterGrace` for callers (`unshareWorld`) that must stay
2204
- * synchronous — same "poll until dead or grace expires, then SIGKILL survivors, then confirm they
2205
- * are actually gone" contract, using an `Atomics.wait` backoff (already this file's pattern for
2206
- * synchronous polling — see `ensureWorldProxy`) instead of `setTimeout`, since a sync function has
2207
- * no event loop to yield to. */
2208
- function killSurvivorsAfterGraceSync(pids, graceMs) {
2209
- const deadline = Date.now() + graceMs;
2210
- while (undeadPids(pids).length > 0 && Date.now() < deadline) {
2518
+ /** The same bounded, identity-checked retirement used by World down, for provider-owned groups.
2519
+ * No caller may release a durable receipt when inspection or signal delivery is uncertain. */
2520
+ export async function retireOwnedProcessGroups(records, graceMs = DOWN_GRACE_MS_DEFAULT) {
2521
+ const pids = [...new Set(records.map(record => record.pid))];
2522
+ const identities = Object.fromEntries(records.map(record => [record.pid, record.start]));
2523
+ await signalPids(pids, 'SIGCONT', identities);
2524
+ await signalPids(pids, 'SIGTERM', identities);
2525
+ await killSurvivorsAfterGrace(pids, graceMs, identities);
2526
+ const remaining = undeadPids(pids);
2527
+ if (remaining.length)
2528
+ throw new CommandRetirementError(`Guest process groups still alive or uninspectable: ${remaining.join(', ')}; ownership retained`);
2529
+ }
2530
+ /** The synchronous proxy starter can retire only its still-held child/group. Numeric
2531
+ * recovery belongs to async downWorld, never to a blocking identity lookup. */
2532
+ function killHeldSurvivorsSync(child, graceMs) {
2533
+ if (!child.pid)
2534
+ return;
2535
+ const pids = [child.pid], deadline = Date.now() + graceMs;
2536
+ while (undeadPids(pids).length && Date.now() < deadline)
2211
2537
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
2212
- }
2213
- const survivors = undeadPids(pids);
2214
- signalPids(survivors, 'SIGKILL');
2538
+ if (undeadPids(pids).length)
2539
+ signalChild(child, 'SIGKILL', true);
2215
2540
  const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
2216
- while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
2541
+ while (undeadPids(pids).length && Date.now() < confirmDeadline)
2217
2542
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
2218
- }
2219
- return survivors;
2220
2543
  }
2221
- /** Async solely for the SIGTERM→SIGKILL grace poll — everything else (proxy stop, SIGTERM,
2222
- * external teardown) still runs in the first synchronous slice, so a legacy caller that fires
2223
- * without awaiting gets exactly the old behavior plus the deferred escalation. A blocking
2224
- * sleep-loop would keep the sync signature but stall the caller's event loop for up to the whole
2225
- * grace; a detached watchdog child would escalate too but leak orphan processes — the await is
2226
- * the honest shape (upWorld/runWorld/the CLI are already async). */
2544
+ /** Retirement awaits birth verification for recorded numbers and the SIGTERM→SIGKILL
2545
+ * grace/confirmation polls. Callers await downWorld before releasing ownership or purging
2546
+ * instance state; held children can receive their initial signal without a birth lookup.
2547
+ * External teardown and proxy retirement are joined in that same lifecycle.
2548
+ * A blocking sleep-loop would stall the caller's event loop through the grace, and a detached
2549
+ * watchdog would leave an unjoined process; lifecycle owners await retirement instead. */
2227
2550
  /**
2228
2551
  * Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
2229
2552
  * twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
@@ -2256,19 +2579,31 @@ function purgeInstanceDir(root, name) {
2256
2579
  class WorldConsumersActiveError extends Error {
2257
2580
  }
2258
2581
  export async function downWorld(name, root = process.cwd(), options = {}) {
2582
+ if (Object.hasOwn(options, 'expectedCreatedAt'))
2583
+ throw new Error('World teardown requires expectedInstance from the original boot');
2259
2584
  assertSafeWorldName(name);
2260
2585
  const resolvedRoot = resolve(root);
2261
2586
  const token = randomUUID();
2262
2587
  // Like boot's claim: a short synchronous lock protects an async lifecycle transition.
2263
2588
  // Do not hold a filesystem lock while external shutdown or the grace period waits.
2264
2589
  withFileLock(instanceLockFile(resolvedRoot, name), () => withFileLock(`${sharingClaimFile(resolvedRoot, name)}.lock`, () => {
2590
+ if (options.expectedInstance !== undefined) {
2591
+ try {
2592
+ const raw = getActiveWorldStore().read(instanceFile(resolvedRoot, name));
2593
+ const recorded = raw === null ? undefined : JSON.parse(raw);
2594
+ if (recorded?.name !== name || typeof recorded.root !== 'string' || resolve(recorded.root) !== resolvedRoot
2595
+ || 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)
2596
+ || recorded.instance !== options.expectedInstance)
2597
+ throw new Error();
2598
+ }
2599
+ catch {
2600
+ throw new Error(`World ownership mismatch: ${name}`);
2601
+ }
2602
+ }
2265
2603
  assertProxyOwnershipResolved(resolvedRoot, name);
2266
2604
  const prior = existsSync(instanceFile(resolvedRoot, name)) ? readWorldInstance(name, resolvedRoot, true) : undefined;
2267
2605
  if (prior?.resources && !prior.lifecycle)
2268
2606
  throw new Error(`World "${name}" belongs to a legacy runtime; use its pinned runtime for teardown before migration`);
2269
- if (options.expectedCreatedAt !== undefined && readWorldInstance(name, resolvedRoot, true).createdAt !== options.expectedCreatedAt) {
2270
- throw new Error(`World "${name}" generation changed; teardown refused`);
2271
- }
2272
2607
  if (options.purge)
2273
2608
  assertWorldStateRemovable(resolvedRoot, instanceDir(resolvedRoot, name));
2274
2609
  prepareBootForDown(resolvedRoot, name);
@@ -2318,6 +2653,7 @@ async function downWorldClaimed(name, root, options) {
2318
2653
  catch {
2319
2654
  instance = undefined;
2320
2655
  }
2656
+ readGuestProcesses(dir, instance?.instance); // missing World metadata never erases guest ownership
2321
2657
  // Nothing to tear down when the metadata is gone and every process the lifecycle recorded has exited.
2322
2658
  if (!instance && before === undefined && releaseAbandonedWorldLifecycle(resolvedRoot, name)) {
2323
2659
  return { name, stopped: [], escalated: [], externalStopped: [], externalErrors: [] };
@@ -2326,28 +2662,37 @@ async function downWorldClaimed(name, root, options) {
2326
2662
  return { name, stopped: [], escalated: [], externalStopped: [],
2327
2663
  externalErrors: ['World instance or external teardown metadata missing or unreadable; cleanup refused; lifecycle evidence retained'] };
2328
2664
  }
2329
- const proxyPid = readProxyState(resolvedRoot, name)?.pid;
2330
- stopWorldProxy(resolvedRoot, name);
2665
+ const proxyState = readProxyState(resolvedRoot, name);
2666
+ const proxyPid = proxyState?.pid;
2667
+ await stopWorldProxy(resolvedRoot, name, spawnedChildren);
2331
2668
  const foregroundPid = instance?.lastRun?.state !== 'completed' ? instance?.lastRun?.consumerPid : undefined;
2332
2669
  const holderPid = instance?.lifecycle?.holderPid ?? instance?.resources?.holderPid;
2333
2670
  const recordedPids = [...new Set([...readPids(pidsFile), ...(foregroundPid ? [foregroundPid] : []), ...(proxyPid ? [proxyPid] : [])])].filter(pid => pid !== holderPid);
2334
- const foreign = foreignPids(resolvedRoot, name, recordedPids);
2671
+ const identities = readIdentities(resolvedRoot, name);
2672
+ if (proxyState?.birth)
2673
+ identities[proxyState.pid] = proxyState.birth;
2674
+ const foreign = (await Promise.all(recordedPids.map(async (pid) => {
2675
+ const now = identities[pid] ? await processBirth(pid) : undefined;
2676
+ return now && now !== identities[pid] ? { pid, recorded: identities[pid], now } : undefined;
2677
+ }))).filter((entry) => !!entry);
2335
2678
  const pids = recordedPids.filter((pid) => !foreign.some((entry) => entry.pid === pid));
2336
2679
  const processServices = Object.values(instance?.services ?? {}).filter((service) => service.type !== 'external' && service.log);
2337
2680
  // Only a service still alive is stopped by this `down`; a dead one's end was not the World's.
2338
2681
  const stillAlive = undeadPids(processServices.map((service) => service.pid).filter((pid) => pids.includes(pid)));
2339
2682
  recordWorldStop(processServices.filter((service) => stillAlive.includes(service.pid)).map((service) => service.log), 'world.service.stop', 'down', 'SIGTERM');
2340
- const stopped = signalPids(pids, 'SIGTERM');
2683
+ const stopped = await signalPids(pids, 'SIGTERM', identities, spawnedChildren);
2341
2684
  // Stop self-managed externals via their declared `down` command. A missing instance is
2342
2685
  // safe only without retained lifecycle ownership, checked before any signals above.
2343
2686
  let externalStopped = [];
2344
2687
  let externalErrors = [];
2688
+ let unavailable = [];
2345
2689
  try {
2346
2690
  if (!instance)
2347
2691
  throw new Error('World instance unavailable');
2348
2692
  const result = await teardownExternalServices(instance);
2349
2693
  externalStopped = result.stopped;
2350
2694
  externalErrors = result.errors;
2695
+ unavailable = result.unavailable;
2351
2696
  }
2352
2697
  catch {
2353
2698
  // no instance.json (never fully came up) — only owned processes needed stopping.
@@ -2357,12 +2702,22 @@ async function downWorldClaimed(name, root, options) {
2357
2702
  // Escalate AFTER the external teardown so externals never wait on the grace, and
2358
2703
  // poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
2359
2704
  // already dead, and livePids() re-checks anyway.
2360
- const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
2705
+ const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren);
2361
2706
  recordWorldStop(processServices.filter((service) => escalated.includes(service.pid)).map((service) => service.log), 'world.service.killed', 'down', 'SIGKILL');
2707
+ // A launch which had already entered its spawn seam may publish while TERM is delivered.
2708
+ // Once services/agents retire, join every late receipt before certifying this generation stopped.
2709
+ const lateGuests = readGuestProcesses(dir, instance?.instance).filter(receipt => !pids.includes(receipt.record.pid));
2710
+ try {
2711
+ await retireOwnedProcessGroups(lateGuests.map(receipt => receipt.record), options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
2712
+ }
2713
+ catch (error) {
2714
+ externalErrors.push(errorMessage(error));
2715
+ }
2716
+ pids.push(...lateGuests.map(receipt => receipt.record.pid));
2362
2717
  if (holderPid && !externalErrors.length && !undeadPids(pids).length) {
2363
2718
  pids.push(holderPid);
2364
- stopped.push(...signalPids([holderPid], 'SIGTERM'));
2365
- escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT));
2719
+ stopped.push(...await signalPids([holderPid], 'SIGTERM', identities, spawnedChildren));
2720
+ escalated.push(...await killSurvivorsAfterGrace([holderPid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities, spawnedChildren));
2366
2721
  }
2367
2722
  // Finalization belongs to this exact generation, not just the reusable name. A new up may
2368
2723
  // win once the old PIDs exit; never erase its PID file, lifecycle or instance directory.
@@ -2379,24 +2734,41 @@ async function downWorldClaimed(name, root, options) {
2379
2734
  externalErrors.push('Proxy or sharing lifecycle changed during teardown; cleanup refused');
2380
2735
  return result;
2381
2736
  }
2382
- const surviving = undeadPids(pids);
2737
+ const guestReceipts = readGuestProcesses(dir, instance?.instance);
2738
+ const surviving = undeadPids([...pids, ...guestReceipts.map(receipt => receipt.record.pid)]);
2383
2739
  if (surviving.length)
2384
2740
  externalErrors.push(`World processes still alive or uninspectable after teardown: ${surviving.join(', ')}; cleanup refused`);
2741
+ if (guestReceipts.some(receipt => foreign.some(entry => entry.pid === receipt.record.pid)))
2742
+ externalErrors.push('Guest spawn identity changed; receipt retained and cleanup refused');
2743
+ let externalAbandoned;
2744
+ if (options.purge && instance && externalErrors.length && externalErrors.length === unavailable.length && !foreign.length) {
2745
+ const inventory = exitedWorldLifecyclePids(resolvedRoot, name, instance.createdAt);
2746
+ if (inventory && !undeadPids([...pids, ...inventory, ...(holderPid ? [holderPid] : [])]).length) {
2747
+ const consumers = worldConsumers(resolvedRoot, name);
2748
+ if (!consumers.warnings.length && consumers.consumers.every(c => c.state === 'exited')) {
2749
+ externalAbandoned = [...externalErrors];
2750
+ recordWorldLifecycleEvent(resolvedRoot, name, `explicit purge surrendered state; unavailable backend not certified stopped:\n${externalAbandoned.join('\n')}`);
2751
+ externalErrors.splice(0);
2752
+ }
2753
+ }
2754
+ }
2385
2755
  if (externalErrors.length)
2386
2756
  return result;
2757
+ releaseGuestProcesses(guestReceipts);
2387
2758
  rmSync(pidsFile, { force: true });
2388
2759
  if (before) {
2389
2760
  writeTextAtomic(join(dir, 'stopped.json'), `${JSON.stringify({
2390
2761
  instanceHash: createHash('sha256').update(before).digest('hex'), stoppedAt: new Date().toISOString(),
2762
+ ...(externalAbandoned ? { externalAbandoned } : {}),
2391
2763
  })}\n`);
2392
2764
  }
2393
- releaseWorldLifecycle(resolvedRoot, name);
2765
+ releaseWorldLifecycle(resolvedRoot, name, externalAbandoned ? 'recorded processes exited; explicit purge surrendered state; ownership released' : undefined);
2394
2766
  if (!options.purge)
2395
2767
  return result;
2396
2768
  const purged = purgeInstanceDir(resolvedRoot, name);
2397
2769
  if (purged && instance)
2398
2770
  clearBrowserFiles(instance.dirs.data, resolvedRoot);
2399
- return { ...result, purged };
2771
+ return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
2400
2772
  }));
2401
2773
  }
2402
2774
  export function statusWorld(name, root = process.cwd(), options = {}) {
@@ -2499,7 +2871,7 @@ function worldCleanupEntry(name, root) {
2499
2871
  assertWorldStateRemovable(root, path);
2500
2872
  }
2501
2873
  catch (error) {
2502
- return { ...entry, reason: error instanceof Error ? error.message : 'Branch ancestry unreadable; retained' };
2874
+ return { ...entry, reason: errorMessage(error) };
2503
2875
  }
2504
2876
  return { ...entry, eligible: true, reason: 'successfully stopped; disposable instance data only', stoppedAt: receipt.stoppedAt, logicalBytes: logicalTreeBytes(path) };
2505
2877
  }
@@ -2540,7 +2912,7 @@ function worldServiceControlRoot(name, service, root = process.cwd()) {
2540
2912
  const instance = readWorldInstance(name, resolve(root));
2541
2913
  if (!instance.services[service])
2542
2914
  throw new Error(`World ${name} has no service "${service}"`);
2543
- return join(instance.dirs.data, service);
2915
+ return ownedPath(instance.dirs.data, service);
2544
2916
  }
2545
2917
  function worldServiceStateService(controlRoot, requestedService) {
2546
2918
  const stateRoot = join(controlRoot, stateDirName(), 'world');
@@ -2639,7 +3011,7 @@ async function waitForPublicUrl(child, logPath, timeoutMs, requireTryCloudflare)
2639
3011
  });
2640
3012
  child.once('error', (error) => {
2641
3013
  exited = true;
2642
- exitSummary = error.message;
3014
+ exitSummary = errorMessage(error);
2643
3015
  });
2644
3016
  const started = Date.now();
2645
3017
  while (Date.now() - started < timeoutMs) {
@@ -2671,18 +3043,18 @@ function commandExists(command) {
2671
3043
  // Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
2672
3044
  return OWN_COMMANDS[command] !== undefined || onPath(command);
2673
3045
  }
2674
- /** A lifecycle command naming this runtime's own bin (`volter-world-infra`, which `init` names in every managed
2675
- * infrastructure's lifecycle) runs this runtime's own script with the executable running it, when nothing on PATH
2676
- * answers the name: a bare `volter-world down` (no runner that put node_modules/.bin on PATH) failed "not found on
2677
- * PATH" and left the infrastructure up. Only the name is mapped — no directory joins PATH — and a same-named
2678
- * command on PATH still wins, as before. */
3046
+ /** Built-in lifecycle names belong to the executing runtime. PATH may contain another
3047
+ * checkout's package-manager bin links; an operator override names an explicit executable path. */
2679
3048
  const OWN_COMMANDS = { 'volter-world-infra': 'infra-cli' };
2680
3049
  export function ownCommand(command) {
2681
3050
  const [bin, ...args] = command;
2682
3051
  const stem = bin === undefined ? undefined : OWN_COMMANDS[bin];
2683
- if (stem === undefined || onPath(bin))
3052
+ if (stem === undefined)
2684
3053
  return command;
2685
- return [process.execPath, siblingScript(import.meta.url, stem), ...args];
3054
+ const script = siblingScript(import.meta.url, stem);
3055
+ const loader = typeof globalThis.Bun === 'undefined' && script.endsWith('.ts')
3056
+ ? ['--experimental-transform-types', '--no-warnings'] : [];
3057
+ return [process.execPath, ...loader, script, ...args];
2686
3058
  }
2687
3059
  /** A command answers when it names an executable: a path is checked as a file (what `which` does with a path),
2688
3060
  * never by spawning `which`. `ownCommand` hands this the running executable's own path (process.execPath), and a
@@ -2828,7 +3200,7 @@ async function shareWorldReserved(name, options) {
2828
3200
  throw new Error(`World "${name}" service "${serviceId}" has no local URL to share (external/self-managed services are not assigned one)`);
2829
3201
  }
2830
3202
  const tunnel = shareCommand(options, service.url);
2831
- const log = join(instance.dirs.logs, `${serviceId}.tunnel.log`);
3203
+ const log = ownedPath(instance.dirs.logs, `${serviceId}.tunnel.log`);
2832
3204
  // Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
2833
3205
  // share from accepting the previous process's URL handshake as the new process's endpoint.
2834
3206
  const out = openPrivateLog(log, 'w');
@@ -2838,11 +3210,18 @@ async function shareWorldReserved(name, options) {
2838
3210
  detached: true,
2839
3211
  stdio: ['ignore', out, out],
2840
3212
  });
2841
- closeSync(out);
3213
+ const birth = retainSpawn(child);
3214
+ const identities = {};
3215
+ const handles = new Map(child.pid ? [[child.pid, child]] : []);
2842
3216
  let publicUrl;
2843
3217
  let publicVerification;
2844
3218
  let pidsWritten = false;
2845
3219
  try {
3220
+ closeSync(out);
3221
+ const identity = await birth;
3222
+ if (!identity?.start)
3223
+ throw new Error('Tunnel birth capture failed; retiring held child');
3224
+ identities[identity.pid] = identity.start;
2846
3225
  publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
2847
3226
  if (options.verifyPath !== false) {
2848
3227
  publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
@@ -2877,8 +3256,8 @@ async function shareWorldReserved(name, options) {
2877
3256
  // gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
2878
3257
  // failed/rejected `share`).
2879
3258
  if (child.pid) {
2880
- signalPids([child.pid], 'SIGTERM');
2881
- await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
3259
+ await signalPids([child.pid], 'SIGTERM', identities, handles);
3260
+ await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT, identities, handles);
2882
3261
  }
2883
3262
  const cleanupProblems = [];
2884
3263
  if (child.pid && undeadPids([child.pid]).length > 0)
@@ -2891,11 +3270,11 @@ async function shareWorldReserved(name, options) {
2891
3270
  writePidsFromInstance(instance);
2892
3271
  }
2893
3272
  catch (restoreError) {
2894
- cleanupProblems.push(`could not restore pids file: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
3273
+ cleanupProblems.push(`could not restore pids file: ${errorMessage(restoreError)}`);
2895
3274
  }
2896
3275
  }
2897
3276
  if (cleanupProblems.length > 0) {
2898
- throw new Error(`${error instanceof Error ? error.message : String(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
3277
+ throw new Error(`${errorMessage(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
2899
3278
  }
2900
3279
  throw error;
2901
3280
  }
@@ -2944,11 +3323,9 @@ export async function shareWorldServices(name, options = {}) {
2944
3323
  * services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
2945
3324
  * tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
2946
3325
  * ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
2947
- * nothing is shared — the exact "half-shared" state docs/concepts/worlds.md promises cannot exist. Stays
2948
- * synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
2949
- * (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
3326
+ * 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,
2950
3327
  * for callers that want to report it. */
2951
- export function unshareWorld(name, options = {}) {
3328
+ export async function unshareWorld(name, options = {}) {
2952
3329
  const root = resolve(options.root ?? process.cwd());
2953
3330
  const instance = readWorldInstance(name, root);
2954
3331
  const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
@@ -2960,13 +3337,14 @@ export function unshareWorld(name, options = {}) {
2960
3337
  throw new Error(`World "${name}" has no service "${serviceId}"`);
2961
3338
  if (service.tunnel?.pid) {
2962
3339
  const pid = service.tunnel.pid;
2963
- signalPids([pid], 'SIGTERM');
2964
- const survivors = killSurvivorsAfterGraceSync([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
3340
+ const identities = readIdentities(root, name);
3341
+ await signalPids([pid], 'SIGTERM', identities);
3342
+ const survivors = await killSurvivorsAfterGrace([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT, identities);
2965
3343
  if (survivors.length > 0)
2966
3344
  escalated.push(...survivors);
2967
3345
  // `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
2968
- // (exited but unreaped — likely since we just polled it synchronously via `Atomics.wait`,
2969
- // which blocks the event loop that would otherwise let this process auto-reap its child) still
3346
+ // (exited but unreaped, possibly awaiting its parent's reap) still
3347
+ // occupies a process-table entry and
2970
3348
  // answers `kill(pid, 0)` yet is dead for every practical purpose — see `survivingOwnedGroups`' own
2971
3349
  // comment. Using plain `livePids` here would treat every zombie as "still shared".
2972
3350
  if (undeadPids([pid]).length > 0) {
@@ -3018,7 +3396,7 @@ async function probeExternalOnce(ready, cwd, env, logPath) {
3018
3396
  return { ok: false, message: `probe returned HTTP ${response.status}` };
3019
3397
  }
3020
3398
  catch (error) {
3021
- return { ok: false, message: `probe failed: ${error instanceof Error ? error.message : String(error)}` };
3399
+ return { ok: false, message: `probe failed: ${errorMessage(error)}` };
3022
3400
  }
3023
3401
  }
3024
3402
  if (ready.stdoutMatch !== undefined) {
@@ -3046,7 +3424,7 @@ export async function doctorWorld(name, options = {}) {
3046
3424
  checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
3047
3425
  }
3048
3426
  catch (error) {
3049
- const message = error instanceof Error ? error.message : String(error);
3427
+ const message = errorMessage(error);
3050
3428
  return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
3051
3429
  }
3052
3430
  checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
@@ -3100,7 +3478,7 @@ export async function doctorWorld(name, options = {}) {
3100
3478
  checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
3101
3479
  }
3102
3480
  catch (error) {
3103
- checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${error instanceof Error ? error.message : String(error)}` });
3481
+ checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${errorMessage(error)}` });
3104
3482
  }
3105
3483
  }
3106
3484
  else {
@@ -3143,7 +3521,7 @@ export async function doctorWorld(name, options = {}) {
3143
3521
  checks.push({
3144
3522
  id: `service:${service.id}:tcp`,
3145
3523
  ok: false,
3146
- message: error instanceof Error ? error.message : String(error),
3524
+ message: errorMessage(error),
3147
3525
  });
3148
3526
  }
3149
3527
  if (service.tunnel || service.publicUrl) {
@@ -3173,7 +3551,7 @@ export async function doctorWorld(name, options = {}) {
3173
3551
  checks.push({
3174
3552
  id: `service:${service.id}:public-health`,
3175
3553
  ok: false,
3176
- message: error instanceof Error ? error.message : String(error),
3554
+ message: errorMessage(error),
3177
3555
  });
3178
3556
  }
3179
3557
  }
@@ -3263,7 +3641,7 @@ export async function runWorldOwned(configId, command, options, abort) {
3263
3641
  let failed = false;
3264
3642
  let retired = true;
3265
3643
  try {
3266
- outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { abort, verbose: options.verbose });
3644
+ outcome = await runWithWorldEnvLogged(instance.name, command, instance.root, { cwd: options.cwd, abort, verbose: options.verbose });
3267
3645
  instance.lastRun = outcome;
3268
3646
  saveWorldInstance(instance);
3269
3647
  }
@@ -3284,7 +3662,7 @@ export async function runWorldOwned(configId, command, options, abort) {
3284
3662
  }
3285
3663
  catch (error) {
3286
3664
  if (failed)
3287
- throw new AggregateError([failure, error], `World execution and cleanup failed: ${String(failure)}; ${String(error)}`);
3665
+ throw new AggregateError([failure, error], `World execution and cleanup failed: ${errorMessage(failure)}; ${errorMessage(error)}`);
3288
3666
  throw error;
3289
3667
  }
3290
3668
  }
@@ -3297,7 +3675,7 @@ async function finishTaskWorld(instance) {
3297
3675
  let uncertainSince;
3298
3676
  for (;;) {
3299
3677
  try {
3300
- const result = await downWorld(instance.name, instance.root, { expectedCreatedAt: instance.createdAt });
3678
+ const result = await downWorld(instance.name, instance.root, { expectedInstance: instance.instance });
3301
3679
  if (result.externalErrors.length) {
3302
3680
  const message = `World "${instance.name}" cleanup incomplete: ${result.externalErrors.join('; ')}. Reservation and diagnostics retained.`;
3303
3681
  // The caller that would have read this on stderr may be gone; the lifecycle log keeps it.
@@ -3373,7 +3751,7 @@ function worldAttachedCommandEnv(name, root, verbose = false) {
3373
3751
  if (error instanceof CaTrustError)
3374
3752
  throw error;
3375
3753
  if (sealed && hostHasRawEgress())
3376
- 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)})`);
3754
+ throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
3377
3755
  // Keep env-only redirect if the ambient proxy cannot start.
3378
3756
  }
3379
3757
  return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
@@ -3410,8 +3788,15 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
3410
3788
  env: { ...attached.env, ...(options.env ?? {}) },
3411
3789
  stdio: 'inherit',
3412
3790
  });
3413
- const forward = (signal) => { if (child.exitCode === null && child.signalCode === null)
3414
- child.kill(signal); };
3791
+ const forward = (signal) => {
3792
+ try {
3793
+ if (child.pid && child.exitCode === null && child.signalCode === null)
3794
+ signalChild(child, signal);
3795
+ }
3796
+ catch (error) {
3797
+ process.stderr.write(`${errorMessage(error)}\n`);
3798
+ }
3799
+ };
3415
3800
  process.on('SIGINT', forward);
3416
3801
  process.on('SIGTERM', forward);
3417
3802
  child.once('error', () => { offSignal('SIGINT', forward); offSignal('SIGTERM', forward); resolveExit(1); });
@@ -3584,7 +3969,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3584
3969
  attached = worldAttachedCommandEnv(name, root, options.verbose);
3585
3970
  }
3586
3971
  catch (cause) {
3587
- return finish(1, undefined, cause instanceof Error ? cause.message : String(cause));
3972
+ return finish(1, undefined, errorMessage(cause));
3588
3973
  }
3589
3974
  return await new Promise((resolveOutcome, rejectOutcome) => {
3590
3975
  let spawnError;
@@ -3598,7 +3983,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3598
3983
  });
3599
3984
  }
3600
3985
  catch (cause) {
3601
- resolveOutcome(finish(1, undefined, cause instanceof Error ? cause.message : String(cause)));
3986
+ resolveOutcome(finish(1, undefined, errorMessage(cause)));
3602
3987
  return;
3603
3988
  }
3604
3989
  const lifetime = commandLifetime(child, options.abort);
@@ -3608,7 +3993,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3608
3993
  writeSync(logFd, chunk);
3609
3994
  }
3610
3995
  catch (error) {
3611
- spawnError = String(error);
3996
+ spawnError = errorMessage(error);
3612
3997
  lifetime.cancel('SIGTERM');
3613
3998
  }
3614
3999
  });
@@ -3618,12 +4003,12 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3618
4003
  writeSync(logFd, chunk);
3619
4004
  }
3620
4005
  catch (error) {
3621
- spawnError = String(error);
4006
+ spawnError = errorMessage(error);
3622
4007
  lifetime.cancel('SIGTERM');
3623
4008
  }
3624
4009
  });
3625
4010
  child.once('error', (cause) => {
3626
- spawnError = cause.message;
4011
+ spawnError = errorMessage(cause);
3627
4012
  });
3628
4013
  const activeRecord = {
3629
4014
  state: 'running',
@@ -3636,7 +4021,7 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
3636
4021
  writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(activeRecord, null, 2)}\n`);
3637
4022
  }
3638
4023
  catch (error) {
3639
- spawnError = String(error);
4024
+ spawnError = errorMessage(error);
3640
4025
  lifetime.cancel('SIGTERM');
3641
4026
  }
3642
4027
  void lifetime.finish().then(({ code, signal }) => {
@@ -3690,8 +4075,8 @@ export async function shellWorld(name, root = process.cwd()) {
3690
4075
  if (error instanceof CaTrustError)
3691
4076
  throw error;
3692
4077
  if (sealed)
3693
- 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)})`);
3694
- process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${error instanceof Error ? error.message : String(error)}); falling back to env-only redirect.\n`);
4078
+ throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to open a shell (${errorMessage(error)})`);
4079
+ process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${errorMessage(error)}); falling back to env-only redirect.\n`);
3695
4080
  }
3696
4081
  }
3697
4082
  else {
@@ -3746,17 +4131,7 @@ function currentProxyState(root, name) {
3746
4131
  return null;
3747
4132
  if (existing.guestUrl)
3748
4133
  return existing;
3749
- try {
3750
- process.kill(existing.pid, 'SIGTERM');
3751
- }
3752
- catch { /* gone already */ }
3753
- for (let i = 0; i < 50 && isAlive(existing.pid); i += 1)
3754
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
3755
- try {
3756
- rmSync(proxyStateFile(root, name), { force: true });
3757
- }
3758
- catch { /* ignore */ }
3759
- return null;
4134
+ throw new Error('World proxy lacks its guest listener; stop this World before restarting its proxy');
3760
4135
  }
3761
4136
  function ensureWorldProxyUnlocked(name, root, caller) {
3762
4137
  const resolvedRoot = resolve(root);
@@ -3814,16 +4189,36 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
3814
4189
  child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', root, ...(envFile ? [`--env-file=${envFile}`] : [])], { windowsHide: true,
3815
4190
  cwd: root, env: process.env, detached: true, stdio: ['ignore', proxyOut, proxyOut],
3816
4191
  });
4192
+ if (child.pid) {
4193
+ spawnedChildren.set(child.pid, child);
4194
+ if (boot) {
4195
+ boot.pids.add(child.pid);
4196
+ boot.handles.set(child.pid, child);
4197
+ }
4198
+ }
3817
4199
  }
3818
4200
  finally {
3819
4201
  closeSync(proxyOut);
3820
4202
  }
3821
4203
  }
3822
4204
  catch (error) {
4205
+ if (child?.pid) {
4206
+ try {
4207
+ signalChild(child, 'SIGTERM', true);
4208
+ killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
4209
+ if (undeadPids([child.pid]).length)
4210
+ throw new CommandRetirementError('Proxy retirement unconfirmed; startup intent retained');
4211
+ }
4212
+ catch (cleanup) {
4213
+ throw new AggregateError([error, cleanup], 'Proxy spawn/descriptor close and retirement failed');
4214
+ }
4215
+ }
3823
4216
  try {
3824
4217
  rmSync(marker, { force: true });
3825
4218
  }
3826
- catch { /* the spawn's failure is the one to report */ } // no child was returned by spawn
4219
+ catch (cleanup) {
4220
+ throw new AggregateError([error, cleanup], 'Proxy spawn and intent cleanup failed');
4221
+ }
3827
4222
  throw error;
3828
4223
  }
3829
4224
  child.on('error', () => { }); // a failed spawn can emit after this synchronous operation throws
@@ -3832,20 +4227,23 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
3832
4227
  rmSync(marker, { force: true });
3833
4228
  throw new Error(`World "${name}": ambient proxy process did not start`);
3834
4229
  }
3835
- boot?.pids.add(pid); // before any fallible filesystem operation
4230
+ if (boot) {
4231
+ boot.pids.add(pid);
4232
+ boot.handles.set(pid, child);
4233
+ } // before any fallible filesystem operation
3836
4234
  child.unref();
3837
4235
  let retirementAttempted = false;
3838
4236
  const retire = () => {
3839
4237
  retirementAttempted = true;
3840
- signalPids([pid], 'SIGTERM');
3841
- killSurvivorsAfterGraceSync([pid], DOWN_GRACE_MS_DEFAULT);
4238
+ signalChild(child, 'SIGTERM', true);
4239
+ killHeldSurvivorsSync(child, DOWN_GRACE_MS_DEFAULT);
3842
4240
  if (undeadPids([pid]).length) {
3843
4241
  const error = new CommandRetirementError(`World "${name}": proxy process ${pid} retirement is unconfirmed; ownership retained`);
3844
4242
  try {
3845
4243
  retainWorldLifecycleForCleanup(root, name);
3846
4244
  }
3847
4245
  catch (cause) {
3848
- error.message += `; claim update failed: ${String(cause)}`;
4246
+ error.message += `; claim update failed: ${errorMessage(cause)}`;
3849
4247
  }
3850
4248
  throw error;
3851
4249
  }
@@ -3859,12 +4257,21 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
3859
4257
  boot.recordPids();
3860
4258
  else
3861
4259
  writeOwnedPids(root, name, [pid], [pid]);
3862
- rmSync(marker, { force: true }); // canonical ownership is now durable
3863
4260
  const deadlineMs = proxyDaemonDeadlineMs();
3864
4261
  const deadline = Date.now() + deadlineMs;
3865
4262
  while (Date.now() < deadline) {
3866
4263
  const state = readProxyState(root, name);
3867
4264
  if (state?.pid === pid) {
4265
+ if (!state.birth)
4266
+ throw new Error('Proxy birth token missing; retiring held child');
4267
+ spawnedIdentities.set(pid, { pid, start: state.birth });
4268
+ if (boot) {
4269
+ boot.identities[pid] = state.birth;
4270
+ boot.recordPids();
4271
+ }
4272
+ else
4273
+ writeOwnedPids(root, name, [pid], [pid]);
4274
+ rmSync(marker, { force: true }); // both numeric ownership and birth are durable
3868
4275
  if (!boot)
3869
4276
  handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
3870
4277
  return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath, caller, state.guestUrl) };
@@ -3888,14 +4295,13 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
3888
4295
  retire();
3889
4296
  }
3890
4297
  catch (retirement) {
3891
- const cause = error instanceof Error ? error.message : String(error);
4298
+ const cause = errorMessage(error);
3892
4299
  if (retirement instanceof CommandRetirementError) {
3893
- Object.assign(retirement, { retirement: retirement.message, cause: error });
3894
- retirement.message = `${cause}; ${retirement.message}`;
4300
+ Object.assign(retirement, { retirement: errorMessage(retirement), cause: error });
4301
+ retirement.message = `${cause}; ${errorMessage(retirement)}`;
3895
4302
  throw retirement;
3896
4303
  }
3897
- if (error instanceof Error)
3898
- error.message += `; retiring the proxy failed too: ${retirement instanceof Error ? retirement.message : String(retirement)}`;
4304
+ throw new AggregateError([error, retirement], 'Proxy startup and retirement failed');
3899
4305
  }
3900
4306
  }
3901
4307
  throw error;
@@ -3931,8 +4337,13 @@ export async function runProxyDaemon(name, root = process.cwd(), envFile) {
3931
4337
  env = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
3932
4338
  }
3933
4339
  const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name), note: (line) => process.stderr.write(`${line}\n`) });
3934
- const state = { pid: process.pid, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
3935
- writeFileSync(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
4340
+ const identity = await spawnIdentity(process.pid);
4341
+ if (!identity.start) {
4342
+ await proxy.close();
4343
+ throw new Error('Proxy birth capture failed');
4344
+ }
4345
+ const state = { pid: process.pid, birth: identity.start, url: proxy.url, caCertPath: proxy.caCertPath, guestUrl: proxy.guestUrl };
4346
+ writeTextAtomic(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
3936
4347
  // Not a recorded service, so it records its own start and end in the World's events (a crash included; a SIGKILL
3937
4348
  // leaves none, which doctor's proxy check then names).
3938
4349
  const events = worldEventsPath(join(instanceDir(resolvedRoot, name), 'logs'));
@@ -3960,13 +4371,19 @@ export async function runProxyDaemon(name, root = process.cwd(), envFile) {
3960
4371
  }
3961
4372
  /** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
3962
4373
  * called from `downWorld` so the trusted CA never outlives the world. */
3963
- function stopWorldProxy(root, name) {
4374
+ async function stopWorldProxy(root, name, handles) {
3964
4375
  const state = readProxyState(root, name);
3965
4376
  if (state && isAlive(state.pid)) {
3966
- try {
3967
- process.kill(state.pid, 'SIGTERM');
4377
+ const identities = readIdentities(root, name);
4378
+ const identity = { pid: state.pid, start: state.birth ?? identities[state.pid] };
4379
+ const child = handles?.get(state.pid);
4380
+ if (child)
4381
+ await signalHeldChild(child, 'SIGTERM', false, Promise.resolve(identity));
4382
+ else {
4383
+ if (!state.birth && !Object.hasOwn(identities, state.pid))
4384
+ throw new Error('Proxy spawn identity missing; retirement refused; ownership retained');
4385
+ await signalOwnedProcess(identity, 'SIGTERM');
3968
4386
  }
3969
- catch { /* ignore */ }
3970
4387
  }
3971
4388
  try {
3972
4389
  rmSync(proxyStateFile(root, name), { force: true });
@@ -4016,7 +4433,7 @@ export function activateScript(name, root = process.cwd()) {
4016
4433
  if (error instanceof CaTrustError)
4017
4434
  throw error;
4018
4435
  if (sealed && hostHasRawEgress())
4019
- throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
4436
+ throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${errorMessage(error)})`);
4020
4437
  // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
4021
4438
  }
4022
4439
  env = withAppUrl(name, root, env);