@volter/world-runtime 3.0.166 → 3.0.168

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 (82) hide show
  1. package/dist/known-external-services.json +0 -1
  2. package/dist/src/actions-runner.js +53 -14
  3. package/dist/src/attach.d.ts +9 -0
  4. package/dist/src/attach.js +9 -2
  5. package/dist/src/cli-commands.js +19 -3
  6. package/dist/src/console-apart.js +3 -2
  7. package/dist/src/credential-door.d.ts +8 -0
  8. package/dist/src/credential-door.js +54 -0
  9. package/dist/src/fixture-env.js +2 -2
  10. package/dist/src/forwardable-headers.d.ts +1 -0
  11. package/dist/src/forwardable-headers.js +19 -0
  12. package/dist/src/go-mysql-binding.d.ts +2 -0
  13. package/dist/src/go-mysql-binding.js +7 -1
  14. package/dist/src/guest-runner.js +8 -4
  15. package/dist/src/host-ready.d.ts +7 -3
  16. package/dist/src/host-ready.js +16 -3
  17. package/dist/src/index.d.ts +6 -1
  18. package/dist/src/index.js +7 -1
  19. package/dist/src/infra.js +10 -5
  20. package/dist/src/init.d.ts +5 -0
  21. package/dist/src/init.js +157 -23
  22. package/dist/src/managed-api-needs.d.ts +101 -0
  23. package/dist/src/managed-api-needs.js +312 -0
  24. package/dist/src/managed-api-source.d.ts +24 -0
  25. package/dist/src/managed-api-source.js +59 -0
  26. package/dist/src/mongodb-backing.js +33 -58
  27. package/dist/src/mongodb-command.d.ts +21 -0
  28. package/dist/src/mongodb-command.js +317 -0
  29. package/dist/src/pglite-host.mjs +2 -2
  30. package/dist/src/placeholder.d.ts +24 -1
  31. package/dist/src/placeholder.js +105 -13
  32. package/dist/src/process-signal.d.mts +2 -0
  33. package/dist/src/process-signal.mjs +44 -2
  34. package/dist/src/project-inspect.d.ts +1 -1
  35. package/dist/src/project-inspect.js +8 -1
  36. package/dist/src/redirect-proxy.js +4 -19
  37. package/dist/src/reflect.js +2 -2
  38. package/dist/src/runtime.d.ts +5 -1
  39. package/dist/src/runtime.js +311 -59
  40. package/dist/src/schema.d.ts +10 -0
  41. package/dist/src/schema.js +76 -3
  42. package/dist/src/serve.js +24 -4
  43. package/dist/src/served-world.d.ts +2 -1
  44. package/dist/src/served-world.js +6 -4
  45. package/dist/src/service-command.d.ts +7 -3
  46. package/dist/src/service-command.js +53 -22
  47. package/dist/src/service-environment.d.ts +4 -0
  48. package/dist/src/service-environment.js +21 -0
  49. package/dist/src/service-recorder.js +9 -13
  50. package/known-external-services.json +0 -1
  51. package/package.json +5 -5
  52. package/src/actions-runner.ts +38 -10
  53. package/src/attach.ts +15 -2
  54. package/src/cli-commands.ts +18 -3
  55. package/src/console-apart.ts +3 -2
  56. package/src/credential-door.ts +54 -0
  57. package/src/fixture-env.ts +2 -2
  58. package/src/forwardable-headers.ts +15 -0
  59. package/src/go-mysql-binding.ts +8 -1
  60. package/src/guest-runner.ts +8 -4
  61. package/src/host-ready.ts +12 -3
  62. package/src/index.ts +8 -1
  63. package/src/infra.ts +8 -3
  64. package/src/init.ts +145 -23
  65. package/src/managed-api-needs.ts +285 -0
  66. package/src/managed-api-source.ts +67 -0
  67. package/src/mongodb-backing.ts +32 -48
  68. package/src/mongodb-command.ts +238 -0
  69. package/src/pglite-host.mjs +2 -2
  70. package/src/placeholder.ts +90 -13
  71. package/src/process-signal.d.mts +2 -0
  72. package/src/process-signal.mjs +44 -2
  73. package/src/project-inspect.ts +9 -2
  74. package/src/redirect-proxy.ts +4 -15
  75. package/src/reflect.ts +2 -2
  76. package/src/runtime.ts +272 -45
  77. package/src/schema.ts +73 -3
  78. package/src/serve.ts +22 -4
  79. package/src/served-world.ts +7 -5
  80. package/src/service-command.ts +41 -19
  81. package/src/service-environment.ts +21 -0
  82. package/src/service-recorder.ts +9 -9
package/src/runtime.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { isScalarCredentialField, withCurrentCredentialDoor } from './credential-door.ts';
1
2
  import { errorMessage } from './error-message.ts';
3
+ import { recorderEnvironment, recordedApplicationEnvironment } from './service-environment.ts';
2
4
  import { materializeWorldBrowserFiles } from './browser-files.ts';
3
5
  import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
4
6
  import { offSignal } from './signals.ts';
5
7
  import { parallelStartup } from './startup.ts';
6
- import { type HostReadyTimes, waitForHostReady } from './host-ready.ts';
8
+ import { startedTwins, type HostReadyTimes, waitForHostReady } from './host-ready.ts';
7
9
  import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
8
10
  import { packCli, packEntry } from './catalog.ts';
9
11
  import { vendorOfPackModule } from '@volter/world-core/runtime-state';
@@ -34,7 +36,7 @@ import { loadWorldConfig } from './configs.ts';
34
36
  import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, type WorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
35
37
  import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
36
38
  import { fakeEnvValue } from './fixture-env.ts';
37
- import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
39
+ import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV, assertRequiredCredentialBindings } from './schema.ts';
38
40
  import { readAppUrl } from './app-url.ts';
39
41
  import { inertInjectEnvWarnings } from './inject-map.ts';
40
42
  import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, tearDownCa } from './proxy-env.ts';
