@volter/world-runtime 3.0.166 → 3.0.167

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 +5 -1
  18. package/dist/src/index.js +4 -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 +309 -58
  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 +5 -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 +270 -44
  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
@@ -212,6 +212,7 @@ export type WorldServiceConfig = {
212
212
  path: string;
213
213
  body: Record<string, unknown>;
214
214
  fill: Record<string, string>;
215
+ required?: string[];
215
216
  };
216
217
  /**
217
218
  * The twin PACKAGE this service boots from (`@volter/twin-<vendor>`), resolved from the world
@@ -528,6 +529,13 @@ export type WorldInstance = {
528
529
  };
529
530
  services: Record<string, WorldServiceInstance>;
530
531
  env: Record<string, string>;
532
+ /** Required owners fixed at this boot; the digest binds their actual scalar responses in env without duplicating values.
533
+ * Omitted digest means issuance has not finished. Absent metadata is readable for cleanup, but not a live application declaration. */
534
+ requiredCredentials?: {
535
+ version: 1;
536
+ owners: Record<string, string>;
537
+ valuesSha256?: string;
538
+ };
531
539
  /** Always written at boot; optional only so cleanup can read incomplete legacy records. */
532
540
  networkCeiling?: WorldNetwork | 'unrestricted';
533
541
  bootServices?: string[];
@@ -567,6 +575,8 @@ export type WorldInstance = {
567
575
  packFacts?: string[];
568
576
  };
569
577
  export declare function isEnvironmentName(value: unknown): value is string;
578
+ /** Required names belong to one twin's exact pending reference, with no competing output or local override. */
579
+ export declare function assertRequiredCredentialBindings(config: WorldConfig, path: string): void;
570
580
  export declare const DEFAULT_WORLD_SELECTION: WorldSelection;
571
581
  export declare const LEGACY_WORLD_SELECTION: WorldSelection;
572
582
  export declare function assertWorldSelection(value: unknown, path: string): asserts value is WorldSelection;
@@ -1,4 +1,5 @@
1
1
  import { errorMessage } from "./error-message.js";
2
+ import { isScalarCredentialField } from "./credential-door.js";
2
3
  import { normalizeWorldNetwork } from '@volter/world-core/network-policy';
3
4
  export const WORLD_SCHEMA_VERSION = 2;
4
5
  export const WORLD_USAGES = ['application', 'dependencies', 'build', 'deployment'];
@@ -179,8 +180,8 @@ function assertLegacyWorldConfig(value, path) {
179
180
  const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
180
181
  if (exports.some(name => name !== undefined && !isEnvironmentName(name)))
181
182
  throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
182
- if (service.issues !== undefined && (!record(service.issues) || typeof service.issues.path !== 'string' || !record(service.issues.body) || !environmentMap(service.issues.fill)))
183
- throw new Error(`Service ${service.id} issues must define path, body and string→string fill in ${path}`);
183
+ if (service.issues !== undefined)
184
+ readCredentialDoor(service.issues, service.id, path);
184
185
  if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY'))
185
186
  throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
186
187
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id))
@@ -338,6 +339,7 @@ function assertLegacyWorldConfig(value, path) {
338
339
  }
339
340
  }
340
341
  }
342
+ assertRequiredCredentialBindings(config, path);
341
343
  return config;
342
344
  }
