@volter/world-runtime 2.0.55 → 3.0.1

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 (121) hide show
  1. package/dist/known-external-services.json +5 -4
  2. package/dist/src/actions-runner-cli.d.ts +2 -0
  3. package/dist/src/actions-runner-cli.js +25 -0
  4. package/dist/src/actions-runner.d.ts +18 -0
  5. package/dist/src/actions-runner.js +281 -0
  6. package/dist/src/ancestry.js +1 -1
  7. package/dist/src/app-url.js +1 -1
  8. package/dist/src/backings.d.ts +29 -0
  9. package/dist/src/backings.js +45 -0
  10. package/dist/src/branch.js +3 -2
  11. package/dist/src/browser-proxy-cli.js +1 -1
  12. package/dist/src/catalog-index.d.ts +38 -0
  13. package/dist/src/catalog-index.js +37 -0
  14. package/dist/src/catalog.d.ts +16 -3
  15. package/dist/src/catalog.js +69 -26
  16. package/dist/src/changeset.d.ts +1 -1
  17. package/dist/src/changeset.js +9 -11
  18. package/dist/src/cli-args.d.ts +4 -0
  19. package/dist/src/cli-args.js +18 -0
  20. package/dist/src/cli-commands.d.ts +1 -0
  21. package/dist/src/cli-commands.js +1216 -0
  22. package/dist/src/cli.js +8 -1300
  23. package/dist/src/configs.js +1 -1
  24. package/dist/src/console-apart.d.ts +2 -2
  25. package/dist/src/console-apart.js +5 -5
  26. package/dist/src/consumers.js +1 -1
  27. package/dist/src/covers.d.ts +1 -1
  28. package/dist/src/covers.js +94 -30
  29. package/dist/src/env-output.d.ts +1 -1
  30. package/dist/src/env-output.js +2 -2
  31. package/dist/src/fixture-env.js +18 -26
  32. package/dist/src/host-cli.js +4 -2
  33. package/dist/src/host-fault-fixture.js +6 -0
  34. package/dist/src/host-ready.d.ts +8 -2
  35. package/dist/src/host-ready.js +16 -4
  36. package/dist/src/host-worker.js +6 -1
  37. package/dist/src/host.d.ts +2 -0
  38. package/dist/src/host.js +9 -1
  39. package/dist/src/index.d.ts +2 -2
  40. package/dist/src/index.js +2 -2
  41. package/dist/src/init.d.ts +33 -23
  42. package/dist/src/init.js +228 -96
  43. package/dist/src/lifecycle-record.js +2 -2
  44. package/dist/src/local-branches.js +1 -1
  45. package/dist/src/origin.d.ts +1 -1
  46. package/dist/src/origin.js +12 -10
  47. package/dist/src/pack-facts.d.ts +30 -6
  48. package/dist/src/pack-facts.js +13 -3
  49. package/dist/src/pglite-backing.js +2 -1
  50. package/dist/src/placeholder.d.ts +1 -1
  51. package/dist/src/placeholder.js +12 -4
  52. package/dist/src/project-inspect.d.ts +1 -1
  53. package/dist/src/project-inspect.js +2 -2
  54. package/dist/src/redirect-proxy.d.ts +5 -0
  55. package/dist/src/redirect-proxy.js +8 -1
  56. package/dist/src/redis-backing.js +4 -2
  57. package/dist/src/reflect.js +1 -1
  58. package/dist/src/root.d.ts +11 -33
  59. package/dist/src/root.js +28 -59
  60. package/dist/src/runtime-test-support.js +1 -1
  61. package/dist/src/runtime.d.ts +7 -1
  62. package/dist/src/runtime.js +317 -53
  63. package/dist/src/schema.d.ts +27 -5
  64. package/dist/src/schema.js +13 -12
  65. package/dist/src/served-world.d.ts +11 -35
  66. package/dist/src/served-world.js +54 -141
  67. package/dist/src/tail.js +1 -1
  68. package/dist/src/up-cli.d.ts +3 -0
  69. package/dist/src/up-cli.js +55 -0
  70. package/dist/src/websocket-relay.d.ts +1 -1
  71. package/dist/src/world-origins.d.ts +1 -1
  72. package/dist/src/world-origins.js +1 -1
  73. package/dist/src/world-view.d.ts +1 -1
  74. package/dist/src/world-view.js +2 -2
  75. package/known-external-services.json +5 -4
  76. package/package.json +11 -11
  77. package/src/actions-runner-cli.ts +24 -0
  78. package/src/actions-runner.ts +244 -0
  79. package/src/ancestry.ts +1 -1
  80. package/src/app-url.ts +1 -1
  81. package/src/backings.ts +55 -0
  82. package/src/branch.ts +3 -2
  83. package/src/browser-proxy-cli.ts +1 -1
  84. package/src/catalog-index.ts +44 -0
  85. package/src/catalog.ts +67 -24
  86. package/src/changeset.ts +10 -13
  87. package/src/cli-args.ts +17 -0
  88. package/src/cli-commands.ts +1070 -0
  89. package/src/cli.ts +9 -1139
  90. package/src/configs.ts +1 -1
  91. package/src/console-apart.ts +5 -5
  92. package/src/consumers.ts +1 -1
  93. package/src/covers.ts +83 -30
  94. package/src/env-output.ts +2 -2
  95. package/src/fixture-env.ts +18 -26
  96. package/src/host-cli.ts +4 -2
  97. package/src/host-ready.ts +13 -4
  98. package/src/host-worker.ts +6 -2
  99. package/src/host.ts +11 -2
  100. package/src/index.ts +2 -2
  101. package/src/init.ts +222 -101
  102. package/src/lifecycle-record.ts +2 -2
  103. package/src/local-branches.ts +1 -1
  104. package/src/origin.ts +12 -10
  105. package/src/pack-facts.ts +22 -6
  106. package/src/pglite-backing.ts +2 -1
  107. package/src/placeholder.ts +13 -4
  108. package/src/project-inspect.ts +2 -2
  109. package/src/redirect-proxy.ts +9 -1
  110. package/src/redis-backing.ts +4 -2
  111. package/src/reflect.ts +1 -1
  112. package/src/root.ts +33 -65
  113. package/src/runtime-test-support.ts +1 -1
  114. package/src/runtime.ts +261 -55
  115. package/src/schema.ts +26 -13
  116. package/src/served-world.ts +50 -130
  117. package/src/tail.ts +1 -1
  118. package/src/up-cli.ts +55 -0
  119. package/src/websocket-relay.ts +1 -1
  120. package/src/world-origins.ts +1 -1
  121. package/src/world-view.ts +3 -3