@@ -69,13 +71,13 @@ export function worldEventsPath(logs: string): string {
69
71
  * its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
70
72
  * file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
71
73
  * `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
72
- function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
74
+ function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext, required?: RequiredCredentialClaims): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
73
75
  const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
76
+ const recorderEnv = recorderEnvironment(env, serviceId, log, logDir, worldEventsPath(dirname(log)));
77
+ // The recorder adds and removes its own names. Validate the same final child environment before opening a log or spawning.
78
+ if (required !== undefined) assertRequiredCredentials(required, recordedApplicationEnvironment(recorderEnv), `service ${serviceId}'s recorded application environment`);
74
79
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
75
80
  const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
76
- const recorderEnv: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
77
- VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: worldEventsPath(dirname(log)) };
78
- if (recorderEnv.NODE_OPTIONS !== undefined) { recorderEnv.VOLTER_WORLD_SERVICE_NODE_OPTIONS = recorderEnv.NODE_OPTIONS; delete recorderEnv.NODE_OPTIONS; }
79
81
  const child = spawn(process.execPath, [siblingScript(import.meta.url, 'service-recorder'), command, ...args], { windowsHide: true,
80
82
  cwd,
81
83
  env: recorderEnv,
@@ -614,9 +616,12 @@ function networkOf(name: string, own: WorldNetwork | undefined, recorded: WorldN
614
616
  return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
615
617
  }
616
618
 
617
- /** Strip the publication envelope before combining permissions (the config shape admits only these keys). */
619
+ /** Strip the publication envelope before combining permissions (the config shape admits only these keys). Every
620
+ * permission the policy carries stays: a dropped `writes` or `publicReads` would read as the default when combined,
621
+ * and a build World inside a closed one would read the public build origins its parent may not (sealing never widens). */
618
622
  function networkPermission(policy: WorldNetwork): WorldNetwork {
619
- return { egress: policy.egress, phase: policy.phase, passthrough: policy.passthrough, ...(policy.ownLoopback === false ? { ownLoopback: false } : {}) };
623
+ return { egress: policy.egress, ...(policy.writes !== undefined ? { writes: policy.writes } : {}), phase: policy.phase, passthrough: policy.passthrough,
624
+ ...(policy.publicReads !== undefined ? { publicReads: policy.publicReads } : {}), ...(policy.ownLoopback === false ? { ownLoopback: false } : {}) };
620
625
  }
621
626
 
622
627
  /** The boot permission, never a fallback to a mutable config. */
@@ -724,7 +729,13 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
724
729
  } catch { throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`); }
725
730
  }
726
731
 
727
- export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
732
+ /** What Windows itself needs in every process: without these, Winsock and DLL loading fail, the temp directory falls
733
+ * back to C:\Windows\Temp and tools lose their profile folders. They hold no caller credential, so a strip (even
734
+ * `*`) never removes them on win32. Windows names are case-insensitive. */
735
+ const WINDOWS_BASELINE_ENV = new Set(['systemroot', 'windir', 'systemdrive', 'comspec', 'pathext', 'temp', 'tmp', 'userprofile',
736
+ 'appdata', 'localappdata', 'programdata', 'programfiles', 'programfiles(x86)', 'programw6432', 'commonprogramfiles',
737
+ 'commonprogramfiles(x86)', 'commonprogramw6432', 'homedrive', 'homepath', 'os', 'processor_architecture', 'number_of_processors']);
738
+ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): Record<string, string> {
728
739
  const out: Record<string, string> = {};
729
740
  const rules = (strip ?? []).map((s): { prefix: string } | { name: string } => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
730
741
  for (const [k, v] of Object.entries(env)) {
@@ -732,6 +743,7 @@ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv
732
743
  // Permission is published by the attached instance, never inherited from
733
744
  // whichever World happened to launch this operator process.
734
745
  if (k === WORLD_NETWORK_POLICY_ENV) continue;
746
+ if (platform === 'win32' && WINDOWS_BASELINE_ENV.has(k.toLowerCase())) { out[k] = v; continue; }
735
747
  if (rules.some((r) => ('prefix' in r ? k.startsWith(r.prefix) : k === r.name))) continue;
736
748
  out[k] = v;
737
749
  }
@@ -769,6 +781,7 @@ function serviceProcessEnv(
769
781
  service: WorldServiceConfig,
770
782
  worldEnv: Record<string, string>,
771
783
  port: number,
784
+ twin = service.type === 'twin',
772
785
  ): Record<string, string> {
773
786
  const env: Record<string, string> = {
774
787
  ...admittedEnv(config.stripEnv),
@@ -782,6 +795,12 @@ function serviceProcessEnv(
782
795
  for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
783
796
  }
784
797
  dropUnissued(env);
798
+ // NODE_ENV is the application's mode: a twin is the World's own server, run as built (React's production server build
799
+ // crashes Bun 1.3.11 at load, so Twenty's NODE_ENV=production stopped every twin with screens); a service's own env
800
+ // may still set it
801
+ // a twin is a service that runs a pack: declared `type: 'twin'`, or untyped and spawning a pack's module (never an
802
+ // untyped command of the application's)
803
+ if (twin) delete env.NODE_ENV;
785
804
  const out: Record<string, string> = { ...env, ...(service.env ?? {}) };
786
805
  // the application's own origin: the World's own application service is reached where it listens (the URL
787
806
  // `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
@@ -1273,6 +1292,7 @@ async function startExternalService(
1273
1292
  service: WorldServiceConfig,
1274
1293
  paths: { root: string; logs: string },
1275
1294
  worldEnv: Record<string, string>,
1295
+ required: RequiredCredentialClaims,
1276
1296
  stripEnv?: string[],
1277
1297
  boot?: BootContext,
1278
1298
  ): Promise<WorldServiceInstance> {
@@ -1281,6 +1301,7 @@ async function startExternalService(
1281
1301
  const log = ownedPath(paths.logs, `${service.id}.log`);
1282
1302
  const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1283
1303
  const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
1304
+ if (!ownInfrastructureService(service)) assertRequiredCredentials(required, env, `external service ${service.id}`);
1284
1305
 
1285
1306
  // 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
1286
1307
  if (!external.up[0] || !commandExists(external.up[0])) {
@@ -1411,8 +1432,11 @@ async function startService(
1411
1432
  paths: { root: string; instance: string; logs: string; data: string },
1412
1433
  allocatedPorts: Set<number>,
1413
1434
  boot: BootContext,
1435
+ required: RequiredCredentialClaims,
1414
1436
  ): Promise<WorldServiceInstance> {
1415
- if (service.type === 'external') return startExternalService(service, paths, worldEnv, config.stripEnv, boot);
1437
+ if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
1438
+ assertRequiredCredentials(required, worldEnv, `service ${service.id}`);
1439
+ if (service.type === 'external') return startExternalService(service, paths, worldEnv, required, config.stripEnv, boot);
1416
1440
  if (!service.command) throw new Error(`Service "${service.id}" must define command`);
1417
1441
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
1418
1442
  const serviceDataDir = ownedPath(paths.data, service.id);
@@ -1433,7 +1457,7 @@ async function startService(
1433
1457
  if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
1434
1458
 
1435
1459
  const log = ownedPath(paths.logs, `${service.id}.log`);
1436
- const env = serviceProcessEnv(config, service, worldEnv, port);
1460
+ const env = serviceProcessEnv(config, service, worldEnv, port, service.type === 'twin' || (service.type === undefined && spawnedModule !== undefined));
1437
1461
  const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
1438
1462
 
1439
1463
  // Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
@@ -1447,7 +1471,9 @@ async function startService(
1447
1471
  // The service runs under the service recorder, which outlives it to write its end into its own log: `up`
1448
1472
  // returns once the World is ready, so nothing else is left to see a service stop.
1449
1473
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1450
- const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot);
1474
+ if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
1475
+ assertRequiredCredentials(required, env, `service ${service.id}'s effective environment`);
1476
+ const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot, (service.type ?? 'twin') === 'twin' ? undefined : required);
1451
1477
  child.once('error', error => boot.fail(error));
1452
1478
  child.unref();
1453
1479
  await trackBootingChild(child, boot, identity);
@@ -1497,7 +1523,8 @@ async function startService(
1497
1523
 
1498
1524
  // Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
1499
1525
  // before it is actually serving, so this makes `up` wait until the service truly responds.
1500
- if (service.ready && service.ready !== 'tcp') {
1526
+ // `ready: { timeoutMs }` with neither is TCP readiness with its own budget: the bind wait above has used it.
1527
+ if (service.ready && service.ready !== 'tcp' && (service.ready.command !== undefined || service.ready.httpUrl !== undefined)) {
1501
1528
  const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
1502
1529
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1503
1530
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
@@ -1645,6 +1672,87 @@ function takeIssued(env: Record<string, string>): Map<string, string> {
1645
1672
  * own store, in milliseconds; this bounds a hung twin, not a slow one). */
1646
1673
  const ISSUE_TIMEOUT_MS = 15_000;
1647
1674
 
1675
+ /** Each required name belongs to this boot and gains a value only from its owner's scalar door response. */
1676
+ type RequiredCredentialClaims = Map<string, { service: string; value: string | undefined }>;
1677
+
1678
+ function requiredCredentialClaims(config: WorldConfig): RequiredCredentialClaims {
1679
+ const required: RequiredCredentialClaims = new Map();
1680
+ for (const service of config.services) for (const name of service.issues?.required ?? [])
1681
+ required.set(name, { service: service.id, value: undefined });
1682
+ return required;
1683
+ }
1684
+
1685
+ /** Bind names and owners to the actual values retained in instance.env; the hash is not vendor authentication. */
1686
+ function requiredCredentialValuesDigest(required: RequiredCredentialClaims): string {
1687
+ const rows = [...required.keys()].sort().map(name => {
1688
+ const claim = required.get(name)!;
1689
+ if (claim.value === undefined) throw new Error(`${name}: no issued value exists to retain`);
1690
+ return [name, claim.service, claim.value];
1691
+ });
1692
+ return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
1693
+ }
1694
+
1695
+ /** New boots retain their required owners even if source config later changes; legacy optional records gain no proof. */
1696
+ function recordedRequiredCredentialClaims(instance: WorldInstance): RequiredCredentialClaims {
1697
+ const required: RequiredCredentialClaims = new Map();
1698
+ if (!Object.hasOwn(instance, 'requiredCredentials')) throw new Error('World boot has no retained credential requirement declaration; finish teardown with its owning runtime and boot with this runtime before attachment');
1699
+ const raw: unknown = instance.requiredCredentials;
1700
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('World required credential record is invalid');
1701
+ const record = raw as Record<string, unknown>;
1702
+ if (Object.keys(record).some(key => !['version', 'owners', 'valuesSha256'].includes(key))
1703
+ || !Object.hasOwn(record, 'version') || record.version !== 1 || !Object.hasOwn(record, 'owners') || record.owners === null
1704
+ || typeof record.owners !== 'object' || Array.isArray(record.owners))
1705
+ throw new Error('World required credential record has an unsupported version or shape');
1706
+ if (!instance.env || typeof instance.env !== 'object' || Array.isArray(instance.env)
1707
+ || !instance.services || typeof instance.services !== 'object' || Array.isArray(instance.services))
1708
+ throw new Error('World required credential record lacks its retained environment or services');
1709
+ for (const [name, owner] of Object.entries(record.owners as Record<string, unknown>)) {
1710
+ if (!isEnvironmentName(name) || typeof owner !== 'string' || !owner || !Object.hasOwn(instance.services, owner))
1711
+ throw new Error(`World required credential owner is invalid for ${name}`);
1712
+ const service = instance.services[owner];
1713
+ if (!service || service.id !== owner || service.type !== 'twin') throw new Error(`World required credential ${name} names no twin owner`);
1714
+ const value = instance.env[name];
1715
+ if (!Object.hasOwn(instance.env, name) || typeof value !== 'string'
1716
+ || value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV)
1717
+ throw new Error(`World required credential ${name} has no retained scalar value`);
1718
+ required.set(name, { service: owner, value });
1719
+ }
1720
+ if (!Object.hasOwn(record, 'valuesSha256')) {
1721
+ if (required.size) throw new Error('World required credential issuance has not completed; refusing attachment');
1722
+ return required;
1723
+ }
1724
+ if (typeof record.valuesSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(record.valuesSha256)
1725
+ || record.valuesSha256 !== requiredCredentialValuesDigest(required))
1726
+ throw new Error('World required credential names, owners or retained values changed; refusing attachment');
1727
+ return required;
1728
+ }
1729
+
1730
+ /** Capture the validated boot's values before an owned executor applies its own environment layers. */
1731
+ export function worldRequiredCredentialGate(instance: WorldInstance): (env: NodeJS.ProcessEnv, boundary: string) => void {
1732
+ const required = recordedRequiredCredentialClaims(instance);
1733
+ return (env, boundary) => assertRequiredCredentials(required, env, boundary);
1734
+ }
1735
+
1736
+ /** These transports do not carry required ownership/value authority; refuse instead of exporting an optional-only rendering. */
1737
+ export function refuseRequiredCredentialTransport(instance: WorldInstance, transport: string, code?: 'WORLD_MACHINE_CAPABILITY_GAP'): void {
1738
+ const required = recordedRequiredCredentialClaims(instance);
1739
+ if (required.size) {
1740
+ const error = new Error(`World ${instance.name}: ${transport} does not carry required credential bindings (${[...required.keys()].join(', ')}); use local env attachment`);
1741
+ throw code ? Object.assign(error, { code }) : error;
1742
+ }
1743
+ }
1744
+
1745
+ /** Check the actual response value, then the environment this boundary will use; never print credential values. */
1746
+ function assertRequiredCredentials(required: RequiredCredentialClaims, env: NodeJS.ProcessEnv, boundary: string): void {
1747
+ const failures: Error[] = [];
1748
+ for (const [name, claim] of required) {
1749
+ if (claim.value === undefined) failures.push(new Error(`${name}: ${claim.service} has not issued its required scalar credential`));
1750
+ else if (!Object.hasOwn(env, name) || env[name] !== claim.value)
1751
+ failures.push(new Error(`${name}: its issued value is missing or overridden at ${boundary}`));
1752
+ }
1753
+ if (failures.length) throw new AggregateError(failures, `World required credential gate refused at ${boundary}: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
1754
+ }
1755
+
1648
1756
  /** Where one credential ask's time went (S2, the twins' credentials): performance.now() at each step, read by the caller
1649
1757
  * against its own origin. `door` is the pack's door read, `sent` the request handed to fetch, `headers` its answer's
1650
1758
  * status, `answered` its body parsed, `filled` every name set or said unset. Door→sent and answered→filled include synchronous placeholder-pull
@@ -1653,22 +1761,33 @@ type CredentialAskTimes = { twin?: string; names?: number; door?: number; sent?:
1653
1761
 
1654
1762
  /** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
1655
1763
  * asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
1656
- * door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
1657
- async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>, controlRoot: string, times?: CredentialAskTimes): Promise<void> {
1764
+ * door, leaves optional names unset and says so. Required scalar names fail the boot with their original reasons. */
1765
+ async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>, controlRoot: string, claims: RequiredCredentialClaims, fresh: boolean, times?: CredentialAskTimes): Promise<void> {
1658
1766
  const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
1659
1767
  const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
1660
1768
  if (times) { times.names = wanted.length; }
1769
+ const required = new Set(service.issues?.required ?? []);
1770
+ const failures: Error[] = [];
1771
+ for (const name of required) {
1772
+ if (!wanted.includes(name) || direct.has(name) || claims.get(name)?.service !== service.id)
1773
+ failures.push(new Error(`${name}: its exact $issue:${service.id} scalar request is absent at issuance`));
1774
+ }
1775
+ if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential requests refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
1661
1776
  if (wanted.length === 0) return;
1662
1777
  for (const name of wanted) pending.delete(name);
1663
- // the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
1664
- let door = service.issues;
1665
- try { door = packFacts()[service.id]?.credentialDoor ?? door; } catch { /* no artifact: the saved copy */ }
1778
+ // Revalidate aliases at the actual issuance boundary. A missing current pack door cannot revive a saved issuer.
1779
+ const door = (service.type ?? 'twin') === 'twin'
1780
+ ? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor).issues : service.issues;
1666
1781
  if (times) times.door = performance.now();
1667
1782
  try {
1668
1783
  if (!door) throw new Error('its service names no credential door');
1669
1784
  if (!started.url) throw new Error('it has no URL to ask');
1670
- // A boot's identity is existing account state, not an application write to deploy.
1671
- const enclosingSeed = placeholderPullActive(service.id, controlRoot);
1785
+ // A boot's identity is existing account state, not an application write to deploy: asked inside the twin's
1786
+ // placeholder-pull window, as the seed is (architecture, the descriptor's credentialDoor), on a fresh World's first
1787
+ // issuance only: a resumed World's door answers what it issued before, and its branch keeps its own writes; a twin
1788
+ // with a root is not seeded, and its door's writes stay its own
1789
+ const seeds = fresh && !(service as { root?: unknown }).root;
1790
+ const enclosingSeed = !seeds || placeholderPullActive(service.id, controlRoot);
1672
1791
  if (!enclosingSeed) beginPlaceholderPull(service.id, controlRoot);
1673
1792
  let answer: Record<string, unknown>;
1674
1793
  try {
@@ -1769,7 +1888,11 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
1769
1888
  continue;
1770
1889
  }
1771
1890
  const field = spec;
1891
+ if (required.has(name) && (!isScalarCredentialField(field) || answer === null || typeof answer !== 'object'
1892
+ || Array.isArray(answer) || !Object.hasOwn(answer, field))) throw new Error(`answered no own scalar ${field}`);
1772
1893
  const value = answer[field];
1894
+ if (required.has(name) && typeof value === 'string' && (value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV))
1895
+ throw new Error(`answered an interpreted environment marker for ${field}`);
1773
1896
  if (typeof value !== 'string') throw new Error(`answered no ${field}`);
1774
1897
  if (direct.has(name)) {
1775
1898
  const issued = new URL(value);
@@ -1779,9 +1902,19 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
1779
1902
  issued.host = endpoint.host;
1780
1903
  env[name] = issued.toString();
1781
1904
  } else env[name] = value;
1905
+ if (required.has(name)) {
1906
+ const claim = claims.get(name);
1907
+ if (!claim || claim.service !== service.id) throw new Error('its required credential owner changed');
1908
+ claim.value = value;
1909
+ }
1782
1910
  } catch (error) {
1783
- process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer.codex_unavailable === 'string' ? `; the twin says: ${answer.codex_unavailable.slice(0, 300)}` : ''}); left unset\n`);
1911
+ process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer?.codex_unavailable === 'string' ? `; the twin says: ${answer.codex_unavailable.slice(0, 300)}` : ''}); left unset\n`);
1784
1912
  delete env[name]; unissued.add(name);
1913
+ if (required.has(name)) {
1914
+ const claim = claims.get(name);
1915
+ if (claim) claim.value = undefined;
1916
+ failures.push(new Error(`${name}: ${service.id} did not issue its required credential (${errorMessage(error)})`, { cause: error }));
1917
+ }
1785
1918
  }
1786
1919
  }
1787
1920
  if (started.url) {
@@ -1793,8 +1926,14 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
1793
1926
  } catch (error) {
1794
1927
  process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
1795
1928
  for (const name of wanted) { delete env[name]; unissued.add(name); }
1929
+ for (const name of required) {
1930
+ const claim = claims.get(name);
1931
+ if (claim) claim.value = undefined;
1932
+ failures.push(new Error(`${name}: ${service.id}'s required credential ask failed (${errorMessage(error)})`, { cause: error }));
1933
+ }
1796
1934
  }
1797
1935
  if (times) times.filled = performance.now();
1936
+ if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
1798
1937
  }
1799
1938
 
1800
1939
  function materializeConfig(config: WorldConfig, root: string): WorldConfig {
@@ -1906,6 +2045,8 @@ async function startColocatedServices(
1906
2045
  // the twins never read the application's own origin, nor a credential not yet issued
1907
2046
  for (const [key, value] of Object.entries(env)) if (value === APP_URL_ENV) delete env[key];
1908
2047
  dropUnissued(env);
2048
+ // the colocated twins' host runs the World's own servers: the application's NODE_ENV is not theirs (serviceProcessEnv)
2049
+ delete env.NODE_ENV;
1909
2050
  env.VOLTER_WORLD_BOOT_ID = boot.bootId;
1910
2051
  const spawnedAt = Date.now();
1911
2052
  // Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
@@ -1946,7 +2087,7 @@ async function startColocatedServices(
1946
2087
  });
1947
2088
  const ready = (async (): Promise<void> => {
1948
2089
  const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1949
- const hostTimes = await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch(async (error) => {
2090
+ const hostTimes = await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => startedTwins(log)), ended, boot, 'the co-located host', log).catch(async (error) => {
1950
2091
  if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1951
2092
  try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
1952
2093
  throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
@@ -2051,6 +2192,11 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2051
2192
  // instead of loading and rebuilding a second facts/host table on every empty up.
2052
2193
  if (packFactsPaths.length) createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
2053
2194
  const loaded = { path: found.path, config: materializeConfig(found.config, root) };
2195
+ // Before any output claim or lifecycle write, every saved alias fits the actual current pack.
2196
+ loaded.config.services = loaded.config.services.map(service => (service.type ?? 'twin') === 'twin'
2197
+ ? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor) : service);
2198
+ assertRequiredCredentialBindings(loaded.config, found.path);
2199
+ const required = requiredCredentialClaims(loaded.config);
2054
2200
  const name = options.name ?? loaded.config.id;
2055
2201
  const mode = options.mode ?? 'local';
2056
2202
  assertSafeWorldName(name);
@@ -2155,7 +2301,6 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2155
2301
  // Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
2156
2302
  if (existsSync(instanceFile)) {
2157
2303
  let prior: WorldInstance | undefined;
2158
- if (options.keepState) prior = readWorldBootRecordLocked(name, root);
2159
2304
  const priorRaw = readFileSync(instanceFile, 'utf8');
2160
2305
  const receiptPath = join(instance, 'stopped.json');
2161
2306
  const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
@@ -2163,6 +2308,13 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2163
2308
  || !Number.isFinite(Date.parse(receipt.stoppedAt))) {
2164
2309
  throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
2165
2310
  }
2311
+ // a resume reads the record its last boot completed; a boot that failed and was rolled back (the receipt above)
2312
+ // completed none, and the resume writes a complete one over the kept state (architecture, the boot record)
2313
+ let priorIncomplete = false;
2314
+ if (options.keepState) {
2315
+ try { prior = readWorldBootRecordLocked(name, root); }
2316
+ catch { priorIncomplete = true; }
2317
+ }
2166
2318
  if (!prior) { try { prior = JSON.parse(priorRaw) as WorldInstance; } catch { /* fresh boot needs no retained shape */ } }
2167
2319
  if (prior) {
2168
2320
  // A readable ceiling remains useful even when unrelated metadata is incomplete.
@@ -2175,7 +2327,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2175
2327
  }
2176
2328
  const ceiling = recordedWorldNetwork(permissionRecord);
2177
2329
  if (ceiling !== undefined) carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
2178
- } catch { if (options.keepState) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
2330
+ } catch { if (options.keepState && !priorIncomplete) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
2179
2331
  }
2180
2332
  // the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
2181
2333
  // same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
@@ -2277,7 +2429,10 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2277
2429
  const unissued = new Set<string>();
2278
2430
  writeProxyEnv(root, name, env);
2279
2431
 
2432
+ const requiredCredentials: NonNullable<WorldInstance['requiredCredentials']> = { version: 1,
2433
+ owners: Object.fromEntries([...required].map(([name, claim]) => [name, claim.service])) };
2280
2434
  const worldInstance: WorldInstance = {
2435
+ requiredCredentials,
2281
2436
  name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
2282
2437
  dirs: { instance, logs, data }, services, env, envFile, pidsFile,
2283
2438
  ...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
@@ -2352,14 +2507,15 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2352
2507
  // own sent and status says that twin's door.
2353
2508
  const origin = performance.now();
2354
2509
  const steps: Array<{ id: string; queued: number; times: CredentialAskTimes }> = [];
2355
- const asks = await Promise.all(host.services.map(async (started) => {
2510
+ // A descriptor refusal still settles every owned ask before rollback can retire its custody.
2511
+ const asks = await Promise.allSettled(host.services.map(async (started) => {
2356
2512
  const declared = colocated.find((s) => s.id === started.id);
2357
2513
  if (!declared) return undefined;
2358
2514
  const began = performance.now();
2359
2515
  const times: CredentialAskTimes = { ...(declared.package ? { twin: declared.package } : {}) };
2360
2516
  steps.push({ id: started.id, queued: began, times });
2361
2517
  const own: Record<string, string> = { ...before };
2362
- await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), times);
2518
+ await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), required, !options.keepState, times);
2363
2519
  return { id: started.id, ms: Math.round(performance.now() - began), own };
2364
2520
  }));
2365
2521
  const at = (time: number | undefined): string => time === undefined ? '-' : `+${Math.round(time - origin)}`;
@@ -2368,13 +2524,17 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2368
2524
  .sort((left, right) => (right.times.filled ?? 0) - (left.times.filled ?? 0))
2369
2525
  .map(({ id, queued, times }) => `${id}${times.twin ? ` (${times.twin})` : ''} ${at(queued)}/${at(times.door)}/${at(times.sent)}/${at(times.headers)}/${at(times.answered)}/${at(times.filled)}`).join('; ')}`);
2370
2526
  const asked: Array<{ id: string; ms: number }> = [];
2371
- for (const ask of asks) {
2527
+ const failures: unknown[] = [];
2528
+ for (const result of asks) {
2529
+ if (result.status === 'rejected') { failures.push(result.reason); continue; }
2530
+ const ask = result.value;
2372
2531
  if (!ask) continue;
2373
2532
  for (const [key, value] of Object.entries(ask.own)) if (before[key] !== value) env[key] = value;
2374
2533
  for (const key of Object.keys(before)) if (!Object.hasOwn(ask.own, key)) delete env[key];
2375
2534
  asked.push({ id: ask.id, ms: ask.ms });
2376
2535
  }
2377
2536
  if (asked.length) boot.progress(`co-located twins: each asked for its credentials at once, slowest first: ${[...asked].sort((left, right) => right.ms - left.ms).slice(0, 6).map((each) => `${each.id} ${each.ms} ms`).join(', ')}; ${asked.length} asked, ${Math.max(...asked.map((each) => each.ms))} ms the longest`);
2537
+ if (failures.length) throw new AggregateError(failures, `World credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
2378
2538
  writeProxyEnv(root, name, env);
2379
2539
  if (colocated.length) boot.progress('co-located twins: credentials issued, proxy environment written');
2380
2540
  };
@@ -2387,7 +2547,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2387
2547
  for (const service of loaded.config.services) {
2388
2548
  if (services[service.id] || !ownInfrastructureService(service)) continue;
2389
2549
  boot.progress(`${service.id}: starting beside the host (the runtime's own infrastructure)`);
2390
- const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot);
2550
+ const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot, required);
2391
2551
  starting.catch((error: unknown) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
2392
2552
  early.set(service.id, starting);
2393
2553
  }
@@ -2429,18 +2589,20 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2429
2589
  attemptProxy();
2430
2590
  boot.signal.throwIfAborted();
2431
2591
  if (!starting) boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
2432
- const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
2592
+ const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot, required));
2433
2593
  // An early start published its teardown when it began; its record moves to its declared position.
2434
2594
  if (starting) delete services[service.id];
2435
2595
  recordService(started);
2436
2596
  recordPidsSoFar();
2437
2597
  Object.assign(env, started.env);
2438
- await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id));
2598
+ await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id), required, !options.keepState);
2439
2599
  writeProxyEnv(root, name, env);