343
345
  function record(value) {
@@ -348,6 +350,72 @@ function assertKeys(value, allowed, what, path, allowComments = false) {
348
350
  if (unexpected.length)
349
351
  throw new Error(`${what} has unknown field(s) ${unexpected.join(', ')} in ${path}`);
350
352
  }
353
+ /** Credential metadata is read completely before normalization or serialization can discard a field. */
354
+ function readCredentialDoor(value, service, path) {
355
+ const what = `Service ${service} issues`;
356
+ if (!record(value))
357
+ throw new Error(`${what} must be an object in ${path}`);
358
+ assertKeys(value, ['path', 'body', 'fill', 'required'], what, path);
359
+ if (!Object.hasOwn(value, 'path') || typeof value.path !== 'string' || value.path === ''
360
+ || !Object.hasOwn(value, 'body') || !record(value.body)
361
+ || !Object.hasOwn(value, 'fill') || !record(value.fill)) {
362
+ throw new Error(`${what} must define non-empty path, object body and string→string fill in ${path}`);
363
+ }
364
+ const fill = [];
365
+ for (const [name, field] of Object.entries(value.fill)) {
366
+ if (!isEnvironmentName(name) || typeof field !== 'string')
367
+ throw new Error(`${what} fill must map environment names to strings in ${path}`);
368
+ fill.push([name, field]);
369
+ }
370
+ const required = [];
371
+ if (Object.hasOwn(value, 'required')) {
372
+ if (!Array.isArray(value.required))
373
+ throw new Error(`${what} required must be an array in ${path}`);
374
+ const seen = new Set();
375
+ for (const name of value.required) {
376
+ if (!isEnvironmentName(name) || seen.has(name))
377
+ throw new Error(`${what} required must contain unique environment names in ${path}`);
378
+ const field = value.fill[name];
379
+ if (!Object.hasOwn(value.fill, name) || typeof field !== 'string' || !isScalarCredentialField(field)) {
380
+ throw new Error(`${what} required ${name} must name its direct scalar fill in ${path}`);
381
+ }
382
+ seen.add(name);
383
+ required.push(name);
384
+ }
385
+ }
386
+ return { path: value.path, body: value.body, fill: Object.fromEntries(fill),
387
+ ...(Object.hasOwn(value, 'required') ? { required } : {}) };
388
+ }
389
+ /** Required names belong to one twin's exact pending reference, with no competing output or local override. */
390
+ export function assertRequiredCredentialBindings(config, path) {
391
+ const owners = new Map();
392
+ for (const service of config.services) {
393
+ if (service.issues === undefined)
394
+ continue;
395
+ const door = readCredentialDoor(service.issues, service.id, path);
396
+ if (door.required !== undefined && (service.type ?? 'twin') !== 'twin') {
397
+ throw new Error(`Service ${service.id} required credential bindings belong to a twin in ${path}`);
398
+ }
399
+ for (const name of door.required ?? []) {
400
+ if (owners.has(name))
401
+ throw new Error(`Required credential ${name} has competing issuers in ${path}`);
402
+ if (!Object.hasOwn(config.env ?? {}, name) || config.env?.[name] !== `${ISSUE_ENV_PREFIX}${service.id}`) {
403
+ throw new Error(`Required credential ${name} must be exactly ${ISSUE_ENV_PREFIX}${service.id} in ${path}`);
404
+ }
405
+ owners.set(name, service.id);
406
+ }
407
+ }
408
+ for (const service of config.services) {
409
+ const endpoints = new Set([service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}),
410
+ ...Object.keys(service.cliRedirect ?? {}), ...(service.external?.discover ?? []).map(entry => entry.as)]);
411
+ for (const [name, owner] of owners) {
412
+ if (endpoints.has(name) || Object.hasOwn(service.env ?? {}, name)
413
+ || (service.id !== owner && Object.hasOwn(service.issues?.fill ?? {}, name))) {
414
+ throw new Error(`Required credential ${name} owned by ${owner} conflicts with service ${service.id} in ${path}`);
415
+ }
416
+ }
417
+ }
418
+ }
351
419
  export const DEFAULT_WORLD_SELECTION = {
352
420
  include: [{ usage: 'application' }],
353
421
  exclude: [],
@@ -389,7 +457,7 @@ function serviceV2ToLegacy(value, path) {
389
457
  assertKeys(source, ['package', 'version'], 'service.source', path);
390
458
  assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
391
459
  assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
392
- assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
460
+ assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover', 'issues'], 'service.bindings', path);
393
461
  const process = execution.process === undefined ? {} : execution.process;
394
462
  const environment = execution.environment === undefined ? {} : execution.environment;
395
463
  if (!record(process) || !record(environment))
@@ -458,6 +526,7 @@ function serviceV2ToLegacy(value, path) {
458
526
  ...(bindings.injectEnv === undefined ? {} : { injectEnv: bindings.injectEnv }),
459
527
  ...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates }),
460
528
  ...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect }),
529
+ ...(!Object.hasOwn(bindings, 'issues') ? {} : { issues: readCredentialDoor(bindings.issues, value.id, path) }),
461
530
  ...(value.root === undefined ? {} : { root: value.root }),
462
531
  ...(external === undefined ? {} : { external }),
463
532
  };
@@ -611,6 +680,8 @@ export function assertWorldConfig(value, path) {
611
680
  return normalizeV2(value, path);
612
681
  }
613
682
  const legacy = assertLegacyWorldConfig(value, path);