@@ -4,6 +4,7 @@ import { parallelStartup } from "./startup.js";
4
4
  import { waitForHostReady } from "./host-ready.js";
5
5
  import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
6
6
  import { packCli, packEntry } from "./catalog.js";
7
+ import { vendorOfPackModule } from '@volter/world-core/runtime';
7
8
  import { spawn, spawnSync } from 'node:child_process';
8
9
  import { get as httpGet } from 'node:http';
9
10
  import { siblingScript } from "./sibling.js";
@@ -21,14 +22,15 @@ import { packFacts } from "./pack-facts.js";
21
22
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
22
23
  import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
23
24
  import { Socket } from 'node:net';
24
- import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
25
+ import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
25
26
  import { loadWorldConfig } from "./configs.js";
26
27
  import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
27
- import { resolveServicePackage } from "./catalog.js";
28
+ import { overlayPackFactsFile, resolveServicePackage } from "./catalog.js";
28
29
  import { fakeEnvValue } from "./fixture-env.js";
29
- import { MINT_ENV } from "./schema.js";
30
+ import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from "./schema.js";
31
+ import { readAppUrl } from "./app-url.js";
30
32
  import { inertInjectEnvWarnings } from "./inject-map.js";
31
- import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
33
+ import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
32
34
  import { CaTrustError, sessionTrustEnv } from "./ca-trust.js";
33
35
  import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from "./consumers.js";
34
36
  import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from "./lifecycle-record.js";