2440
2600
  boot.progress(`${service.id}: started, its environment merged`);
2441
2601
  }
2442
2602
  if (colocatedReady) await colocatedReady();
2443
- detachBootSignals();
2603
+ // An abort records its reason while the final owned credential asks settle. No later service may remain
2604
+ // to observe it, so completion itself must reject it before publishing a successful environment.
2605
+ boot.signal.throwIfAborted();
2444
2606
  // a name a service of no twin in this World issues (a config edited after init) is left unset, and said
2445
2607
  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`);
2446
2608
  for (const [name] of issuing) unissued.add(name);
@@ -2450,6 +2612,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2450
2612
  // next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
2451
2613
  // config test below exercises exactly this.
2452
2614
  attemptProxy();
2615
+ assertRequiredCredentials(required, env, 'World environment publication');
2453
2616
  // Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
2454
2617
  // lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
2455
2618
  // Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
@@ -2458,6 +2621,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2458
2621
  process.stderr.write(`${warning}\n`);
2459
2622
  }
2460
2623
 
2624
+ // This can await the browser materializer's import. Keep boot cancellation attached through that work,
2625
+ // and do not persist completed credential claims or publish the environment until it has finished.
2626
+ await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
2627
+ boot.signal.throwIfAborted();
2628
+ assertRequiredCredentials(required, env, 'World environment publication');
2629
+ requiredCredentials.valuesSha256 = requiredCredentialValuesDigest(required);
2461
2630
  mkdirSync(dirname(envFile), { recursive: true });
2462
2631
  // Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
2463
2632
  // the mode the caller chose (a shared env file may be read under another uid).
@@ -2468,11 +2637,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
2468
2637
  delete worldInstance.previousEnvOutput;
2469
2638
  saveWorldInstance(worldInstance);
2470
2639
  boot.progress(`environment written: ${envFile}`);
2471
- await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
2472
2640
  writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
2473
2641
  saveWorldInstance(worldInstance);
2474
2642
  const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
2643
+ boot.signal.throwIfAborted();
2475
2644
  handoffWorldLifecycle(root, name, servicePids);
2645
+ detachBootSignals();
2476
2646
  boot.progress(`returning: the World is up; ${lag.account()}`);
2477
2647
  if (mode === 'share') {
2478
2648
  try {
@@ -2692,6 +2862,24 @@ function killHeldSurvivorsSync(child: ReturnType<typeof spawn>, graceMs: number)
2692
2862
  * purge leaves that lock alone; its owner's finally releases it, so waiting claimants never
2693
2863
  * enter through a replacement lock while the original is still held.
2694
2864
  */
2865
+ /** A purged World's env output outside its folder (architecture, "World environment exports are outputs"): removed with
2866
+ * the record that proved it the World's own, when it still holds exactly the World's exports as a single-link regular
2867
+ * file and no other World of the root records that path; anything else stays. */
2868
+ function purgeEnvOutput(root: string, name: string, purged: string, instance: WorldInstance): void {
2869
+ const path = typeof instance.envFile === 'string' ? resolve(instance.envFile) : undefined;
2870
+ if (!path || !relative(purged, path).startsWith('..')) return;
2871
+ const others = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
2872
+ const shared = others.some((world) => {
2873
+ try { return resolve((JSON.parse(readFileSync(join(instanceDir(root, world), 'instance.json'), 'utf8')) as WorldInstance).envFile) === path; } catch { return false; }
2874
+ });
2875
+ if (shared) return;
2876
+ try {
2877
+ const named = lstatSync(path);
2878
+ if (!named.isFile() || named.nlink !== 1 || !readFileSync(path).equals(Buffer.from(envFileContents(instance.env)))) return;
2879
+ rmSync(path);
2880
+ } catch { /* no output, or one the World cannot read: it stays */ }
2881
+ }
2882
+
2695
2883
  function purgeInstanceDir(root: string, name: string): string {
2696
2884
  assertSafeWorldName(name);
2697
2885
  const base = worldBaseDir(root);
@@ -2922,6 +3110,7 @@ async function downWorldClaimed(
2922
3110
  if (!options.purge) return result;
2923
3111
  const purged = purgeInstanceDir(resolvedRoot, name);
2924
3112
  if (purged && instance) clearBrowserFiles!(instance.dirs.data, resolvedRoot);
3113
+ if (purged && instance) purgeEnvOutput(resolvedRoot, name, purged, instance);
2925
3114
  return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
2926
3115
  }));
2927
3116
  }
@@ -3597,6 +3786,14 @@ export async function doctorWorld(name: string, options: { root?: string; verify
3597
3786
  checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
3598
3787
  ? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
3599
3788
  : 'declared boot services have instance records' });
3789
+ // a vendor-backed root whose vendor rotated its refresh token while the seal failed (world-core head.ts
3790
+ // unsealedMarkerOf): it renews only inside the process that held the new one, so a person must seal it again
3791
+ const credentialDir = join(root, stateDirName(), 'credentials');
3792
+ for (const file of existsSync(credentialDir) ? readdirSync(credentialDir).filter((f) => f.endsWith('.unsealed.json')).sort() : []) {
3793
+ let said = '';
3794
+ try { const r = JSON.parse(readFileSync(join(credentialDir, file), 'utf8')) as { field?: string; at?: string; cause?: string }; said = `: ${r.field ?? 'a field'} rotated at ${r.at ?? '?'} was not sealed (${r.cause ?? 'no cause recorded'})`; } catch { /* the marker's presence is the finding */ }
3795
+ checks.push({ id: `root-${file.replace(/\.unsealed\.json$/, '')}`, ok: false, message: `the root's credential lost a rotation${said}; seal it again before its process ends` });
3796
+ }
3600
3797
 
