@volter/world-runtime 2.0.55 → 3.0.0

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
package/src/runtime.ts CHANGED
@@ -4,6 +4,7 @@ import { parallelStartup } from './startup.ts';
4
4
  import { waitForHostReady } from './host-ready.ts';
5
5
  import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
6
6
  import { packCli, packEntry } from './catalog.ts';
7
+ import { vendorOfPackModule } from '@volter/world-core/runtime';
7
8
  import { spawn, spawnSync } from 'node:child_process';
8
9
  import { get as httpGet, type ClientRequest } from 'node:http';
9
10
  import { siblingScript } from './sibling.ts';
@@ -21,15 +22,15 @@ import { packFacts } from './pack-facts.ts';
21
22
  import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
22
23
  import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
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 type { PlanReviewRecord } 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';
26
26
  import { loadWorldConfig } from './configs.ts';
27
27
  import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
28
- import { resolveServicePackage } from './catalog.ts';
28
+ import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
29
29
  import { fakeEnvValue } from './fixture-env.ts';
30
- import { MINT_ENV } from './schema.ts';
30
+ import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
31
+ import { readAppUrl } from './app-url.ts';
31
32
  import { inertInjectEnvWarnings } from './inject-map.ts';
32
- import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
33
+ import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
33
34
  import { CaTrustError, sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
34
35
  import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldRunOutcome, WorldRunRecord, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
35
36
  import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from './consumers.ts';
@@ -138,6 +139,7 @@ async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<vo
138
139
  function worldBootOf(port: number, abort: AbortSignal): Promise<{ boot?: unknown } | null> {
139
140
  return new Promise((resolveBoot) => {
140
141
  let request: ClientRequest | undefined;
142
+ // 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
141
143
  let deadline: ReturnType<typeof setTimeout> | undefined;
142
144
  let settled = false;
143
145
  let responseStarted = false;
@@ -503,6 +505,8 @@ function shellQuote(value: string): string {
503
505
 
504
506
  function envFileContents(env: Record<string, string>): string {
505
507
  return `${Object.entries(env)
508
+ // the application's own origin is resolved where a command attaches, never written as the literal marker
509
+ .filter(([, value]) => value !== APP_URL_ENV)
506
510
  .sort(([a], [b]) => a.localeCompare(b))
507
511
  .map(([key, value]) => `export ${key}=${shellQuote(value)}`)
508
512
  .join('\n')}\n`;
@@ -571,6 +575,25 @@ export function withoutVaultSignIn<T extends Record<string, string | undefined>>
571
575
  }
572
576
 
573
577
  /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
578
+ /**
579
+ * The network policy a World publishes: its own, under the ceiling of the World it was started inside (docs/contributing/
580
+ * architecture.md, "Worlds within a World"). A World started by a process of a World whose egress is closed or listed
581
+ * (the parent's published policy) reaches at most what the parent reaches: its egress is its own list intersected with
582
+ * the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
583
+ * so a vendor the inner World does not twin reaches the parent's twin of it.
584
+ */
585
+ function networkOf(name: string, own: { egress: readonly string[] } | undefined, parentEnv: NodeJS.ProcessEnv = process.env): Record<string, string> | undefined {
586
+ const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
587
+ let ceiling: readonly string[] | undefined;
588
+ if (serialized !== undefined) {
589
+ // an unreadable parent policy closes the child entirely, as it closes the parent
590
+ try { ceiling = (JSON.parse(serialized) as { egress?: string[] }).egress ?? []; } catch { ceiling = []; }
591
+ }
592
+ if (own === undefined && ceiling === undefined) return undefined;
593
+ const egress = own === undefined ? ceiling! : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling!.includes(origin));
594
+ return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
595
+ }
596
+
574
597
  export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
575
598
  const out: Record<string, string> = {};
576
599
  const rules = (strip ?? []).map((s): { prefix: string } | { name: string } => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
@@ -585,7 +608,20 @@ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv
585
608
  return withoutVaultSignIn(out);
586
609
  }
587
610
  // The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
611
+ /** 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
612
+ * caller's own value (a real key in their shell) never stands in for the credential the World meant to issue. */
613
+ export const UNISSUED_ENV = 'VOLTER_WORLD_UNISSUED';
614
+ function unissuedOf(instanceEnv: Record<string, string>): string[] {
615
+ try { const names: unknown = JSON.parse(instanceEnv[UNISSUED_ENV] ?? '[]'); return Array.isArray(names) ? names.filter((n): n is string => typeof n === 'string') : []; } catch { return []; }
616
+ }
617
+
588
618
  function stripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
619
+ const unissued = unissuedOf(instanceEnv);
620
+ const rules = savedStripEnvOf(instanceEnv);
621
+ return unissued.length === 0 ? rules : [...(rules ?? []), ...unissued];
622
+ }
623
+
624
+ function savedStripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
589
625
  // Freeze the admission policy for the instance; editing/removing its source cannot widen it.
590
626
  const saved = instanceEnv.VOLTER_WORLD_STRIP_ENV;
591
627
  if (saved !== undefined) {
@@ -615,7 +651,16 @@ function serviceProcessEnv(
615
651
  if (service.controlPlane) {
616
652
  for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
617
653
  }
618
- return { ...env, ...(service.env ?? {}) };
654
+ dropUnissued(env);
655
+ const out: Record<string, string> = { ...env, ...(service.env ?? {}) };
656
+ // the application's own origin: the World's own application service is reached where it listens (the URL
657
+ // `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
658
+ for (const [key, value] of Object.entries(out)) {
659
+ if (value !== APP_URL_ENV) continue;
660
+ if (service.id === 'app') out[key] = `http://127.0.0.1:${port}`;
661
+ else delete out[key];
662
+ }
663
+ return out;
619
664
  }
620
665
 
621
666
  async function waitForTcp(port: number, timeoutMs = 15_000, abort?: AbortSignal): Promise<void> {
@@ -701,8 +746,11 @@ function writeOwnedPids(root: string, name: string, pids: number[], fresh: numbe
701
746
  function identityPath(root: string, name: string): string {
702
747
  return join(instanceDir(root, name), 'pids.identity');
703
748
  }
704
- /** Each running pid's start time (one call); a pid with no process is absent. Printed in one fixed zone and locale, so
705
- * the same process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). */
749
+ /** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
750
+ * process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
751
+ * given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
752
+ * processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
753
+ * paid. */
706
754
  function processStarts(pids: number[]): Map<number, string> {
707
755
  const starts = new Map<number, string>();
708
756
  if (!pids.length || process.platform === 'win32') return starts;
@@ -713,10 +761,11 @@ function processStarts(pids: number[]): Map<number, string> {
713
761
  }
714
762
  return starts;
715
763
  }
716
- 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' } });
717
- for (const line of (result.stdout ?? '').split('\n')) {
718
- const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
719
- if (match) starts.set(Number(match[1]), match[2]!);
764
+ const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
765
+ for (const pid of pids) {
766
+ const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
767
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
768
+ if (match && Number(match[1]) === pid) starts.set(pid, match[2]!);
720
769
  }
721
770
  return starts;
722
771
  }
@@ -1129,7 +1178,7 @@ async function startService(
1129
1178
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1130
1179
  const serviceDataDir = join(paths.data, service.id);
1131
1180
  mkdirSync(serviceDataDir, { recursive: true });
1132
- // R17 — the lockfile holds on the spawn path too: a twin that runs `packages/twin/<vendor>/src/cli.ts`
1181
+ // R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
1133
1182
  // is checked against its pin and its protocol standing before it is spawned.
1134
1183
  const spawnedModule = (service.type ?? 'twin') === 'twin' ? spawnedTwinModule(service, paths.root) : undefined;
1135
1184
  const mounted: MountedPackage = spawnedModule !== undefined ? lockfileCheck(service, spawnedModule) : {};
@@ -1139,6 +1188,8 @@ async function startService(
1139
1188
  const binding = managedDatabaseFor(vendorOfModule(spawnedModule, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
1140
1189
  if (binding !== undefined) args.push(binding.arg, binding.url);
1141
1190
  }
1191
+ const sides = (service.type ?? 'twin') === 'twin' ? await sidePortsFor(vendorOfModule(spawnedModule, service.id), args, allocatedPorts) : [];
1192
+ for (const side of sides) if (!side.pinned) args.push(side.flag, String(side.port));
1142
1193
  if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
1143
1194
  if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
1144
1195
 
@@ -1218,6 +1269,7 @@ async function startService(
1218
1269
  command: [service.command, ...args],
1219
1270
  port,
1220
1271
  url,
1272
+ ...sidesRecord(sides),
1221
1273
  ...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
1222
1274
  ...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
1223
1275
  pid: child.pid ?? 0,
@@ -1246,35 +1298,49 @@ function mountedPackage(modulePath: string, serviceId: string): MountedPackage {
1246
1298
  }
1247
1299
  const up = dirname(dir); if (up === dir) break; dir = up;
1248
1300
  }
1249
- const m = /\/packages\/twin\/([^/]+)\//.exec(modulePath) ?? /@volter\/twin-([^/]+)\//.exec(modulePath);
1250
- const vendor = m?.[1] ?? serviceId;
1251
- try { const facts = packFacts()[vendor]; if (facts?.protocol) out.protocol = facts.protocol; } catch { /* no artifact: the standing is unknown, not refused */ }
1301
+ const vendor = vendorOfPackModule(modulePath) ?? serviceId;
1302
+ try { const facts = packFacts()[vendor]; if (facts?.protocol) out.protocol = { ...protocolStanding(facts.protocol.declared), declared: facts.protocol.declared }; } catch { /* no artifact: the standing is unknown, not refused */ }
1252
1303
  return out;
1253
1304
  }
1254
1305
  /** R17 + R16 at boot, on BOTH paths (the colocated host and the spawned process): the mounted
1255
- * package must satisfy the pin, its protocol standing must not be refused, and a deprecated one
1256
- * is said aloud. Returns what to stamp on the instance record. */
1306
+ * package must satisfy its pin and its recorded declaration must match the kernel. Registration and the host
1307
+ * check the loaded descriptor again before a factory runs; compiled facts are only preflight evidence. */
1257
1308
  function lockfileCheck(service: WorldServiceConfig, modulePath: string): MountedPackage {
1258
1309
  const mounted = mountedPackage(modulePath, service.id);
1259
1310
  if (service.version !== undefined) {
1260
1311
  if (mounted.version === undefined) throw new Error(`Twin "${service.id}" pins version ${service.version} but no package.json was found above ${modulePath} — nothing to hold the pin against`);
1261
- if (!versionSatisfies(mounted.version, service.version)) 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)`);
1312
+ if (!versionSatisfies(mounted.version, service.version)) 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)`);
1262
1313
  }
1263
- if (mounted.protocol?.standing === 'refused') 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)`);
1264
- if (mounted.protocol?.standing === 'deprecated') 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`);
1314
+ if (mounted.protocol?.standing === 'refused') throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION} only (runtime contract R16)`);
1265
1315
  return mounted;
1266
1316
  }
1267
- /** The module a spawned twin runs from: its first arg under packages/twin/<vendor>/ or an installed
1317
+ /** The module a spawned twin runs from: its first arg in a vendor package, from a checkout or an installed
1268
1318
  * @volter/twin-<vendor>/ package, resolved. */
1269
1319
  function spawnedTwinModule(service: WorldServiceConfig, root: string): string | undefined {
1270
- const first = (service.args ?? []).find((a) => /packages\/twin\/[^/]+\//.test(a) || /@volter\/twin-[^/]+\//.test(a));
1320
+ const first = (service.args ?? []).find((a) => !a.startsWith('-') && /\.(?:[cm]?js|tsx?)$/.test(a) && vendorOfPackModule(resolve(root, a)) !== undefined);
1271
1321
  return first === undefined ? undefined : (first.startsWith('/') ? first : resolve(root, first));
1272
1322
  }
1273
1323
 
1274
- /** The vendor a twin module belongs to (`packages/twin/<vendor>/…` or `@volter/twin-<vendor>/…`), else the service id. */
1324
+ /** A twin's side ports (TwinPack.sidePorts), each allocated before boot unless the service's own args already name it
1325
+ * (a World that pins the port keeps its pin), with the URL the World records for it. */
1326
+ async function sidePortsFor(vendor: string, args: readonly string[], allocatedPorts: Set<number>): Promise<Array<{ name: string; option: string; flag: string; port: number; pinned: boolean; url: string }>> {
1327
+ const declared = packFacts()[vendor]?.sidePorts ?? [];
1328
+ const out: Array<{ name: string; option: string; flag: string; port: number; pinned: boolean; url: string }> = [];
1329
+ for (const side of declared) {
1330
+ const at = args.indexOf(side.flag);
1331
+ const given = at >= 0 ? Number(args[at + 1]) : Number.NaN;
1332
+ const pinned = Number.isInteger(given) && given > 0;
1333
+ const port = pinned ? given : await allocatePort(allocatedPorts);
1334
+ out.push({ name: side.name, option: side.option, flag: side.flag, port, pinned, url: `http://127.0.0.1:${port}${side.path ?? ''}` });
1335
+ }
1336
+ return out;
1337
+ }
1338
+
1339
+ const sidesRecord = (sides: ReadonlyArray<{ name: string; port: number; url: string }>): { sides?: Record<string, { port: number; url: string }> } =>
1340
+ sides.length ? { sides: Object.fromEntries(sides.map((side) => [side.name, { port: side.port, url: side.url }])) } : {};
1341
+
1275
1342
  function vendorOfModule(modulePath: string | undefined, serviceId: string): string {
1276
- const m = modulePath === undefined ? null : (/\/packages\/twin\/([^/]+)\//.exec(modulePath) ?? /@volter\/twin-([^/]+)(\/|$)/.exec(modulePath));
1277
- return m?.[1] ?? serviceId;
1343
+ return (modulePath === undefined ? undefined : vendorOfPackModule(modulePath)) ?? serviceId;
1278
1344
  }
1279
1345
 
1280
1346
  /**
@@ -1297,12 +1363,86 @@ export function managedDatabaseFor(vendor: string, configPath: string | undefine
1297
1363
  return url === undefined ? undefined : { arg: declared.arg, url };
1298
1364
  }
1299
1365
 
1366
+ /** The facts files the World's installed packs carry (a released pack's `generated/pack-facts.json`), which the kernel's
1367
+ * pack-facts loader reads through the instance file. */
1368
+ function packFactsFiles(config: WorldConfig, root: string): string[] {
1369
+ const files: string[] = [];
1370
+ for (const service of config.services) {
1371
+ if (service.package === undefined) continue;
1372
+ let dir: string;
1373
+ try { dir = resolveServicePackage(root, service.package); } catch { continue; }
1374
+ const file = join(dir, 'generated', 'pack-facts.json');
1375
+ if (existsSync(file)) files.push(file);
1376
+ }
1377
+ return [...new Set(files)].sort();
1378
+ }
1379
+
1300
1380
  /**
1301
1381
  * The config as the runtime boots it (docs/concepts/worlds.md#the-config-and-the-running-world): a service
1302
1382
  * naming a `package` gets its entry and colocate module resolved from the world root NOW, by
1303
1383
  * node's own walk, and a `$mint` env value becomes a freshly minted throwaway credential for this
1304
1384
  * boot. Everything downstream sees ordinary command/args/module/env.
1305
1385
  */
1386
+ /** An env name whose value is still `$issue:<service>` is not set yet: no twin, host or application is handed the
1387
+ * marker. */
1388
+ function dropUnissued(env: Record<string, string>): void {
1389
+ for (const [key, value] of Object.entries(env)) if (value.startsWith(ISSUE_ENV_PREFIX)) delete env[key];
1390
+ }
1391
+
1392
+ /** The names the World's twins issue (`$issue:<service>` in its config), taken out of the boot env until issued. */
1393
+ function takeIssued(env: Record<string, string>): Map<string, string> {
1394
+ const pending = new Map<string, string>();
1395
+ for (const [key, value] of Object.entries(env)) if (value.startsWith(ISSUE_ENV_PREFIX)) pending.set(key, value.slice(ISSUE_ENV_PREFIX.length));
1396
+ dropUnissued(env);
1397
+ return pending;
1398
+ }
1399
+
1400
+ /** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
1401
+ * own store, in milliseconds; this bounds a hung twin, not a slow one). */
1402
+ const ISSUE_TIMEOUT_MS = 15_000;
1403
+
1404
+ /** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
1405
+ * asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
1406
+ * door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
1407
+ async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>): Promise<void> {
1408
+ const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
1409
+ if (wanted.length === 0) return;
1410
+ for (const name of wanted) pending.delete(name);
1411
+ // the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
1412
+ let door = service.issues;
1413
+ try { door = packFacts()[service.id]?.credentialDoor ?? door; } catch { /* no artifact: the saved copy */ }
1414
+ try {
1415
+ if (!door) throw new Error('its service names no credential door');
1416
+ if (!started.url) throw new Error('it has no URL to ask');
1417
+ 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) });
1418
+ if (!res.ok) throw new Error(`answered ${res.status}`);
1419
+ const answer = (await res.json()) as Record<string, unknown>;
1420
+ for (const name of wanted) {
1421
+ const spec = door.fill[name] ?? '';
1422
+ // `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
1423
+ // beside the World's instance file, the name set to its path
1424
+ const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
1425
+ const value = answer[field];
1426
+ if (spec.startsWith('file:')) {
1427
+ if (value === undefined || value === null) throw new Error(`answered no ${field}`);
1428
+ // beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
1429
+ if (!env.VOLTER_WORLD_INSTANCE) throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
1430
+ const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
1431
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1432
+ const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
1433
+ writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1434
+ env[name] = path;
1435
+ continue;
1436
+ }
1437
+ if (typeof value !== 'string') throw new Error(`answered no ${field}`);
1438
+ env[name] = value;
1439
+ }
1440
+ } catch (error) {
1441
+ process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
1442
+ for (const name of wanted) { delete env[name]; unissued.add(name); }
1443
+ }
1444
+ }
1445
+
1306
1446
  function materializeConfig(config: WorldConfig, root: string): WorldConfig {
1307
1447
  const services = config.services.map((service) => {
1308
1448
  if (service.package === undefined) return service;
@@ -1358,7 +1498,7 @@ async function startColocatedServices(
1358
1498
  const hostCli = siblingScript(import.meta.url, 'host-cli');
1359
1499
  const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
1360
1500
  const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
1361
- const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage }> = [];
1501
+ const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage; sides: Awaited<ReturnType<typeof sidePortsFor>> }> = [];
1362
1502
  for (const service of colocated) {
1363
1503
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1364
1504
  const serviceDataDir = join(paths.data, service.id);
@@ -1373,8 +1513,9 @@ async function startColocatedServices(
1373
1513
  const mounted = lockfileCheck(service, module);
1374
1514
  const scenario = service.colocate!.scenarioPath;
1375
1515
  const binding = managedDatabaseFor(vendorOfModule(module, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
1376
- 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('|'));
1377
- allocated.push({ service, port, mounted });
1516
+ const sides = await sidePortsFor(vendorOfModule(module, service.id), [], allocatedPorts);
1517
+ 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('|'));
1518
+ allocated.push({ service, port, mounted, sides });
1378
1519
  }
1379
1520
 
1380
1521
  const log = join(paths.logs, 'host.log');
@@ -1382,6 +1523,9 @@ async function startColocatedServices(
1382
1523
  // services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
1383
1524
  const env: Record<string, string> = { ...admittedEnv(config.stripEnv), ...(config.env ?? {}), ...worldEnv };
1384
1525
  for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
1526
+ // the twins never read the application's own origin, nor a credential not yet issued
1527
+ for (const [key, value] of Object.entries(env)) if (value === APP_URL_ENV) delete env[key];
1528
+ dropUnissued(env);
1385
1529
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1386
1530
  // Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
1387
1531
  const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
@@ -1398,7 +1542,7 @@ async function startColocatedServices(
1398
1542
  const host = '127.0.0.1';
1399
1543
  // Every twin's address is known once its port is allocated: the records and
1400
1544
  // the environment they inject exist before the host reports its starts.
1401
- const services = allocated.map(({ service, port, mounted }) => {
1545
+ const services = allocated.map(({ service, port, mounted, sides }) => {
1402
1546
  const url = `http://${host}:${port}`;
1403
1547
  return {
1404
1548
  id: service.id,
@@ -1406,6 +1550,7 @@ async function startColocatedServices(
1406
1550
  command,
1407
1551
  port,
1408
1552
  url,
1553
+ ...sidesRecord(sides),
1409
1554
  ...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
1410
1555
  ...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
1411
1556
  pid: child.pid ?? 0,
@@ -1419,7 +1564,7 @@ async function startColocatedServices(
1419
1564
  });
1420
1565
  const ready = (async (): Promise<void> => {
1421
1566
  const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1422
- 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) => {
1567
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch((error) => {
1423
1568
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1424
1569
  try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1425
1570
  throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
@@ -1435,7 +1580,7 @@ async function startColocatedServices(
1435
1580
  // The host's receipt (this boot, every requested port) is published only
1436
1581
  // after every factory bound its own port; a port another process held fails
1437
1582
  // 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
1583
+ // for each port, so no twin waits out a settle (ADR 0014). A port that
1439
1584
  // answers with another boot's identity is still refused.
1440
1585
  const hostSettle = Promise.resolve('alive' as const);
1441
1586
  await parallelStartup(allocated, async ({ service, port }) => {
@@ -1462,6 +1607,11 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1462
1607
  abort?.throwIfAborted();
1463
1608
  const root = resolve(options.root ?? process.cwd());
1464
1609
  const found = loadWorldConfig(configId, root);
1610
+ // the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
1611
+ const packFactsPaths = packFactsFiles(found.config, root);
1612
+ for (const file of packFactsPaths) overlayPackFactsFile(file);
1613
+ // and this process's injector host rules, which `up`'s inert-injectEnv warning reads, know them too
1614
+ createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
1465
1615
  const loaded = { path: found.path, config: materializeConfig(found.config, root) };
1466
1616
  const name = options.name ?? loaded.config.id;
1467
1617
  const mode = options.mode ?? 'local';
@@ -1508,10 +1658,18 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1508
1658
  const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
1509
1659
  const instanceFile = join(instance, 'instance.json');
1510
1660
  let carriedOrigin: WorldInstance['origin'] | undefined;
1511
- const priorOutputInstance = existsSync(instanceFile) ? JSON.parse(readFileSync(instanceFile, 'utf8')) as WorldInstance : undefined;
1512
- const priorOutput = priorOutputInstance?.name === name && resolve(priorOutputInstance.root) === root
1513
- ? { path: resolve(priorOutputInstance.envFile), contents: envFileContents(priorOutputInstance.env) } : undefined;
1514
- const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutput, options.forceEnvOutput);
1661
+ // the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
1662
+ // writes it), so a file any of them last wrote is the World's own, never a custody file
1663
+ const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
1664
+ const priorOutputs = [name, ...siblings].flatMap((world) => {
1665
+ const file = world === name ? instanceFile : join(instanceDir(root, world), 'instance.json');
1666
+ if (!existsSync(file)) return [];
1667
+ try {
1668
+ const prior = JSON.parse(readFileSync(file, 'utf8')) as WorldInstance;
1669
+ return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
1670
+ } catch { return []; }
1671
+ });
1672
+ const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
1515
1673
 
1516
1674
  if (loaded.config.resources) process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
1517
1675
  checkedOwner(options.owner);
@@ -1625,7 +1783,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1625
1783
  // the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
1626
1784
  // on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
1627
1785
  VOLTER_WORLD: name,
1628
- ...(loaded.config.network ? { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, mode === 'sealed' ? { egress: [] } : loaded.config.network)) } : {}),
1786
+ ...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
1629
1787
  VOLTER_WORLD_MODE: mode,
1630
1788
  VOLTER_WORLD_CONFIG: loaded.path,
1631
1789
  VOLTER_WORLD_INSTANCE: instanceFile,
@@ -1646,6 +1804,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1646
1804
  };
1647
1805
  env.NO_PROXY = `127.0.0.1,localhost${env.NO_PROXY || inheritedEnv.NO_PROXY ? `,${env.NO_PROXY ?? inheritedEnv.NO_PROXY}` : ''}`;
1648
1806
  env.no_proxy = `127.0.0.1,localhost${env.no_proxy || inheritedEnv.no_proxy ? `,${env.no_proxy ?? inheritedEnv.no_proxy}` : ''}`;
1807
+ const issuing = takeIssued(env);
1808
+ const unissued = new Set<string>();
1649
1809
  writeProxyEnv(root, name, env);
1650
1810
 
1651
1811
  const worldInstance: WorldInstance = {
@@ -1655,6 +1815,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1655
1815
  ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
1656
1816
  ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
1657
1817
  lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
1818
+ ...(packFactsPaths.length ? { packFacts: packFactsPaths } : {}),
1658
1819
  ...(carriedOrigin ? { origin: carriedOrigin } : {}),
1659
1820
  };
1660
1821
  saveWorldInstance(worldInstance);
@@ -1685,8 +1846,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1685
1846
  // The co-located host's twins are known by address once their ports are
1686
1847
  // allocated. A twin serves and calls no other service while it starts,
1687
1848
  // so a later twin declaration starts beside the host's startup; any other
1688
- // service waits until every earlier one is ready, as declared (ADR-0009).
1689
- let colocatedReady: Promise<void> | undefined;
1849
+ // service waits until every earlier one is ready, as declared (ADR 0015).
1850
+ // A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
1851
+ let colocatedReady: (() => Promise<void>) | undefined;
1690
1852
  if (isolation !== 'process') {
1691
1853
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
1692
1854
  const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
@@ -1694,7 +1856,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1694
1856
  services[started.id] = started;
1695
1857
  Object.assign(env, started.env);
1696
1858
  }
1697
- colocatedReady = host.ready;
1859
+ colocatedReady = async () => {
1860
+ await host.ready;
1861
+ for (const started of host.services) {
1862
+ const declared = colocated.find((s) => s.id === started.id);
1863
+ if (declared) await issueCredentials(declared, started, env, issuing, unissued);
1864
+ }
1865
+ writeProxyEnv(root, name, env);
1866
+ };
1698
1867
  recordPidsSoFar();
1699
1868
  writeProxyEnv(root, name, env);
1700
1869
  }
@@ -1706,6 +1875,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1706
1875
  // the twins undetected.
1707
1876
  const attemptProxy = (): void => {
1708
1877
  if (proxyForServices || (Object.keys(activeVendorMap(env)).length === 0 && env[WORLD_NETWORK_POLICY_ENV] === undefined)) return;
1878
+ // a host with no raw sockets (a browser tab) has nothing for the proxy to intercept; the injector holds the policy
1879
+ if (!hostHasRawEgress()) return;
1709
1880
  if (!opensslAvailable()) {
1710
1881
  if (mode === 'sealed' || loaded.config.network !== undefined) {
1711
1882
  throw new Error(
@@ -1726,17 +1897,22 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1726
1897
  };
1727
1898
  for (const service of loaded.config.services) {
1728
1899
  if (services[service.id]) continue; // already up co-located
1729
- if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady; colocatedReady = undefined; }
1900
+ if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady(); colocatedReady = undefined; }
1730
1901
  attemptProxy();
1731
1902
  boot.signal.throwIfAborted();
1732
1903
  const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
1733
1904
  services[started.id] = started;
1734
1905
  recordPidsSoFar();
1735
1906
  Object.assign(env, started.env);
1907
+ await issueCredentials(service, started, env, issuing, unissued);
1736
1908
  writeProxyEnv(root, name, env);
1737
1909
  }
1738
- if (colocatedReady) await colocatedReady;
1910
+ if (colocatedReady) await colocatedReady();
1739
1911
  detachBootSignals();
1912
+ // a name a service of no twin in this World issues (a config edited after init) is left unset, and said
1913
+ if (issuing.size > 0) process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
1914
+ for (const [name] of issuing) unissued.add(name);
1915
+ if (unissued.size > 0) { env[UNISSUED_ENV] = JSON.stringify([...unissued].sort()); writeProxyEnv(root, name, env); }
1740
1916
  // Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
1741
1917
  // only lands in `env` once IT has started, so a check that only runs "before starting the
1742
1918
  // next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
@@ -2937,7 +3113,15 @@ export function urlWorld(name: string, options: { service?: string; root?: strin
2937
3113
  if (options.service === undefined) {
2938
3114
  return Object.values(status.services)
2939
3115
  .filter((service) => service.url)
2940
- .map((service) => ({ id: service.id, url: service.url! }));
3116
+ .flatMap((service) => [{ id: service.id, url: service.url! }, ...Object.entries(service.sides ?? {}).map(([side, at]) => ({ id: `${service.id}.${side}`, url: at.url }))]);
3117
+ }
3118
+ // a side port by `<service>.<name>` (the SMTP twin's mailbox: smtp.inspect)
3119
+ const dot = options.service.lastIndexOf('.');
3120
+ if (!status.services[options.service] && dot > 0) {
3121
+ const owner = status.services[options.service.slice(0, dot)];
3122
+ const side = owner?.sides?.[options.service.slice(dot + 1)];
3123
+ if (side) return [{ id: options.service, url: side.url }];
3124
+ if (owner) throw new Error(`Service "${owner.id}" has no side port "${options.service.slice(dot + 1)}"; it has: ${Object.keys(owner.sides ?? {}).join(', ') || 'none'}`);
2941
3125
  }
2942
3126
  const service = status.services[options.service];
2943
3127
  if (!service) {
@@ -3052,26 +3236,42 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false): {
3052
3236
  const proxy = ensureWorldProxy(name, root, [admittedEnv(stripEnvOf(status.env)), status.env]);
3053
3237
  if (proxy?.url) {
3054
3238
  proxyEnv = proxy.env;
3055
- } else if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
3239
+ // a host with no raw sockets (a browser tab) runs no proxy: the injector holds the policy there, as at `up`
3240
+ } else if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
3056
3241
  } catch (error) {
3057
3242
  if (error instanceof CaTrustError) throw error;
3058
- if (sealed) 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)})`);
3243
+ if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${error instanceof Error ? error.message : String(error)})`);
3059
3244
  // Keep env-only redirect if the ambient proxy cannot start.
3060
3245
  }
3061
- return { instance: status, env: { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
3062
- VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' } };
3246
+ return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
3247
+ VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' }) };
3248
+ }
3249
+
3250
+ /** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
3251
+ * unset (the application's own env files decide) and the command is told how to record it: never a guess. */
3252
+ export function withAppUrl<E extends Record<string, string | undefined>>(name: string, root: string, env: E): E {
3253
+ const names = Object.keys(env).filter((key) => env[key] === APP_URL_ENV);
3254
+ if (names.length === 0) return env;
3255
+ const out: Record<string, string | undefined> = { ...env };
3256
+ const recorded = readAppUrl(name, { root });
3257
+ for (const key of names) {
3258
+ if (recorded) out[key] = recorded.url.replace(/\/+$/, '');
3259
+ else delete out[key];
3260
+ }
3261
+ if (!recorded) 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`);
3262
+ return out as E;
3063
3263
  }
3064
3264
 
3065
3265
  /** Runs the command in the world's env in the foreground and resolves to its exit code. The child is
3066
3266
  * awaited, not blocked on: a runtime that runs no synchronous child (the browser engine) still runs
3067
3267
  * this one, and the caller sees no difference — stdio is inherited either way. */
3068
- export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean } = {}): Promise<number> {
3268
+ export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean; env?: Record<string, string> } = {}): Promise<number> {
3069
3269
  if (command.length === 0) throw new Error('Missing command after --');
3070
3270
  const attached = worldAttachedCommandEnv(name, root, options.verbose);
3071
3271
  return new Promise((resolveExit) => {
3072
3272
  const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
3073
3273
  cwd: resolve(options.cwd ?? root),
3074
- env: attached.env,
3274
+ env: { ...attached.env, ...(options.env ?? {}) },
3075
3275
  stdio: 'inherit',
3076
3276
  });
3077
3277
  const forward = (signal: NodeJS.Signals) => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
@@ -3275,7 +3475,7 @@ export function worldShellEnv(name: string, root = process.cwd()): { shell: stri
3275
3475
  return {
3276
3476
  shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
3277
3477
  cwd: resolve(root),
3278
- env: { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>,
3478
+ env: withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>),
3279
3479
  };
3280
3480
  }
3281
3481
 
@@ -3290,7 +3490,9 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
3290
3490
  const resolvedRoot = resolve(root);
3291
3491
  let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
3292
3492
  const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
3293
- if (opensslAvailable()) {
3493
+ if (!hostHasRawEgress()) {
3494
+ 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`);
3495
+ } else if (opensslAvailable()) {
3294
3496
  try {
3295
3497
  proxy = await startRedirectProxy({ env, trustEnv: [admittedEnv(stripEnvOf(env)), env], tlsDir: tlsDir(resolvedRoot, name) });
3296
3498
  Object.assign(env, proxy.proxyEnv());
@@ -3355,7 +3557,7 @@ function currentProxyState(root: string, name: string): ProxyState | null {
3355
3557
 
3356
3558
  function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInputs): { url: string; caCertPath: string; env: Record<string, string> } | null {
3357
3559
  const resolvedRoot = resolve(root);
3358
- if (!opensslAvailable()) return null;
3560
+ if (!hostHasRawEgress() || !opensslAvailable()) return null;
3359
3561
  const existing = currentProxyState(resolvedRoot, name);
3360
3562
  if (existing && isAlive(existing.pid)) {
3361
3563
  return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath, caller, existing.guestUrl) };
@@ -3367,7 +3569,7 @@ function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInp
3367
3569
 
3368
3570
  function ensureWorldProxyFromEnv(name: string, root: string, env: Record<string, string>, boot: BootContext): { url: string; caCertPath: string; env: Record<string, string> } | null {
3369
3571
  const resolvedRoot = resolve(root);
3370
- if (!opensslAvailable()) return null;
3572
+ if (!hostHasRawEgress() || !opensslAvailable()) return null;
3371
3573
  writeProxyEnv(resolvedRoot, name, env);
3372
3574
  const existing = currentProxyState(resolvedRoot, name);
3373
3575
  if (existing && isAlive(existing.pid)) {
@@ -3443,6 +3645,9 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
3443
3645
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
3444
3646
  }
3445
3647
  retire();
3648
+ // the proxy is confirmed gone: ownership goes back to the World's running processes, as on success (it was retained
3649
+ // for cleanup only while the child's fate was unknown), so the World stays running unsealed, as the warning says
3650
+ if (!boot) handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
3446
3651
  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`);
3447
3652
  return { url: '', caCertPath, env: {} };
3448
3653
  } catch (error) {
@@ -3553,12 +3758,13 @@ export function activateScript(name: string, root = process.cwd()): string {
3553
3758
  if (proxy && proxy.url) {
3554
3759
  env = { ...baseEnv, ...proxy.env };
3555
3760
  proxyNote = ` (ambient proxy ${proxy.url})`;
3556
- } else if (sealed) throw new Error('attachment proxy is unavailable');
3761
+ } else if (sealed && hostHasRawEgress()) throw new Error('attachment proxy is unavailable');
3557
3762
  } catch (error) {
3558
3763
  if (error instanceof CaTrustError) throw error;
3559
- if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
3764
+ if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
3560
3765
  // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
3561
3766
  }
3767
+ env = withAppUrl(name, root, env);
3562
3768
  const keys = Object.keys(env).sort();
3563
3769
  const lines: string[] = [
3564
3770
  `# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,