@@ -150,6 +152,7 @@ async function confirmOwnPort(port, boot, ended, twin, what, log, early = false,
150
152
  function worldBootOf(port, abort) {
151
153
  return new Promise((resolveBoot) => {
152
154
  let request;
155
+ // biome-ignore lint/style/useConst: `finish` reads this, and an already-aborted signal calls `finish` before the timer is armed, where a const would still be in its temporal dead zone
153
156
  let deadline;
154
157
  let settled = false;
155
158
  let responseStarted = false;
@@ -463,6 +466,8 @@ function shellQuote(value) {
463
466
  }
464
467
  function envFileContents(env) {
465
468
  return `${Object.entries(env)
469
+ // the application's own origin is resolved where a command attaches, never written as the literal marker
470
+ .filter(([, value]) => value !== APP_URL_ENV)
466
471
  .sort(([a], [b]) => a.localeCompare(b))
467
472
  .map(([key, value]) => `export ${key}=${shellQuote(value)}`)
468
473
  .join('\n')}\n`;
@@ -525,6 +530,30 @@ export function withoutVaultSignIn(env) {
525
530
  return out;
526
531
  }
527
532
  /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
533
+ /**
534
+ * The network policy a World publishes: its own, under the ceiling of the World it was started inside (docs/contributing/
535
+ * architecture.md, "Worlds within a World"). A World started by a process of a World whose egress is closed or listed
536
+ * (the parent's published policy) reaches at most what the parent reaches: its egress is its own list intersected with
537
+ * the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
538
+ * so a vendor the inner World does not twin reaches the parent's twin of it.
539
+ */
540
+ function networkOf(name, own, parentEnv = process.env) {
541
+ const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
542
+ let ceiling;
543
+ if (serialized !== undefined) {
544
+ // an unreadable parent policy closes the child entirely, as it closes the parent
545
+ try {
546
+ ceiling = JSON.parse(serialized).egress ?? [];
547
+ }
548
+ catch {
549
+ ceiling = [];
550
+ }
551
+ }
552
+ if (own === undefined && ceiling === undefined)
553
+ return undefined;
554
+ const egress = own === undefined ? ceiling : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling.includes(origin));
555
+ return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
556
+ }
528
557
  export function admittedEnv(strip, env = process.env) {
529
558
  const out = {};
530
559
  const rules = (strip ?? []).map((s) => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
@@ -542,7 +571,24 @@ export function admittedEnv(strip, env = process.env) {
542
571
  return withoutVaultSignIn(out);
543
572
  }
544
573
  // The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
574
+ /** The names a twin of this World was to issue and did not (its door failed): kept out of every command's env, so a
575
+ * caller's own value (a real key in their shell) never stands in for the credential the World meant to issue. */
576
+ export const UNISSUED_ENV = 'VOLTER_WORLD_UNISSUED';
577
+ function unissuedOf(instanceEnv) {
578
+ try {
579
+ const names = JSON.parse(instanceEnv[UNISSUED_ENV] ?? '[]');
580
+ return Array.isArray(names) ? names.filter((n) => typeof n === 'string') : [];
581
+ }
582
+ catch {
583
+ return [];
584
+ }
585
+ }
545
586
  function stripEnvOf(instanceEnv) {
587
+ const unissued = unissuedOf(instanceEnv);
588
+ const rules = savedStripEnvOf(instanceEnv);
589
+ return unissued.length === 0 ? rules : [...(rules ?? []), ...unissued];
590
+ }
591
+ function savedStripEnvOf(instanceEnv) {
546
592
  // Freeze the admission policy for the instance; editing/removing its source cannot widen it.
547
593
  const saved = instanceEnv.VOLTER_WORLD_STRIP_ENV;
548
594
  if (saved !== undefined) {
@@ -578,7 +624,19 @@ function serviceProcessEnv(config, service, worldEnv, port) {
578
624
  for (const key of CONTROL_PLANE_EGRESS_ENV)
579
625
  delete env[key];
580
626
  }
581
- return { ...env, ...(service.env ?? {}) };
627
+ dropUnissued(env);
628
+ const out = { ...env, ...(service.env ?? {}) };
629
+ // the application's own origin: the World's own application service is reached where it listens (the URL
630
+ // `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
631
+ for (const [key, value] of Object.entries(out)) {
632
+ if (value !== APP_URL_ENV)
633
+ continue;
634
+ if (service.id === 'app')
635
+ out[key] = `http://127.0.0.1:${port}`;
636
+ else
637
+ delete out[key];
638
+ }
639
+ return out;
582
640
  }
583
641
  async function waitForTcp(port, timeoutMs = 15_000, abort) {
584
642
  const started = Date.now();
@@ -659,8 +717,11 @@ function writeOwnedPids(root, name, pids, fresh = []) {
659
717
  function identityPath(root, name) {
660
718
  return join(instanceDir(root, name), 'pids.identity');
661
719
  }
662
- /** Each running pid's start time (one call); a pid with no process is absent. Printed in one fixed zone and locale, so
663
- * the same process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). */
720
+ /** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
721
+ * process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
722
+ * given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
723
+ * processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
724
+ * paid. */
664
725
  function processStarts(pids) {
665
726
  const starts = new Map();
666
727
  if (!pids.length || process.platform === 'win32')
@@ -676,11 +737,12 @@ function processStarts(pids) {
676
737
  }
677
738
  return starts;
678
739
  }
679
- const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', pids.join(',')], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } });
680
- for (const line of (result.stdout ?? '').split('\n')) {
681
- const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
682
- if (match)
683
- starts.set(Number(match[1]), match[2]);
740
+ const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
741
+ for (const pid of pids) {
742
+ const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
743
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
744
+ if (match && Number(match[1]) === pid)
745
+ starts.set(pid, match[2]);
684
746
  }
685
747
  return starts;
686
748
  }
@@ -1108,7 +1170,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1108
1170
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1109
1171
  const serviceDataDir = join(paths.data, service.id);
1110
1172
  mkdirSync(serviceDataDir, { recursive: true });
1111
- // R17 — the lockfile holds on the spawn path too: a twin that runs `packages/twin/<vendor>/src/cli.ts`
1173
+ // R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
1112
1174
  // is checked against its pin and its protocol standing before it is spawned.
1113
1175
  const spawnedModule = (service.type ?? 'twin') === 'twin' ? spawnedTwinModule(service, paths.root) : undefined;
1114
1176
  const mounted = spawnedModule !== undefined ? lockfileCheck(service, spawnedModule) : {};
@@ -1118,6 +1180,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1118
1180
  if (binding !== undefined)
1119
1181
  args.push(binding.arg, binding.url);
1120
1182
  }
1183
+ const sides = (service.type ?? 'twin') === 'twin' ? await sidePortsFor(vendorOfModule(spawnedModule, service.id), args, allocatedPorts) : [];
1184
+ for (const side of sides)
1185
+ if (!side.pinned)
1186
+ args.push(side.flag, String(side.port));
1121
1187
  if (service.portArg !== false)
1122
1188
  args.push(service.portArg ?? '--port', String(port));
1123
1189
  if (service.rootArg !== false)
@@ -1199,6 +1265,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
1199
1265
  command: [service.command, ...args],
1200
1266
  port,
1201
1267
  url,
1268
+ ...sidesRecord(sides),
1202
1269
  ...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
1203
1270
  ...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
1204
1271
  pid: child.pid ?? 0,
@@ -1235,43 +1302,53 @@ function mountedPackage(modulePath, serviceId) {
1235
1302
  break;
1236
1303
  dir = up;
1237
1304
  }
1238
- const m = /\/packages\/twin\/([^/]+)\//.exec(modulePath) ?? /@volter\/twin-([^/]+)\//.exec(modulePath);
1239
- const vendor = m?.[1] ?? serviceId;
1305
+ const vendor = vendorOfPackModule(modulePath) ?? serviceId;
1240
1306
  try {
1241
1307
  const facts = packFacts()[vendor];
1242
1308
  if (facts?.protocol)
1243
- out.protocol = facts.protocol;
1309
+ out.protocol = { ...protocolStanding(facts.protocol.declared), declared: facts.protocol.declared };
1244
1310
  }
1245
1311
  catch { /* no artifact: the standing is unknown, not refused */ }
1246
1312
  return out;
1247
1313
  }
1248
1314
  /** R17 + R16 at boot, on BOTH paths (the colocated host and the spawned process): the mounted
1249
- * package must satisfy the pin, its protocol standing must not be refused, and a deprecated one
1250
- * is said aloud. Returns what to stamp on the instance record. */
1315
+ * package must satisfy its pin and its recorded declaration must match the kernel. Registration and the host
1316
+ * check the loaded descriptor again before a factory runs; compiled facts are only preflight evidence. */
1251
1317
  function lockfileCheck(service, modulePath) {
1252
1318
  const mounted = mountedPackage(modulePath, service.id);
1253
1319
  if (service.version !== undefined) {
1254
1320
  if (mounted.version === undefined)
1255
1321
  throw new Error(`Twin "${service.id}" pins version ${service.version} but no package.json was found above ${modulePath} — nothing to hold the pin against`);
1256
1322
  if (!versionSatisfies(mounted.version, service.version))
1257
- throw new Error(`Twin "${service.id}" pins version ${service.version} and the mounted package is ${mounted.version} (${mounted.packagePath}) — re-pin the world config, or mount the version it names (volter-world outdated lists both)`);
1323
+ throw new Error(`Twin "${service.id}" pins version ${service.version} and the mounted package is ${mounted.version} (${mounted.packagePath}) — install the version the World names (npm install -D @volter/twin-${service.id}@${service.version}, as a committed lockfile does), or move the pin to it: "version" on the service in .volter/world.json (volter-world outdated lists both)`);
1258
1324
  }
1259
1325
  if (mounted.protocol?.standing === 'refused')
1260
- throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION} and the previous major only (runtime contract R16)`);
1261
- if (mounted.protocol?.standing === 'deprecated')
1262
- process.stderr.write(`volter-world: twin "${service.id}" targets protocol major ${mounted.protocol.major}, one behind this platform's ${PROTOCOL_VERSION} — served for this cycle under deprecation (R16)\n`);
1326
+ throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION} only (runtime contract R16)`);
1263
1327
  return mounted;
1264
1328
  }
1265
- /** The module a spawned twin runs from: its first arg under packages/twin/<vendor>/ or an installed
1329
+ /** The module a spawned twin runs from: its first arg in a vendor package, from a checkout or an installed
1266
1330
  * @volter/twin-<vendor>/ package, resolved. */
1267
1331
  function spawnedTwinModule(service, root) {
1268
- const first = (service.args ?? []).find((a) => /packages\/twin\/[^/]+\//.test(a) || /@volter\/twin-[^/]+\//.test(a));
1332
+ const first = (service.args ?? []).find((a) => !a.startsWith('-') && /\.(?:[cm]?js|tsx?)$/.test(a) && vendorOfPackModule(resolve(root, a)) !== undefined);
1269
1333
  return first === undefined ? undefined : (first.startsWith('/') ? first : resolve(root, first));
1270
1334
  }
1271
- /** The vendor a twin module belongs to (`packages/twin/<vendor>/…` or `@volter/twin-<vendor>/…`), else the service id. */
1335
+ /** A twin's side ports (TwinPack.sidePorts), each allocated before boot unless the service's own args already name it
1336
+ * (a World that pins the port keeps its pin), with the URL the World records for it. */
1337
+ async function sidePortsFor(vendor, args, allocatedPorts) {
1338
+ const declared = packFacts()[vendor]?.sidePorts ?? [];
1339
+ const out = [];
1340
+ for (const side of declared) {
1341
+ const at = args.indexOf(side.flag);
1342
+ const given = at >= 0 ? Number(args[at + 1]) : Number.NaN;
1343
+ const pinned = Number.isInteger(given) && given > 0;
1344
+ const port = pinned ? given : await allocatePort(allocatedPorts);
1345
+ out.push({ name: side.name, option: side.option, flag: side.flag, port, pinned, url: `http://127.0.0.1:${port}${side.path ?? ''}` });
1346
+ }
1347
+ return out;
1348
+ }
1349
+ const sidesRecord = (sides) => sides.length ? { sides: Object.fromEntries(sides.map((side) => [side.name, { port: side.port, url: side.url }])) } : {};
1272
1350
  function vendorOfModule(modulePath, serviceId) {
1273
- const m = modulePath === undefined ? null : (/\/packages\/twin\/([^/]+)\//.exec(modulePath) ?? /@volter\/twin-([^/]+)(\/|$)/.exec(modulePath));
1274
- return m?.[1] ?? serviceId;
1351
+ return (modulePath === undefined ? undefined : vendorOfPackModule(modulePath)) ?? serviceId;
1275
1352
  }
1276
1353
  /**
1277
1354
  * THE MANAGED-DATABASE BINDING (architecture.md, managed infrastructure: the data plane's declared exception): for a
@@ -1295,12 +1372,107 @@ export function managedDatabaseFor(vendor, configPath, env) {
1295
1372
  const url = Object.keys(connections).sort().map((name) => connections[name])[0];
1296
1373
  return url === undefined ? undefined : { arg: declared.arg, url };
1297
1374
  }
1375
+ /** The facts files the World's installed packs carry (a released pack's `generated/pack-facts.json`), which the kernel's
1376
+ * pack-facts loader reads through the instance file. */
1377
+ function packFactsFiles(config, root) {
1378
+ const files = [];
1379
+ for (const service of config.services) {
1380
+ if (service.package === undefined)
1381
+ continue;
1382
+ let dir;
1383
+ try {
1384
+ dir = resolveServicePackage(root, service.package);
1385
+ }
1386
+ catch {
1387
+ continue;
1388
+ }
1389
+ const file = join(dir, 'generated', 'pack-facts.json');
1390
+ if (existsSync(file))
1391
+ files.push(file);
1392
+ }
1393
+ return [...new Set(files)].sort();
1394
+ }
1298
1395
  /**
1299
1396
  * The config as the runtime boots it (docs/concepts/worlds.md#the-config-and-the-running-world): a service
1300
1397
  * naming a `package` gets its entry and colocate module resolved from the world root NOW, by
1301
1398
  * node's own walk, and a `$mint` env value becomes a freshly minted throwaway credential for this
1302
1399
  * boot. Everything downstream sees ordinary command/args/module/env.
1303
1400
  */
1401
+ /** An env name whose value is still `$issue:<service>` is not set yet: no twin, host or application is handed the
1402
+ * marker. */
1403
+ function dropUnissued(env) {
1404
+ for (const [key, value] of Object.entries(env))
1405
+ if (value.startsWith(ISSUE_ENV_PREFIX))
1406
+ delete env[key];
1407
+ }
1408
+ /** The names the World's twins issue (`$issue:<service>` in its config), taken out of the boot env until issued. */
1409
+ function takeIssued(env) {
1410
+ const pending = new Map();
1411
+ for (const [key, value] of Object.entries(env))
1412
+ if (value.startsWith(ISSUE_ENV_PREFIX))
1413
+ pending.set(key, value.slice(ISSUE_ENV_PREFIX.length));
1414
+ dropUnissued(env);
1415
+ return pending;
1416
+ }
1417
+ /** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
1418
+ * own store, in milliseconds; this bounds a hung twin, not a slow one). */
1419
+ const ISSUE_TIMEOUT_MS = 15_000;
1420
+ /** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
1421
+ * asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
1422
+ * door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
1423
+ async function issueCredentials(service, started, env, pending, unissued) {
1424
+ const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
1425
+ if (wanted.length === 0)
1426
+ return;
1427
+ for (const name of wanted)
1428
+ pending.delete(name);
1429
+ // the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
1430
+ let door = service.issues;
1431
+ try {
1432
+ door = packFacts()[service.id]?.credentialDoor ?? door;
1433
+ }
1434
+ catch { /* no artifact: the saved copy */ }
1435
+ try {
1436
+ if (!door)
1437
+ throw new Error('its service names no credential door');
1438
+ if (!started.url)
1439
+ throw new Error('it has no URL to ask');
1440
+ const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
1441
+ if (!res.ok)
1442
+ throw new Error(`answered ${res.status}`);
1443
+ const answer = (await res.json());
1444
+ for (const name of wanted) {
1445
+ const spec = door.fill[name] ?? '';
1446
+ // `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
1447
+ // beside the World's instance file, the name set to its path
1448
+ const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
1449
+ const value = answer[field];
1450
+ if (spec.startsWith('file:')) {
1451
+ if (value === undefined || value === null)
1452
+ throw new Error(`answered no ${field}`);
1453
+ // beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
1454
+ if (!env.VOLTER_WORLD_INSTANCE)
1455
+ throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
1456
+ const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
1457
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1458
+ const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
1459
+ writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1460
+ env[name] = path;
1461
+ continue;
1462
+ }
1463
+ if (typeof value !== 'string')
1464
+ throw new Error(`answered no ${field}`);
1465
+ env[name] = value;
1466
+ }
1467
+ }
1468
+ catch (error) {
1469
+ process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
1470
+ for (const name of wanted) {
1471
+ delete env[name];
1472
+ unissued.add(name);
1473
+ }
1474
+ }
1475
+ }
1304
1476
  function materializeConfig(config, root) {
1305
1477
  const services = config.services.map((service) => {
1306
1478
  if (service.package === undefined)
@@ -1367,8 +1539,9 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1367
1539
  const mounted = lockfileCheck(service, module);
1368
1540
  const scenario = service.colocate.scenarioPath;
1369
1541
  const binding = managedDatabaseFor(vendorOfModule(module, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
1370
- args.push('--spec', [service.id, module, service.colocate.export, String(port), serviceDataDir, ...(scenario ? [`scenario=${scenario.startsWith('/') ? scenario : resolve(paths.root, scenario)}`] : []), ...(binding !== undefined ? [`database=${binding.url}`] : [])].join('|'));
1371
- allocated.push({ service, port, mounted });
1542
+ const sides = await sidePortsFor(vendorOfModule(module, service.id), [], allocatedPorts);
1543
+ args.push('--spec', [service.id, module, service.colocate.export, String(port), serviceDataDir, ...(scenario ? [`scenario=${scenario.startsWith('/') ? scenario : resolve(paths.root, scenario)}`] : []), ...(binding !== undefined ? [`database=${binding.url}`] : []), ...sides.map((side) => `side.${side.option}=${side.port}`)].join('|'));
1544
+ allocated.push({ service, port, mounted, sides });
1372
1545
  }
1373
1546
  const log = join(paths.logs, 'host.log');
1374
1547
  // The host is world infrastructure, not an app process — same egress hygiene as controlPlane
@@ -1376,6 +1549,11 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1376
1549
  const env = { ...admittedEnv(config.stripEnv), ...(config.env ?? {}), ...worldEnv };
1377
1550
  for (const key of CONTROL_PLANE_EGRESS_ENV)
1378
1551
  delete env[key];
1552
+ // the twins never read the application's own origin, nor a credential not yet issued
1553
+ for (const [key, value] of Object.entries(env))
1554
+ if (value === APP_URL_ENV)
1555
+ delete env[key];
1556
+ dropUnissued(env);
1379
1557
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1380
1558
  // Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
1381
1559
  const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
@@ -1390,7 +1568,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1390
1568
  const host = '127.0.0.1';
1391
1569
  // Every twin's address is known once its port is allocated: the records and
1392
1570
  // the environment they inject exist before the host reports its starts.
1393
- const services = allocated.map(({ service, port, mounted }) => {
1571
+ const services = allocated.map(({ service, port, mounted, sides }) => {
1394
1572
  const url = `http://${host}:${port}`;
1395
1573
  return {
1396
1574
  id: service.id,
@@ -1398,6 +1576,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1398
1576
  command,
1399
1577
  port,
1400
1578
  url,
1579
+ ...sidesRecord(sides),
1401
1580
  ...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
1402
1581
  ...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
1403
1582
  pid: child.pid ?? 0,
@@ -1411,7 +1590,12 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1411
1590
  });
1412
1591
  const ready = (async () => {
1413
1592
  const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1414
- await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1593
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try {
1594
+ return statSync(log).size;
1595
+ }
1596
+ catch {
1597
+ return 0;
1598
+ } }), ended, boot, 'the co-located host', log).catch((error) => {
1415
1599
  if (undeadPids([child.pid]).length)
1416
1600
  recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1417
1601
  try {
@@ -1435,7 +1619,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1435
1619
  // The host's receipt (this boot, every requested port) is published only
1436
1620
  // after every factory bound its own port; a port another process held fails
1437
1621
  // its bind and leaves no receipt. The receipt is the host's ownership proof
1438
- // for each port, so no twin waits out a settle (ADR-0008). A port that
1622
+ // for each port, so no twin waits out a settle (ADR 0014). A port that
1439
1623
  // answers with another boot's identity is still refused.
1440
1624
  const hostSettle = Promise.resolve('alive');
1441
1625
  await parallelStartup(allocated, async ({ service, port }) => {
@@ -1463,6 +1647,12 @@ export async function upWorld(configId, options = {}, abort) {
1463
1647
  abort?.throwIfAborted();
1464
1648
  const root = resolve(options.root ?? process.cwd());
1465
1649
  const found = loadWorldConfig(configId, root);
1650
+ // the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
1651
+ const packFactsPaths = packFactsFiles(found.config, root);
1652
+ for (const file of packFactsPaths)
1653
+ overlayPackFactsFile(file);
1654
+ // and this process's injector host rules, which `up`'s inert-injectEnv warning reads, know them too
1655
+ createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
1466
1656
  const loaded = { path: found.path, config: materializeConfig(found.config, root) };
1467
1657
  const name = options.name ?? loaded.config.id;
1468
1658
  const mode = options.mode ?? 'local';
@@ -1510,10 +1700,22 @@ export async function upWorld(configId, options = {}, abort) {
1510
1700
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1511
1701
  const instanceFile = join(instance, 'instance.json');
1512
1702
  let carriedOrigin;
1513
- const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) : undefined;
1514
- const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1515
- ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
1516
- const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutput, options.forceEnvOutput);
1703
+ // the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
1704
+ // writes it), so a file any of them last wrote is the World's own, never a custody file
1705
+ const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
1706
+ const priorOutputs = [name, ...siblings].flatMap((world) => {
1707
+ const file = world === name ? instanceFile : join(instanceDir(root, world), 'instance.json');
1708
+ if (!existsSync(file))
1709
+ return [];
1710
+ try {
1711
+ const prior = JSON.parse(readFileSync(file, 'utf8'));
1712
+ return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
1713
+ }
1714
+ catch {
1715
+ return [];
1716
+ }
1717
+ });
1718
+ const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
1517
1719
  if (loaded.config.resources)
1518
1720
  process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
1519
1721
  checkedOwner(options.owner);
@@ -1649,7 +1851,7 @@ export async function upWorld(configId, options = {}, abort) {
1649
1851
  // the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
1650
1852
  // on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
1651
1853
  VOLTER_WORLD: name,
1652
- ...(loaded.config.network ? { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, mode === 'sealed' ? { egress: [] } : loaded.config.network)) } : {}),
1854
+ ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
1653
1855
  VOLTER_WORLD_MODE: mode,
1654
1856
  VOLTER_WORLD_CONFIG: loaded.path,
1655
1857
  VOLTER_WORLD_INSTANCE: instanceFile,
@@ -1670,6 +1872,8 @@ export async function upWorld(configId, options = {}, abort) {
1670
1872
  };
1671
1873
  env.NO_PROXY = `127.0.0.1,localhost${env.NO_PROXY || inheritedEnv.NO_PROXY ? `,${env.NO_PROXY ?? inheritedEnv.NO_PROXY}` : ''}`;
1672
1874
  env.no_proxy = `127.0.0.1,localhost${env.no_proxy || inheritedEnv.no_proxy ? `,${env.no_proxy ?? inheritedEnv.no_proxy}` : ''}`;
1875
+ const issuing = takeIssued(env);
1876
+ const unissued = new Set();
1673
1877
  writeProxyEnv(root, name, env);
1674
1878
  const worldInstance = {
1675
1879
  name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
@@ -1678,6 +1882,7 @@ export async function upWorld(configId, options = {}, abort) {
1678
1882
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1679
1883
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
1680
1884
  lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
1885
+ ...(packFactsPaths.length ? { packFacts: packFactsPaths } : {}),
1681
1886
  ...(carriedOrigin ? { origin: carriedOrigin } : {}),
1682
1887
  };
1683
1888
  saveWorldInstance(worldInstance);
@@ -1711,7 +1916,8 @@ export async function upWorld(configId, options = {}, abort) {
1711
1916
  // The co-located host's twins are known by address once their ports are
1712
1917
  // allocated. A twin serves and calls no other service while it starts,
1713
1918
  // so a later twin declaration starts beside the host's startup; any other
1714
- // service waits until every earlier one is ready, as declared (ADR-0009).
1919
+ // service waits until every earlier one is ready, as declared (ADR 0015).
1920
+ // A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
1715
1921
  let colocatedReady;
1716
1922
  if (isolation !== 'process') {
1717
1923
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
@@ -1720,7 +1926,15 @@ export async function upWorld(configId, options = {}, abort) {
1720
1926
  services[started.id] = started;
1721
1927
  Object.assign(env, started.env);
1722
1928
  }
1723
- colocatedReady = host.ready;
1929
+ colocatedReady = async () => {
1930
+ await host.ready;
1931
+ for (const started of host.services) {
1932
+ const declared = colocated.find((s) => s.id === started.id);
1933
+ if (declared)
1934
+ await issueCredentials(declared, started, env, issuing, unissued);
1935
+ }
1936
+ writeProxyEnv(root, name, env);
1937
+ };
1724
1938
  recordPidsSoFar();
1725
1939
  writeProxyEnv(root, name, env);
1726
1940
  }
@@ -1733,6 +1947,9 @@ export async function upWorld(configId, options = {}, abort) {
1733
1947
  const attemptProxy = () => {
1734
1948
  if (proxyForServices || (Object.keys(activeVendorMap(env)).length === 0 && env[WORLD_NETWORK_POLICY_ENV] === undefined))
1735
1949
  return;
1950
+ // a host with no raw sockets (a browser tab) has nothing for the proxy to intercept; the injector holds the policy
1951
+ if (!hostHasRawEgress())
1952
+ return;
1736
1953
  if (!opensslAvailable()) {
1737
1954
  if (mode === 'sealed' || loaded.config.network !== undefined) {
1738
1955
  throw new Error(`world "${name}" requires its network policy proxy, but openssl is unavailable; refusing to start. Install openssl.`);
@@ -1754,7 +1971,7 @@ export async function upWorld(configId, options = {}, abort) {
1754
1971
  if (services[service.id])
1755
1972
  continue; // already up co-located
1756
1973
  if (colocatedReady && (service.type ?? 'twin') !== 'twin') {
1757
- await colocatedReady;
1974
+ await colocatedReady();
1758
1975
  colocatedReady = undefined;
1759
1976
  }
1760
1977
  attemptProxy();
@@ -1763,11 +1980,21 @@ export async function upWorld(configId, options = {}, abort) {
1763
1980
  services[started.id] = started;
1764
1981
  recordPidsSoFar();
1765
1982
  Object.assign(env, started.env);
1983
+ await issueCredentials(service, started, env, issuing, unissued);
1766
1984
  writeProxyEnv(root, name, env);
1767
1985
  }
1768
1986
  if (colocatedReady)
1769
- await colocatedReady;
1987
+ await colocatedReady();
1770
1988
  detachBootSignals();
1989
+ // a name a service of no twin in this World issues (a config edited after init) is left unset, and said
1990
+ if (issuing.size > 0)
1991
+ process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
1992
+ for (const [name] of issuing)
1993
+ unissued.add(name);
1994
+ if (unissued.size > 0) {
1995
+ env[UNISSUED_ENV] = JSON.stringify([...unissued].sort());
1996
+ writeProxyEnv(root, name, env);
1997
+ }
1771
1998
  // Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
1772
1999
  // only lands in `env` once IT has started, so a check that only runs "before starting the
1773
2000
  // next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
@@ -2989,7 +3216,17 @@ export function urlWorld(name, options = {}) {
2989
3216
  if (options.service === undefined) {
2990
3217
  return Object.values(status.services)
2991
3218
  .filter((service) => service.url)
2992
- .map((service) => ({ id: service.id, url: service.url }));
3219
+ .flatMap((service) => [{ id: service.id, url: service.url }, ...Object.entries(service.sides ?? {}).map(([side, at]) => ({ id: `${service.id}.${side}`, url: at.url }))]);
3220
+ }
3221
+ // a side port by `<service>.<name>` (the SMTP twin's mailbox: smtp.inspect)
3222
+ const dot = options.service.lastIndexOf('.');
3223
+ if (!status.services[options.service] && dot > 0) {
3224
+ const owner = status.services[options.service.slice(0, dot)];
3225
+ const side = owner?.sides?.[options.service.slice(dot + 1)];
3226
+ if (side)
3227
+ return [{ id: options.service, url: side.url }];
3228
+ if (owner)
3229
+ throw new Error(`Service "${owner.id}" has no side port "${options.service.slice(dot + 1)}"; it has: ${Object.keys(owner.sides ?? {}).join(', ') || 'none'}`);
2993
3230
  }
2994
3231
  const service = status.services[options.service];
2995
3232
  if (!service) {
@@ -3120,19 +3357,38 @@ function worldAttachedCommandEnv(name, root, verbose = false) {
3120
3357
  const proxy = ensureWorldProxy(name, root, [admittedEnv(stripEnvOf(status.env)), status.env]);
3121
3358
  if (proxy?.url) {
3122
3359
  proxyEnv = proxy.env;
3360
+ // a host with no raw sockets (a browser tab) runs no proxy: the injector holds the policy there, as at `up`
3123
3361
  }
3124
- else if (sealed)
3362
+ else if (sealed && hostHasRawEgress())
3125
3363
  throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
3126
3364
  }
3127
3365
  catch (error) {
3128
3366
  if (error instanceof CaTrustError)
3129
3367
  throw error;
3130
- if (sealed)
3368
+ if (sealed && hostHasRawEgress())
3131
3369
  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)})`);
3132
3370
  // Keep env-only redirect if the ambient proxy cannot start.
3133
3371
  }
3134
- return { instance: status, env: { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
3135
- VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' } };
3372
+ return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
3373
+ VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' }) };
3374
+ }
3375
+ /** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
3376
+ * unset (the application's own env files decide) and the command is told how to record it: never a guess. */
3377
+ export function withAppUrl(name, root, env) {
3378
+ const names = Object.keys(env).filter((key) => env[key] === APP_URL_ENV);
3379
+ if (names.length === 0)
3380
+ return env;
3381
+ const out = { ...env };
3382
+ const recorded = readAppUrl(name, { root });
3383
+ for (const key of names) {
3384
+ if (recorded)
3385
+ out[key] = recorded.url.replace(/\/+$/, '');
3386
+ else
3387
+ delete out[key];
3388
+ }
3389
+ if (!recorded)
3390
+ process.stderr.write(`world "${name}": ${names.join(', ')} name the application's own address, which is not recorded; left unset. Record it with: volter-world app-url ${name} --set <url>\n`);
3391
+ return out;
3136
3392
  }
3137
3393
  /** Runs the command in the world's env in the foreground and resolves to its exit code. The child is
3138
3394
  * awaited, not blocked on: a runtime that runs no synchronous child (the browser engine) still runs
@@ -3144,7 +3400,7 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
3144
3400
  return new Promise((resolveExit) => {
3145
3401
  const child = spawn(command[0], command.slice(1), { windowsHide: true,
3146
3402
  cwd: resolve(options.cwd ?? root),
3147
- env: attached.env,
3403
+ env: { ...attached.env, ...(options.env ?? {}) },
3148
3404
  stdio: 'inherit',
3149
3405
  });
3150
3406
  const forward = (signal) => { if (child.exitCode === null && child.signalCode === null)
@@ -3400,7 +3656,7 @@ export function worldShellEnv(name, root = process.cwd()) {
3400
3656
  return {
3401
3657
  shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
3402
3658
  cwd: resolve(root),
3403
- env: { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name },
3659
+ env: withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name }),
3404
3660
  };
3405
3661
  }
3406
3662
  /** Drop into an interactive subshell with the world active — the contained sibling of `activate`
@@ -3414,7 +3670,10 @@ export async function shellWorld(name, root = process.cwd()) {
3414
3670
  const resolvedRoot = resolve(root);
3415
3671
  let proxy = null;
3416
3672
  const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
3417
- if (opensslAvailable()) {
3673
+ if (!hostHasRawEgress()) {
3674
+ process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (this host opens no raw sockets; the injector holds the network policy). type 'exit' to leave.\n`);
3675
+ }
3676
+ else if (opensslAvailable()) {
3418
3677
  try {
3419
3678
  proxy = await startRedirectProxy({ env, trustEnv: [admittedEnv(stripEnvOf(env)), env], tlsDir: tlsDir(resolvedRoot, name) });
3420
3679
  Object.assign(env, proxy.proxyEnv());
@@ -3494,7 +3753,7 @@ function currentProxyState(root, name) {
3494
3753
  }
3495
3754
  function ensureWorldProxyUnlocked(name, root, caller) {
3496
3755
  const resolvedRoot = resolve(root);
3497
- if (!opensslAvailable())
3756
+ if (!hostHasRawEgress() || !opensslAvailable())
3498
3757
  return null;
3499
3758
  const existing = currentProxyState(resolvedRoot, name);
3500
3759
  if (existing && isAlive(existing.pid)) {
@@ -3506,7 +3765,7 @@ function ensureWorldProxyUnlocked(name, root, caller) {
3506
3765
  }
3507
3766
  function ensureWorldProxyFromEnv(name, root, env, boot) {
3508
3767
  const resolvedRoot = resolve(root);
3509
- if (!opensslAvailable())
3768
+ if (!hostHasRawEgress() || !opensslAvailable())
3510
3769
  return null;
3511
3770
  writeProxyEnv(resolvedRoot, name, env);
3512
3771
  const existing = currentProxyState(resolvedRoot, name);
@@ -3606,6 +3865,10 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
3606
3865
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
3607
3866
  }
3608
3867
  retire();
3868
+ // the proxy is confirmed gone: ownership goes back to the World's running processes, as on success (it was retained
3869
+ // for cleanup only while the child's fate was unknown), so the World stays running unsealed, as the warning says
3870
+ if (!boot)
3871
+ handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
3609
3872
  process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
3610
3873
  return { url: '', caCertPath, env: {} };
3611
3874
  }
@@ -3739,16 +4002,17 @@ export function activateScript(name, root = process.cwd()) {
3739
4002
  env = { ...baseEnv, ...proxy.env };
3740
4003
  proxyNote = ` (ambient proxy ${proxy.url})`;
3741
4004
  }
3742
- else if (sealed)
4005
+ else if (sealed && hostHasRawEgress())
3743
4006
  throw new Error('attachment proxy is unavailable');
3744
4007
  }
3745
4008
  catch (error) {
3746
4009
  if (error instanceof CaTrustError)
3747
4010
  throw error;
3748
- if (sealed)
4011
+ if (sealed && hostHasRawEgress())
3749
4012
  throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
3750
4013
  // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
3751
4014
  }
4015
+ env = withAppUrl(name, root, env);
3752
4016
  const keys = Object.keys(env).sort();
3753
4017
  const lines = [
3754
4018
  `# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,