3601
3798
  if (instance.lifecycle) {
3602
3799
  checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
@@ -3888,8 +4085,9 @@ export function listWorlds(root = process.cwd()): Array<{ name: string; running:
3888
4085
  .sort((a, b) => a.name.localeCompare(b.name));
3889
4086
  }
3890
4087
 
3891
- function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv } {
4088
+ function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv; required: RequiredCredentialClaims } {
3892
4089
  const status = instance ?? statusWorld(name, root);
4090
+ const required = recordedRequiredCredentialClaims(status);
3893
4091
  const sealed = status.env.VOLTER_WORLD_MODE === 'sealed' || status.env[WORLD_NETWORK_POLICY_ENV] !== undefined;
3894
4092
  const baseEnv = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
3895
4093
  let proxyEnv: Record<string, string> = {};
@@ -3904,8 +4102,10 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false, in
3904
4102
  if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
3905
4103
  // Keep env-only redirect if the ambient proxy cannot start.
3906
4104
  }
3907
- return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
3908
- VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' }) };
4105
+ const env = withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
4106
+ VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' });
4107
+ assertRequiredCredentials(required, env, 'command attachment');
4108
+ return { instance: status, env, required };
3909
4109
  }
3910
4110
 
3911
4111
  /** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
@@ -3929,10 +4129,12 @@ export function withAppUrl<E extends Record<string, string | undefined>>(name: s
3929
4129
  export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean; env?: Record<string, string> } = {}): Promise<number> {
3930
4130
  if (command.length === 0) throw new Error('Missing command after --');
3931
4131
  const attached = worldAttachedCommandEnv(name, root, options.verbose);
4132
+ const env = { ...attached.env, ...(options.env ?? {}) };
4133
+ assertRequiredCredentials(attached.required, env, 'SDK command environment');
3932
4134
  return new Promise((resolveExit) => {
3933
4135
  const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
3934
4136
  cwd: resolve(options.cwd ?? root),
3935
- env: { ...attached.env, ...(options.env ?? {}) },
4137
+ env,
3936
4138
  stdio: 'inherit',
3937
4139
  });
3938
4140
  const forward = (signal: NodeJS.Signals) => {
@@ -3998,6 +4200,8 @@ export async function attachWorld(name: string, command: string[], root = proces
3998
4200
  // Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
3999
4201
  if (!current.running) throw new Error(`World "${name}" is not running while attaching: none of its recorded processes is live and its own (recorded pids ${readPids(current.pidsFile).join(', ') || 'none'}; live and its own: none); ${describeWorldLifecycle(resolvedRoot, name, current.createdAt)})`);
4000
4202
  if (current.createdAt !== attached.instance.createdAt) throw new Error(`World "${name}" changed while attaching: its instance was created ${attached.instance.createdAt} when the command's environment was read and ${current.createdAt} now`);
4203
+ // A supplied instance is a snapshot, not authority to erase the current boot's required bindings.
4204
+ assertRequiredCredentials(recordedRequiredCredentialClaims(current), attached.env, 'registered command attachment');
4001
4205
  // Attaching to a World whose services died is allowed (a command may not need them), but never silent.
4002
4206
  for (const [id, state] of Object.entries(current.serviceStates)) {
4003
4207
  if (state === 'running' || state === 'external') continue;
@@ -4139,13 +4343,21 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
4139
4343
  * TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
4140
4344
  * since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
4141
4345
  export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
4346
+ const { shell, cwd, env } = worldShellContext(name, root);
4347
+ return { shell, cwd, env };
4348
+ }
4349
+
4350
+ /** Keep the boot's required proof with the shell environment until its proxy transformations have finished. */
4351
+ function worldShellContext(name: string, root: string): { shell: string; cwd: string; env: Record<string, string>; required: RequiredCredentialClaims } {
4142
4352
  const status = statusWorld(name, root);
4143
4353
  if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
4144
- return {
4145
- shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
4146
- cwd: resolve(root),
4147
- env: withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>),
4148
- };
4354
+ const required = recordedRequiredCredentialClaims(status);
4355
+ const env = withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>);
4356
+ assertRequiredCredentials(required, env, 'shell environment');
4357
+ // Windows has no /bin/bash: its shell is the one ComSpec names (cmd.exe), kept by the Windows baseline (admittedEnv).
4358
+ const inherited = admittedEnv(stripEnvOf(status.env));
4359
+ const fallback = process.platform === 'win32' ? (inherited.ComSpec ?? inherited.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe') : '/bin/bash';
4360
+ return { shell: status.env.SHELL || inherited.SHELL || fallback, cwd: resolve(root), env, required };
4149
4361
  }