683
+ if (legacy.services.some(service => service.issues?.required !== undefined))
684
+ throw new Error(`Required credential bindings require a format-2 World manifest in ${path}`);
614
685
  return { ...legacy, manifestVersion: 1 };
615
686
  }
616
687
  export function worldSelectionIncludes(selection, vendor, usage) {
@@ -645,6 +716,7 @@ function serviceToV2(service) {
645
716
  ...(service.ready === undefined ? {} : { ready: service.ready }),
646
717
  };
647
718
  const bindings = {
719
+ ...(service.issues === undefined ? {} : { issues: readCredentialDoor(service.issues, service.id, 'World serialization') }),
648
720
  ...(service.injectEnv === undefined ? {} : { injectEnv: service.injectEnv }),
649
721
  ...(service.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: service.injectEnvTemplates }),
650
722
  ...(service.cliRedirect === undefined ? {} : { cliRedirect: service.cliRedirect }),
@@ -667,6 +739,7 @@ function serviceToV2(service) {
667
739
  }
668
740
  /** Serialize normalized intent as the committed format-2 manifest. */
669
741
  export function worldConfigDocument(config, selection = config.selection ?? DEFAULT_WORLD_SELECTION) {
742
+ assertLegacyWorldConfig({ ...config, manifestVersion: 2 }, 'World serialization');
670
743
  return {
671
744
  schemaVersion: WORLD_SCHEMA_VERSION,
672
745
  metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
package/dist/src/serve.js CHANGED
@@ -8,13 +8,16 @@
8
8
  import { timingSafeEqual } from 'node:crypto';
9
9
  import { createServer as createHttpServer } from 'node:http';
10
10
  import { deriveWorldManifest } from "./attach.js";
11
- import { statusWorld } from "./runtime.js";
11
+ import { refuseRequiredCredentialTransport, statusWorld } from "./runtime.js";
12
+ import { errorMessage } from "./error-message.js";
12
13
  import { worldAttachmentNetworkEnv, worldStrictEgress } from '@volter/world-core/network-policy';
13
14
  export const MANIFEST_PATH = '/.well-known/volter-world';
14
15
  /** The manifest a REMOTE attacher sees: every vendor behind the one advertised
15
16
  * door. The serving side keeps routing against its own loopback env. */
16
17
  export function advertiseWorldManifest(name, root, advertisedOrigin) {
17
- const local = deriveWorldManifest(name, statusWorld(name, root).env);
18
+ const status = statusWorld(name, root);
19
+ refuseRequiredCredentialTransport(status, 'remote WorldManifest publication');
20
+ const local = deriveWorldManifest(name, status.env);
18
21
  const vendors = {};
19
22
  // /__vendor/<id> keeps the fetch flow routable through the one door (the
20
23
  // injector's fetch patch rewrites URLs wholesale and loses the vendor Host);
@@ -40,8 +43,17 @@ export async function startManifestServer(options) {
40
43
  response.end('volter-world: GET ' + MANIFEST_PATH + '\n');
41
44
  return;
42
45
  }
46
+ let body;
47
+ try {
48
+ body = `${JSON.stringify(options.manifest(), null, 2)}\n`;
49
+ }
50
+ catch (error) {
51
+ response.writeHead(503, { 'content-type': 'text/plain' });
52
+ response.end(`World manifest unavailable: ${errorMessage(error)}\n`);
53
+ return;
54
+ }
43
55
  response.writeHead(200, { 'content-type': 'application/json' });
44
- response.end(`${JSON.stringify(options.manifest(), null, 2)}\n`);
56
+ response.end(body);
45
57
  });
46
58
  await new Promise((resolveListen, reject) => {
47
59
  server.once('error', reject);
@@ -64,14 +76,22 @@ export async function fetchRemoteManifest(ref, options = {}) {
64
76
  if (!response.ok)
65
77
  throw new Error(`remote world manifest: ${url} → ${response.status}`);
66
78
  const manifest = (await response.json());
67
- if (typeof manifest.name !== 'string' || typeof manifest.vendors !== 'object' || manifest.vendors === null) {
79
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
80
+ || typeof manifest.name !== 'string' || typeof manifest.vendors !== 'object' || manifest.vendors === null) {
68
81
  throw new Error(`remote world manifest: ${url} returned no manifest`);
69
82
  }
83
+ refuseRequiredRemoteManifest(manifest);
70
84
  return manifest;
71
85
  }
86
+ /** Required credentials have no approved remote transport; their field must never be silently ignored. */
87
+ function refuseRequiredRemoteManifest(manifest) {
88
+ if (Object.hasOwn(manifest, 'requiredCredentials'))
89
+ throw new Error('Remote WorldManifest required credential transport is unsupported; refusing attachment');
90
+ }
72
91
  /** The env a remote env-attachment exports: vendor twin URLs (the advertised
73
92
  * door), suggested fake credentials, CA trust, and the injector preload. */
74
93
  export function remoteAttachEnv(manifest, options) {
94
+ refuseRequiredRemoteManifest(manifest);
75
95
  const env = { ...manifest.env,
76
96
  ...worldAttachmentNetworkEnv(options.worldRef ?? manifest.name, manifest.network, options.callerEnv ?? process.env, worldStrictEgress(manifest.env)),
77
97
  };
@@ -51,7 +51,8 @@ export type MountedWorld = {
51
51
  served: string;
52
52
  root: string;
53
53
  readonly token: string;
54
- readonly readToken: string;
54
+ readonly readToken: string; /** whether its boot has finished: until then its twins are not known */
55
+ ready: () => boolean;
55
56
  twins: () => string[];
56
57
  rotate: () => {
57
58
  token: string;
@@ -50,6 +50,7 @@ import { describeActionFields, resourceBlobFile } from '@volter/world-core/runti
50
50
  import { worldLedgers } from "./changeset.js";
51
51
  import { clockFile, downWorld, instanceDir, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from "./runtime.js";
52
52
  import { assertChangesetRules } from "./schema.js";
53
+ import { forwardableHeaders } from "./forwardable-headers.js";
53
54
  export const TOKEN_HEADER = 'x-volter-token';
54
55
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
55
56
  export const TWINS_KEY_HEADER = 'x-twins-key';
@@ -237,7 +238,7 @@ export async function mountWorld(name, opts = {}) {
237
238
  return null;
238
239
  // the body as bytes, none when empty: an empty body streamed is sent chunked with no last chunk (Bun 1.2), and the
239
240
  // twin's server reads the next request on the connection as its continuation (HPE_INVALID_CHUNK_SIZE)
240
- return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: request.headers, ...(body?.byteLength ? { body } : {}), redirect: 'manual' }); };
241
+ return async (request) => { const at = new URL(request.url); const body = request.body ? new Uint8Array(await request.arrayBuffer()) : undefined; return fetch(`${target.replace(/\/+$/, '')}${at.pathname}${at.search}`, { method: request.method, headers: forwardableHeaders(request.headers), ...(body?.byteLength ? { body } : {}), redirect: 'manual' }); };
241
242
  },
242
243
  };
243
244
  const world = new WorldDoors(name, worldRoot, configRef, served, host, held('token') ?? mintToken('tok_'), held('token.read') ?? mintToken('tok_r_'));
@@ -248,6 +249,7 @@ export async function mountWorld(name, opts = {}) {
248
249
  name, served, root: worldRoot,
249
250
  maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
250
251
  get token() { return world.token; }, get readToken() { return world.readToken; },
252
+ ready: () => host.ready(),
251
253
  twins: () => world.twins(),
252
254
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
253
255
  handle: (request) => world.handle(request),
@@ -1359,7 +1361,7 @@ export class WorldDoors {
1359
1361
  const enforced = browser && grant?.scope === 'read' && await this.enforcesRead(vendor, twin, url.origin);
1360
1362
  if (browser && grant?.scope === 'read' && !reads && !enforced)
1361
1363
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1362
- const headers = browser ? new Headers(request.headers) : new Headers();
1364
+ const headers = browser ? forwardableHeaders(request.headers) : new Headers();
1363
1365
  for (const key of [TOKEN_HEADER, TWINS_KEY_HEADER, CALLER_HEADER, ORIGINAL_PATH_HEADER, READ_ONLY_HEADER, 'host', TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, TWIN_SITE_HEADER])
1364
1366
  headers.delete(key);
1365
1367
  for (const name of ['accept', 'accept-language', 'if-none-match', 'range', 'user-agent']) {
@@ -1617,7 +1619,7 @@ export class WorldDoors {
1617
1619
  const enforced = scope === 'read' && (await this.enforcesRead(vendor, twin, url.origin));
1618
1620
  if (scope === 'read' && !reads && !enforced)
1619
1621
  return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
1620
- const headers = new Headers(request.headers);
1622
+ const headers = forwardableHeaders(request.headers);
1621
1623
  headers.delete(TOKEN_HEADER);
1622
1624
  headers.delete(TWINS_KEY_HEADER);
1623
1625
  headers.delete('host');
@@ -1725,7 +1727,7 @@ export class WorldDoors {
1725
1727
  async forward(request, url, rest, name, origin) {
1726
1728
  // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
1727
1729
  // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
1728
- const caller = new Headers(request.headers);
1730
+ const caller = forwardableHeaders(request.headers);
1729
1731
  for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, TWIN_SITES_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
1730
1732
  caller.delete(h);
1731
1733
  // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
@@ -1,3 +1,7 @@
1
- /** Resolve only the known npm Bun layout, never execute or interpret a command
2
- * script. Keep explicit paths, other commands and non-Windows behavior intact. */
3
- export declare function serviceExecutable(command: string, env: NodeJS.ProcessEnv): string;
1
+ /** The program and arguments a service command runs as. Explicit paths, commands with an extension and every
2
+ * non-Windows command keep their meaning; a command that cannot be resolved is spawned as given, and fails through
3
+ * the ordinary recorded spawn error. */
4
+ export declare function serviceInvocation(command: string, args: string[], env: NodeJS.ProcessEnv): {
5
+ command: string;
6
+ args: string[];
7
+ };
@@ -1,7 +1,8 @@
1
- // Process services run without a shell. On Windows npm's Bun command is a .cmd
2
- // shim, whose native package executable must be selected before Node spawns it.
1
+ // Process services run without a shell. On Windows a bare command name is found the way a shell would find it, and an
2
+ // npm command shim (`.cmd`, which Node's shell-free spawn cannot execute) runs as what it launches: its package's
3
+ // script under Node, or the native executable of npm's Bun.
3
4
  import { readFileSync, statSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
5
+ import { isAbsolute, join, resolve } from 'node:path';
5
6
  const file = (path) => {
6
7
  try {
7
8
  return statSync(path).isFile();
@@ -10,29 +11,59 @@ const file = (path) => {
10
11
  return false;
11
12
  }
12
13
  };
13
- /** Resolve only the known npm Bun layout, never execute or interpret a command
14
- * script. Keep explicit paths, other commands and non-Windows behavior intact. */
15
- export function serviceExecutable(command, env) {
16
- if (process.platform !== 'win32' || command.toLowerCase() !== 'bun')
17
- return command;
14
+ /** npm's Bun package: `bun.cmd` beside `node_modules/bun/bin/bun.exe`. */
15
+ function npmBun(directory) {
16
+ const installed = resolve(directory, 'node_modules', 'bun');
17
+ const executable = join(installed, 'bin', 'bun.exe');
18
+ if (!file(executable))
19
+ return undefined;
20
+ try {
21
+ const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8'));
22
+ if (manifest.name === 'bun' && manifest.bin?.bun === 'bin/bun.exe')
23
+ return executable;
24
+ }
25
+ catch { /* An incomplete installation follows the ordinary spawn-error path. */ }
26
+ return undefined;
27
+ }
28
+ /** The script an npm cmd-shim launches (`"%dp0%\node_modules\<package>\bin\<tool>.js" %*`), when it names one under
29
+ * its own directory. Only the shim's text is read; nothing in it is executed. */
30
+ function shimScript(shim) {
31
+ let text;
32
+ try {
33
+ text = readFileSync(shim, 'utf8');
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ const match = /"%dp0%\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text) ?? /"%~dp0\\([^"%]+\.[cm]?js)"\s+%\*/i.exec(text);
39
+ if (!match)
40
+ return undefined;
41
+ const script = resolve(shim, '..', match[1]);
42
+ return file(script) ? script : undefined;
43
+ }
44
+ /** The program and arguments a service command runs as. Explicit paths, commands with an extension and every
45
+ * non-Windows command keep their meaning; a command that cannot be resolved is spawned as given, and fails through
46
+ * the ordinary recorded spawn error. */
47
+ export function serviceInvocation(command, args, env) {
48
+ if (process.platform !== 'win32' || !command || isAbsolute(command) || /[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command))
49
+ return { command, args };
18
50
  const path = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] ?? '';
19
- const directories = [process.cwd(), ...path.split(';').map((value) => value.replace(/^"(.*)"$/, '$1'))];
51
+ const directories = [process.cwd(), ...path.split(';').filter(Boolean).map((value) => value.replace(/^"(.*)"$/, '$1'))];
20
52
  for (const directory of directories) {
21
- const native = resolve(directory, 'bun.exe');
53
+ const native = resolve(directory, `${command}.exe`);
22
54
  if (file(native))
23
- return native;
24
- if (!file(resolve(directory, 'bun.cmd')))
25
- continue;
26
- const installed = resolve(directory, 'node_modules', 'bun');
27
- const executable = join(installed, 'bin', 'bun.exe');
28
- if (!file(executable))
55
+ return { command: native, args };
56
+ const shim = resolve(directory, `${command}.cmd`);
57
+ if (!file(shim))
29
58
  continue;
30
- try {
31
- const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8'));
32
- if (manifest.name === 'bun' && manifest.bin?.bun === 'bin/bun.exe')
33
- return executable;
59
+ if (command.toLowerCase() === 'bun') {
60
+ const bun = npmBun(directory);
61
+ if (bun)
62
+ return { command: bun, args };
34
63
  }
35
- catch { /* An incomplete installation follows the ordinary spawn-error path. */ }
64
+ const script = shimScript(shim);
65
+ if (script)
66
+ return { command: process.execPath, args: [script, ...args] };
36
67
  }
37
- return command;
68
+ return { command, args };
38
69
  }
@@ -0,0 +1,4 @@
1
+ /** The recorder's environment and its child's are computed here so the boot gate sees the exact final names. */
2
+ export declare function recorderEnvironment(env: NodeJS.ProcessEnv, service: string, log: string, logDir: string, events: string): NodeJS.ProcessEnv;
3
+ /** Restore the injector for the real child and omit only the recorder's existing private transport names. */
4
+ export declare function recordedApplicationEnvironment(recorded: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,21 @@
1
+ /** The recorder's environment and its child's are computed here so the boot gate sees the exact final names. */
2
+ export function recorderEnvironment(env, service, log, logDir, events) {
3
+ const out = { ...env, VOLTER_WORLD_SERVICE_ID: service, VOLTER_WORLD_SERVICE_LOG: log,
4
+ VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: events };
5
+ if (out.NODE_OPTIONS !== undefined) {
6
+ out.VOLTER_WORLD_SERVICE_NODE_OPTIONS = out.NODE_OPTIONS;
7
+ delete out.NODE_OPTIONS;
8
+ }
9
+ return out;
10
+ }
11
+ /** Restore the injector for the real child and omit only the recorder's existing private transport names. */
12
+ export function recordedApplicationEnvironment(recorded) {
13
+ const out = { ...recorded };
14
+ if (out.VOLTER_WORLD_SERVICE_NODE_OPTIONS !== undefined) {
15
+ out.NODE_OPTIONS = out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
16
+ delete out.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
17
+ }
18
+ for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX'])
19
+ delete out[key];
20
+ return out;
21
+ }
@@ -17,22 +17,17 @@
17
17
  // The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
18
18
  import { signalChild } from './process-signal.mjs';
19
19
  import { errorMessage } from "./error-message.js";
20
- import { serviceExecutable } from "./service-command.js";
20
+ import { serviceInvocation } from "./service-command.js";
21
+ import { recordedApplicationEnvironment } from "./service-environment.js";
21
22
  import { spawn } from 'node:child_process';
22
23
  import { constants } from 'node:os';
23
24
  import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
24
25
  const [command = '', ...args] = process.argv.slice(2);
25
- const env = { ...process.env };
26
- if (env.VOLTER_WORLD_SERVICE_NODE_OPTIONS !== undefined) {
27
- env.NODE_OPTIONS = env.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
28
- delete env.VOLTER_WORLD_SERVICE_NODE_OPTIONS;
29
- }
30
- const service = env.VOLTER_WORLD_SERVICE_ID;
31
- const logPath = env.VOLTER_WORLD_SERVICE_LOG;
32
- const eventsPath = env.VOLTER_WORLD_EVENTS;
33
- const cap = Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
34
- for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX'])
35
- delete env[key];
26
+ const env = recordedApplicationEnvironment(process.env);
27
+ const service = process.env.VOLTER_WORLD_SERVICE_ID;
28
+ const logPath = process.env.VOLTER_WORLD_SERVICE_LOG;
29
+ const eventsPath = process.env.VOLTER_WORLD_EVENTS;
30
+ const cap = Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(process.env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
36
31
  /** The log's size and whether its last byte ends a line (a record never lands inside a service's line). */
37
32
  const logEnd = () => {
38
33
  if (!logPath)
@@ -62,7 +57,8 @@ const record = (entry, then) => {
62
57
  process.stdout.write(`${logEnd().atLineStart ? '' : '\n'}${line}`, () => then?.());
63
58
  };
64
59
  const started = Date.now();
65
- const child = spawn(serviceExecutable(command, env), args, { windowsHide: true, stdio: 'inherit', env });
60
+ const invocation = serviceInvocation(command, args, env);
61
+ const child = spawn(invocation.command, invocation.args, { windowsHide: true, stdio: 'inherit', env });
66
62
  if (child.pid !== undefined)
67
63
  record({ event: 'world.service.started', pid: child.pid, parentPid: process.pid });
68
64
  /** The log's last `bytes` (from a line start) as the rotation `<log>.1`: however far a runaway writer overshot the cap,
@@ -1017,7 +1017,6 @@
1017
1017
  "organizationlicense": "generic app-internal env stem, names no vendor",
1018
1018
  "osso": "defunct SAML SaaS (Osso), no live vendor surface",
1019
1019
  "postgrest": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
1020
- "postizoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1021
1020
  "seedoauth2": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1022
1021
  "seedplatformoauth": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
1023
1022
  "sink": "repo-local stem; env-stem extraction firing on an app-internal name, no vendor exists",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.166",
3
+ "version": "3.0.167",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -62,6 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.2.20",
64
64
  "@types/node": "^24.0.0",
65
+ "mongodb": "~6.20.0",
65
66
  "pg": "8.16.3",
66
67
  "redis": "5.8.2",
67
68
  "typescript": "^5.9.0"
@@ -70,8 +71,8 @@
70
71
  "node": ">=22.3"
71
72
  },
72
73
  "peerDependencies": {
73
- "@volter/world-core": "3.0.156",
74
- "@volter/world-console": "3.0.152"
74
+ "@volter/world-core": "3.0.157",
75
+ "@volter/world-console": "3.0.153"
75
76
  },
76
77
  "dependencies": {
77
78
  "@electric-sql/pglite": "0.5.8",
@@ -80,9 +81,8 @@
80
81
  "@volter/browser-runtime": "0.1.104",
81
82
  "@volter/browser-sandboxes": "0.1.84",
82
83
  "@volter/world-access": "3.0.1",
83
- "@volter/world-core": "3.0.156",
84
+ "@volter/world-core": "3.0.157",
84
85
  "modern-screenshot": "4.7.0",
85
- "mongodb": "~6.20.0",
86
86
  "pg-gateway": "0.3.0-beta.4",
87
87
  "smol-toml": "^1.8.0",
88
88
  "yaml": "2.9.0"
@@ -8,6 +8,7 @@
8
8
  // What it does not do (named, so a workflow that needs them fails plainly): matrices beyond their first combination,
9
9
  // services and containers, composite and JavaScript actions from the marketplace, reusable workflows, concurrency groups.
10
10
  import { errorMessage } from './error-message.ts';
11
+ import { worldRequiredCredentialGate } from './runtime.ts';
11
12
  import { spawn } from 'node:child_process';
12
13
  import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
14
  import { join } from 'node:path';
@@ -102,15 +103,36 @@ function readKv(file: string): Record<string, string> {
102
103
  return out;
103
104
  }
104
105
 
105
- function sh(command: string, cwd: string, env: Record<string, string>, shell: string, log: (l: string) => void): Promise<number> {
106
- return new Promise((resolve) => {
106
+ function sh(command: string, cwd: string, env: Record<string, string>, shell: string, log: (l: string) => void, requireEnv: (env: NodeJS.ProcessEnv, boundary: string) => void): Promise<number> {
107
+ requireEnv(env, 'Actions command');
108
+ return new Promise((resolve, reject) => {
107
109
  const file = join(cwd, `.volter-step-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`);
108
- writeFileSync(file, command);
109
- const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] as const : shell === 'sh' ? ['sh', ['-e', file]] as const : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]] as const;
110
- const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
111
- child.stdout.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); });
112
- child.stderr.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); });
113
- child.on('close', (code) => { rmSync(file, { force: true }); resolve(code ?? 1); });
110
+ let failure: unknown = undefined;
111
+ const finish = (code: number | null, signal: NodeJS.Signals | null): void => {
112
+ const primary = failure ?? (signal ? Object.assign(new Error(`Actions command terminated by ${signal}`), { signal })
113
+ : code === null ? new Error('Actions command closed without an exit status') : undefined);
114
+ try { rmSync(file, { force: true }); }
115
+ catch (cleanup) {
116
+ reject(primary === undefined ? cleanup : new AggregateError([primary, cleanup], 'Actions command and script cleanup failed', { cause: primary }));
117
+ return;
118
+ }
119
+ if (primary !== undefined) reject(primary);
120
+ else resolve(code!);
121
+ };
122
+ try {
123
+ writeFileSync(file, command);
124
+ const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] as const : shell === 'sh' ? ['sh', ['-e', file]] as const : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]] as const;
125
+ const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
126
+ // Spawn errors settle at close, after the owned child's streams close; retain the original native cause.
127
+ child.on('error', (error: Error) => { failure ??= error; });
128
+ const output = (d: Buffer): void => {
129
+ try { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); }
130
+ catch (error) { failure ??= error; }
131
+ };
132
+ child.stdout.on('data', output);
133
+ child.stderr.on('data', output);
134
+ child.on('close', finish);
135
+ } catch (error) { failure = error; finish(null, null); }
114
136
  });
115
137
  }
116
138
 
@@ -138,6 +160,11 @@ function builtin(step: Step, env: Record<string, string>, workspace: string): {
138
160
  /** One run: started, each job's steps run in order of `needs`, completed with its conclusion. */
139
161
  export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row): Promise<'success' | 'failure'> {
140
162
  const log = opts.log ?? (() => {});
163
+ // The runner's original World context, before job/step overrides, owns its required values.
164
+ const instancePath = process.env.VOLTER_WORLD_INSTANCE ?? opts.env.VOLTER_WORLD_INSTANCE;
165
+ if (!instancePath && (process.env.VOLTER_WORLD || opts.env.VOLTER_WORLD)) throw new Error('Actions runner has no local credential declaration');
166
+ const requireEnv = instancePath ? worldRequiredCredentialGate(JSON.parse(readFileSync(instancePath, 'utf8')))
167
+ : (_env: NodeJS.ProcessEnv, _boundary: string): void => {};
141
168
  const id = Number(run.id);
142
169
  const started = await api(opts, 'POST', `/_twin/repos/${repo}/actions/runs/${id}/start`);
143
170
  if (started.status !== 200) { log(`run ${repo}#${id}: not started (${started.status})`); return 'failure'; }
@@ -158,7 +185,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
158
185
  const root = mkdtempSync(join(opts.workRoot, `run-${id}-`));
159
186
  const workspace = join(root, 'workspace');
160
187
  const clone = `${opts.github.replace(/\/$/, '').replace(/^(https?:\/\/)/, `$1x-access-token:${jobToken}@`)}/${repo}.git`;
161
- const git = async (args: string[], cwd: string): Promise<number> => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env as Record<string, string>, ...opts.env }, 'bash', log);
188
+ const git = async (args: string[], cwd: string): Promise<number> => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env as Record<string, string>, ...opts.env }, 'bash', log, requireEnv);
162
189
  let conclusion: 'success' | 'failure' = 'success';
163
190
  try {
164
191
  mkdirSync(root, { recursive: true });
@@ -204,6 +231,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
204
231
  for (const [k, v] of Object.entries(stepScope.env as Row)) env[k] = String(v);
205
232
  if (!condition(step.if, stepScope)) { log(` step ${i + 1} ${step.name ?? step.uses ?? 'run'}: skipped`); continue; }
206
233
  log(` step ${i + 1}: ${step.name ?? step.uses ?? String(step.run).split('\n')[0]}`);
234
+ requireEnv(env, 'Actions step environment');
207
235
  let ok: boolean;
208
236
  if (step.uses) {
209
237
  // its inputs are expressions too (`registry-url: ${{ vars.NPM_REGISTRY }}`)
@@ -213,7 +241,7 @@ export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row
213
241
  ok = r.ok;
214
242
  } else {
215
243
  const cwd = join(workspace, step['working-directory'] ?? job.defaults?.run?.['working-directory'] ?? '');
216
- ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log)) === 0;
244
+ ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log, requireEnv)) === 0;
217
245
  }
218
246
  if (step.id) (scope.steps as Row)[step.id] = { outputs: readKv(files.output), outcome: ok ? 'success' : 'failure', conclusion: ok || step['continue-on-error'] ? 'success' : 'failure' };
219
247
  if (!ok && !step['continue-on-error']) failed = true;