4150
4362
 
4151
4363
  /** Drop into an interactive subshell with the world active — the contained sibling of `activate`
@@ -4155,7 +4367,7 @@ export function worldShellEnv(name: string, root = process.cwd()): { shell: stri
4155
4367
  * land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
4156
4368
  * trusted ONLY via this subshell's env — never installed system-wide. See docs/guides/route-a-cli-through-the-world.md. */
4157
4369
  export async function shellWorld(name: string, root = process.cwd()): Promise<number> {
4158
- const { shell, cwd, env } = worldShellEnv(name, root);
4370
+ const { shell, cwd, env, required } = worldShellContext(name, root);
4159
4371
  const resolvedRoot = resolve(root);
4160
4372
  let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
4161
4373
  const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
@@ -4176,13 +4388,25 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
4176
4388
  if (sealed) throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
4177
4389
  process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (env-only; install openssl for ambient https redirect). type 'exit' to leave.\n`);
4178
4390
  }
4391
+ let failed = false;
4392
+ let failure: unknown;
4179
4393
  try {
4394
+ assertRequiredCredentials(required, env, 'interactive shell');
4180
4395
  const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
4181
- return result.status ?? 0;
4396
+ if (result.error) throw result.error;
4397
+ if (result.signal) throw new Error(`World shell ended by ${result.signal}`);
4398
+ if (result.status === null) throw new Error('World shell returned no exit status');
4399
+ return result.status;
4400
+ } catch (error) {
4401
+ failed = true; failure = error;
4402
+ throw error;
4182
4403
  } finally {
4183
4404
  if (proxy) {
4184
- await proxy.close();
4185
- tearDownCa(tlsDir(resolvedRoot, name));
4405
+ try { await proxy.close(); tearDownCa(tlsDir(resolvedRoot, name)); }
4406
+ catch (cleanup) {
4407
+ if (failed) throw new AggregateError([failure, cleanup], `World shell failed (${errorMessage(failure)}); proxy cleanup also failed (${errorMessage(cleanup)})`, { cause: failure });
4408
+ throw cleanup;
4409
+ }
4186
4410
  }
4187
4411
  }
4188
4412
  }
@@ -4446,6 +4670,7 @@ function clearWorldProxyFiles(root: string, name: string): void {
4446
4670
  */
4447
4671
  export function activateScript(name: string, root = process.cwd()): string {
4448
4672
  const status = statusWorld(name, root);
4673
+ const required = recordedRequiredCredentialClaims(status);
4449
4674
  if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
4450
4675
  const baseEnv = status.env ?? {};
4451
4676
  // Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
@@ -4465,6 +4690,8 @@ export function activateScript(name: string, root = process.cwd()): string {
4465
4690
  // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
4466
4691
  }
4467
4692
  env = withAppUrl(name, root, env);
4693
+ // Exports follow the bookkeeping assignments; the final prompt assignment is the one later value change.
4694
+ assertRequiredCredentials(required, { ...env, PS1: `(world:${name}) ${env.PS1 ?? ''}` }, 'shell activation including its prompt');
4468
4695
  const keys = Object.keys(env).sort();
4469
4696
  const lines: string[] = [
4470
4697
  `# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,