@volter/world-runtime 3.0.38 → 3.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
@@ -1,5 +1,6 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { serveHttp } from '@volter/world-core/runtime';
2
- import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
3
+ import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
3
4
  import { websocketRelay } from "./websocket-relay.js";
4
5
  // The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
5
6
  // redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
@@ -26,6 +27,9 @@ import { lookup as dnsLookup } from 'node:dns/promises';
26
27
  import { BlockList, isIP, connect as netConnect, createServer as createNetServer } from 'node:net';
27
28
  import { networkInterfaces } from 'node:os';
28
29
  import { connect as tlsConnect } from 'node:tls';
30
+ import { request as httpsRequest } from 'node:https';
31
+ import { createServer as createHttpServer } from 'node:http';
32
+ import { Readable } from 'node:stream';
29
33
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
34
  import { join } from 'node:path';
31
35
  // The host→twin table is loaded through the shared inject-map module (one world-runtime home
@@ -33,6 +37,7 @@ import { join } from 'node:path';
33
37
  // never re-encode the vendor host predicates — they stay one source of truth with the Node
34
38
  // injector. External callers go through `proxyTargetFor`/`activeVendorMap`.
35
39
  import { loadInject } from "./inject-map.js";
40
+ import { packFacts } from "./pack-facts.js";
36
41
  import { sessionTrustEnv } from "./ca-trust.js";
37
42
  /**
38
43
  * The body served when a host was MITM'd but no twin serves the PATH.
@@ -168,7 +173,7 @@ export async function guestDestination(host) {
168
173
  addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address);
169
174
  }
170
175
  catch (error) {
171
- return { refused: `${host} does not resolve (${error.message})` };
176
+ return { refused: `${host} does not resolve (${errorMessage(error)})` };
172
177
  }
173
178
  const bad = addresses.find(isGuestForbiddenAddress);
174
179
  if (bad || !addresses.length)
@@ -277,7 +282,7 @@ export function leafCertFor(host, ca) {
277
282
  * of which World to look at (`doctor`). Shared by every front that relays to twins. */
278
283
  export function twinUnreachableMessage(vendor, origin, err, world) {
279
284
  const cause = err.cause;
280
- return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${err.message}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
285
+ return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${errorMessage(err)}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${errorMessage(cause)})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
281
286
  }
282
287
  /** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
283
288
  * - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
@@ -308,6 +313,117 @@ export async function startRedirectProxy(options) {
308
313
  // Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
309
314
  // or real upstream would otherwise keep the server from closing).
310
315
  const openSockets = new Set();
316
+ const publicBuildRequest = (target, env) => {
317
+ const policy = worldNetworkPolicyFromEnv(env);
318
+ return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
319
+ };
320
+ // One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
321
+ // It never follows redirects and never lends the upstream socket to the caller.
322
+ const externalRequest = async (target, method, incoming, body, signal) => {
323
+ const env = currentEnv();
324
+ const policy = worldNetworkPolicyFromEnv(env);
325
+ // the method is visible here: an egress grant reads, a write needs `writes`
326
+ if (!policy || !allowsWorldNetworkEgress(policy, target, method) || shouldBlockUntwinned(target, env))
327
+ throw new Error('World policy refuses the effective request');
328
+ const host = incoming.get('host');
329
+ if (host && new URL(`${target.protocol}//${host}`).host !== target.host)
330
+ throw new Error('request Host differs from authorized authority');
331
+ if (method === 'CONNECT' || incoming.has('upgrade'))
332
+ throw new Error('inspected egress cannot upgrade its transport');
333
+ if (!body && (Number(incoming.get('content-length') || 0) > 0 || incoming.has('transfer-encoding')))
334
+ throw new Error('transport cannot mediate this request body');
335
+ const headers = new Headers(incoming);
336
+ const hopHeaders = (headers.get('connection') ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
337
+ for (const h of [...hopHeaders, 'host', 'proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'x-twins-key', 'x-volter-twin-original-host'])
338
+ headers.delete(h);
339
+ return await new Promise((resolve, reject) => {
340
+ const upstream = httpsRequest({ hostname: target.hostname, port: Number(target.port || 443), servername: target.hostname,
341
+ path: target.pathname + target.search, method, headers: { ...Object.fromEntries(headers), host: target.host },
342
+ agent: false, ca: readFileSync(trust.SSL_CERT_FILE), signal, }, res => {
343
+ const out = new Headers();
344
+ const nominated = String(res.headers.connection ?? '').split(',').map(h => h.trim().toLowerCase());
345
+ for (const [key, value] of Object.entries(res.headers))
346
+ if (value !== undefined && ![...nominated, 'connection', 'transfer-encoding', 'keep-alive', 'trailer', 'upgrade'].includes(key)) {
347
+ for (const part of Array.isArray(value) ? value : [value])
348
+ out.append(key, String(part));
349
+ }
350
+ const empty = method === 'HEAD' || [204, 205, 304].includes(res.statusCode);
351
+ if (empty)
352
+ res.resume();
353
+ note(`inspected egress ${method} ${target.origin}${target.pathname} -> ${res.statusCode}`);
354
+ resolve(new Response(empty ? null : Readable.toWeb(res), { status: res.statusCode, headers: out }));
355
+ });
356
+ upstream.on('socket', socket => { openSockets.add(socket); socket.on('close', () => openSockets.delete(socket)); });
357
+ upstream.on('error', reject);
358
+ upstream.on('upgrade', (_res, socket) => { socket.destroy(); reject(new Error('upstream upgrade refused')); });
359
+ if (body) {
360
+ const source = Readable.fromWeb(body);
361
+ source.on('error', error => upstream.destroy(error));
362
+ upstream.on('error', () => source.destroy());
363
+ source.pipe(upstream);
364
+ }
365
+ else
366
+ upstream.end();
367
+ });
368
+ };
369
+ // Use the HTTP parser for framing; never forward trailing bytes as a second raw request.
370
+ const usedPlainSockets = new WeakSet();
371
+ const inspectedPlain = createHttpServer((req, res) => {
372
+ if (usedPlainSockets.has(req.socket)) {
373
+ note('inspected egress refused extra plain request');
374
+ req.socket.destroy();
375
+ return;
376
+ }
377
+ usedPlainSockets.add(req.socket);
378
+ res.shouldKeepAlive = false;
379
+ res.setHeader('connection', 'close');
380
+ res.on('finish', () => req.socket.end());
381
+ const abort = new AbortController();
382
+ req.on('aborted', () => abort.abort());
383
+ res.on('close', () => { if (!res.writableFinished)
384
+ abort.abort(); });
385
+ void (async () => {
386
+ if (/\\/.test(req.url.split(/[?#]/, 1)[0]))
387
+ throw new Error('backslash in proxy target');
388
+ const target = new URL(req.url);
389
+ if (proxyTargetFor(target.hostname, currentEnv()) && !publicBuildRequest(target, currentEnv()))
390
+ throw new Error('inspected external request names a twinned host');
391
+ const headers = new Headers();
392
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
393
+ headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
394
+ const body = headers.has('transfer-encoding') || Number(headers.get('content-length') || 0) > 0 ? Readable.toWeb(req) : null;
395
+ const response = await externalRequest(target, req.method, headers, body, abort.signal);
396
+ res.statusCode = response.status;
397
+ for (const [key, value] of response.headers)
398
+ res.setHeader(key, value);
399
+ if (response.body) {
400
+ const stream = Readable.fromWeb(response.body);
401
+ stream.on('error', () => res.destroy());
402
+ res.on('close', () => stream.destroy());
403
+ stream.pipe(res);
404
+ }
405
+ else
406
+ res.end();
407
+ })().catch(error => { if (!res.headersSent) {
408
+ res.statusCode = 502;
409
+ res.end(note(`${error.message}\n`));
410
+ }
411
+ else
412
+ res.destroy(); });
413
+ });
414
+ inspectedPlain.maxRequestsPerSocket = 1;
415
+ inspectedPlain.on('dropRequest', (req, socket) => { note('inspected egress refused extra plain request'); socket.destroy(); });
416
+ inspectedPlain.on('clientError', (_error, socket) => socket.destroy());
417
+ // Bun's Node HTTP adapter parses only on a listening server, not a synthetic connection event.
418
+ // Relays below carry bytes solely to this local parser; only externalRequest opens public sockets.
419
+ await new Promise((resolve, reject) => {
420
+ inspectedPlain.once('error', reject);
421
+ inspectedPlain.listen(0, '127.0.0.1', resolve);
422
+ });
423
+ const inspectedAddress = inspectedPlain.address();
424
+ if (!inspectedAddress || typeof inspectedAddress === 'string')
425
+ throw new Error('inspected HTTP parser has no TCP address');
426
+ const inspectedPort = inspectedAddress.port;
311
427
  // The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
312
428
  // to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
313
429
  // server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
@@ -317,8 +433,9 @@ export async function startRedirectProxy(options) {
317
433
  // websocket is relayed message by message to the twin's own websocket at the same path.
318
434
  const tlsServers = new Map();
319
435
  const tlsPorts = new Map();
320
- const ensureHostServer = async (vendorHost) => {
321
- const existing = tlsPorts.get(vendorHost);
436
+ const ensureHostServer = async (vendorHost, guest = false, originPort = 443) => {
437
+ const serverKey = `${vendorHost}:${originPort}:${guest}`;
438
+ const existing = tlsPorts.get(serverKey);
322
439
  if (existing)
323
440
  return existing;
324
441
  const leaf = leafCertFor(vendorHost, ca);
@@ -326,6 +443,8 @@ export async function startRedirectProxy(options) {
326
443
  hostname: '127.0.0.1',
327
444
  port: 0,
328
445
  tls: { key: leaf.key, cert: leaf.cert },
446
+ // Carry a twin's declared transport cap through TLS termination; public grants add no cap.
447
+ maxRequestBodySize: packFacts()[proxyTargetFor(vendorHost, currentEnv())?.vendor ?? '']?.maxRequestBodySize,
329
448
  // A twin can think before its first byte (a git pack built from a cold cache took 19-28 s); Bun's 10 s idle
330
449
  // default cut those connections and the client read an empty reply. Bun's maximum, as reflect.ts serves.
331
450
  idleTimeout: 255,
@@ -336,13 +455,29 @@ export async function startRedirectProxy(options) {
336
455
  // The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
337
456
  // share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
338
457
  // for the host and merely not for this path, and the bare message sends the next reader hunting.
339
- const twin = proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
458
+ const effective = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
459
+ const publicBuild = publicBuildRequest(effective, env);
460
+ const twin = publicBuild ? null : proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
340
461
  if (!twin) {
341
462
  // a host the World refuses (sealed, or outside its network policy): answered here, over the World's TLS, so
342
463
  // a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
343
464
  // leaves the World
344
- if (shouldBlockUntwinned(`https://${hostHeader}`, env))
465
+ const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
466
+ if (shouldBlockUntwinned(target.href, env))
345
467
  return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
468
+ if (!publicBuild && proxyTargetFor(vendorHost, env))
469
+ return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
470
+ const policy = worldNetworkPolicyFromEnv(env);
471
+ if (policy && worldNetworkNeedsPathInspection(policy, target)) {
472
+ if (guest)
473
+ return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), 'guest inspected egress cannot preserve its checked destination pin');
474
+ try {
475
+ return await externalRequest(target, req.method, req.headers, req.body, req.signal);
476
+ }
477
+ catch (error) {
478
+ return new Response(note(`inspected egress refused: ${error.message}\n`), { status: 502 });
479
+ }
480
+ }
346
481
  const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
347
482
  if (unreachable)
348
483
  return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
@@ -395,8 +530,8 @@ export async function startRedirectProxy(options) {
395
530
  }),
396
531
  });
397
532
  const p = hostServer.port ?? 0;
398
- tlsServers.set(vendorHost, hostServer);
399
- tlsPorts.set(vendorHost, p);
533
+ tlsServers.set(serverKey, hostServer);
534
+ tlsPorts.set(serverKey, p);
400
535
  return p;
401
536
  };
402
537
  // two listeners with one handler: the World's (host clients; a connection from elsewhere is still a guest's) and its
@@ -436,7 +571,7 @@ export async function startRedirectProxy(options) {
436
571
  }
437
572
  return;
438
573
  }
439
- handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest);
574
+ handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest, buffered);
440
575
  };
441
576
  clientSocket.on('data', onData);
442
577
  };
@@ -496,12 +631,13 @@ export async function startRedirectProxy(options) {
496
631
  : netConnect(Number(origin.port || 80), connectHost, connected);
497
632
  openSockets.add(upstream);
498
633
  upstream.on('close', () => openSockets.delete(upstream));
499
- upstream.on('error', (err) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${err.message}\n`)));
634
+ upstream.on('error', (err) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${errorMessage(err)}\n`)));
500
635
  };
501
- const handlePlainProxyRequest = (method, urlStr, version, headers, socket, head, guest) => {
636
+ const handlePlainProxyRequest = (method, urlStr, version, headers, socket, head, guest, raw) => {
502
637
  try {
503
638
  const u = new URL(urlStr);
504
- const twin = proxyTargetFor(u.hostname, currentEnv(), u.pathname);
639
+ const publicBuild = publicBuildRequest(u, currentEnv());
640
+ const twin = publicBuild ? null : proxyTargetFor(u.hostname, currentEnv(), u.pathname);
505
641
  const path = u.pathname + u.search;
506
642
  if (twin) {
507
643
  const target = new URL(twin.origin);
@@ -513,7 +649,7 @@ export async function startRedirectProxy(options) {
513
649
  // No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
514
650
  // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
515
651
  // handler's noTwinMessage), never pass through to the real vendor.
516
- if (proxyTargetFor(u.hostname, currentEnv())) {
652
+ if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
517
653
  writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
518
654
  return;
519
655
  }
@@ -521,6 +657,25 @@ export async function startRedirectProxy(options) {
521
657
  writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
522
658
  return;
523
659
  }
660
+ const policy = worldNetworkPolicyFromEnv(currentEnv());
661
+ if (policy && worldNetworkNeedsPathInspection(policy, u)) {
662
+ if (guest) {
663
+ writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
664
+ return;
665
+ }
666
+ socket.pause();
667
+ const parser = netConnect(inspectedPort, '127.0.0.1', () => {
668
+ parser.write(raw);
669
+ socket.pipe(parser);
670
+ parser.pipe(socket);
671
+ socket.resume();
672
+ });
673
+ openSockets.add(parser);
674
+ parser.on('close', () => { openSockets.delete(parser); socket.destroy(); });
675
+ parser.on('error', () => socket.destroy());
676
+ socket.on('close', () => parser.destroy());
677
+ return;
678
+ }
524
679
  // Non-vendor: plain pass-through to the real host; for a World Machine, only off its host's own network, and to
525
680
  // the address checked (architecture, "The scoped proxy's local pass-through")
526
681
  if (guest) {
@@ -560,7 +715,7 @@ export async function startRedirectProxy(options) {
560
715
  // a destination that cannot be reached at all (a name that does not resolve, a refused port) is answered by the
561
716
  // caller's fallback when it has one, before the tunnel was ever established; otherwise the client's socket ends
562
717
  upstream.on('error', (error) => {
563
- if (!connected && unreachable && unreachable(error.code ?? error.message))
718
+ if (!connected && unreachable && unreachable(error.code ?? errorMessage(error)))
564
719
  return;
565
720
  clientSocket.destroy();
566
721
  });
@@ -572,13 +727,23 @@ export async function startRedirectProxy(options) {
572
727
  note(`${connectHost}:${connectPort} did not answer (${reason})`);
573
728
  refusedPages.add(connectHost);
574
729
  unreachableHosts.set(connectHost, reason);
575
- ensureHostServer(connectHost).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
730
+ ensureHostServer(connectHost, guest, connectPort).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
576
731
  return true;
577
732
  };
578
733
  // MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
579
734
  // the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
580
- if (twin && connectHost) {
581
- ensureHostServer(connectHost)
735
+ const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
736
+ const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
737
+ const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
738
+ const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
739
+ if (passthrough && !twin)
740
+ note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
741
+ if (inspected && guest && !twin) {
742
+ writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
743
+ return;
744
+ }
745
+ if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
746
+ ensureHostServer(connectHost, guest, connectPort)
582
747
  .then((port) => tunnel(port, '127.0.0.1'))
583
748
  .catch(() => clientSocket.destroy());
584
749
  }
@@ -590,7 +755,7 @@ export async function startRedirectProxy(options) {
590
755
  // line, as before
591
756
  if (connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX)) {
592
757
  refusedPages.add(connectHost);
593
- ensureHostServer(connectHost)
758
+ ensureHostServer(connectHost, guest, connectPort)
594
759
  .then((port) => tunnel(port, '127.0.0.1'))
595
760
  .catch(() => clientSocket.destroy());
596
761
  }
@@ -651,6 +816,8 @@ export async function startRedirectProxy(options) {
651
816
  }
652
817
  catch { /* ignore */ }
653
818
  }
819
+ inspectedPlain.closeAllConnections();
820
+ inspectedPlain.close();
654
821
  guestServer.close();
655
822
  server.close(() => resolveClose());
656
823
  }),
@@ -8,12 +8,17 @@
8
8
  // in a container is state outside it (a branch of the World would share the container's queue and cache). The
9
9
  // twin keeps each key a subject of the World (the Redis pack's storage module). It needs nothing
10
10
  // installed: no container runtime, no redis-server binary.
11
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
12
12
  import { connect } from 'node:net';
13
13
  import { dirname, join } from 'node:path';
14
+ import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
15
+ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, signalOwnedProcess } from "./process-receipt.js";
14
16
  import { spawn } from 'node:child_process';
17
+ import { survivingOwnedGroups } from "./process-groups.js";
18
+ import { errorMessage } from "./error-message.js";
15
19
  import { findInstalledPackage, packCli } from "./catalog.js";
16
20
  import { vendorPackageDir } from '@volter/world-core/runtime';
21
+ const hosts = new Map();
17
22
  const PACKAGE = '@volter/twin-redis';
18
23
  const READY_TIMEOUT_MS = 60_000;
19
24
  /** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
@@ -34,8 +39,10 @@ function alive(pid) {
34
39
  process.kill(pid, 0);
35
40
  return true;
36
41
  }
37
- catch {
38
- return false;
42
+ catch (error) {
43
+ if (error.code === 'ESRCH')
44
+ return false;
45
+ throw error;
39
46
  }
40
47
  }
41
48
  function listening(port) {
@@ -52,8 +59,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
52
59
  export async function redisTwinUp(services, dataDir, cli, launch = {}) {
53
60
  for (const service of services) {
54
61
  const pidFile = pidPath(dataDir, service);
55
- if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())) && await listening(service.hostPort))
56
- continue;
62
+ if (hosts.has(pidFile) || existsSync(pidFile) || existsSync(spawnReceiptPath(pidFile))) {
63
+ await redisTwinDown([service], dataDir);
64
+ }
57
65
  // a port that already answers is another process's (another World's twin, a redis-server): serving there would
58
66
  // hand this World's app someone else's keys, so it is refused, never taken as ready
59
67
  if (await listening(service.hostPort))
@@ -67,23 +75,51 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
67
75
  // the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
68
76
  // type transformation, not only stripping (the kernel declares constructor parameter properties)
69
77
  const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
70
- const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
71
- env: launch.env ?? process.env,
72
- detached: true,
73
- windowsHide: true,
74
- stdio: ['ignore', logFd, logFd],
75
- });
76
- child.unref();
77
- closeSync(logFd);
78
- writeFileSync(pidFile, `${child.pid}\n`);
79
- const deadline = Date.now() + READY_TIMEOUT_MS;
80
- while (!(await listening(service.hostPort)) || !alive(child.pid)) {
81
- if (!alive(child.pid))
82
- throw new Error(`redis exited before serving — see ${log}`);
78
+ let held;
79
+ try {
80
+ try {
81
+ const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
82
+ env: launch.env ?? process.env, detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd],
83
+ });
84
+ // Retain BEFORE descriptor close, birth capture or either publication can fail.
85
+ held = { child, lifetime: commandLifetime(child, launch.signal), closed: false };
86
+ const observed = held;
87
+ child.once('close', () => { observed.closed = true; });
88
+ child.on('error', () => { }); // lifetime joins spawn failure and close
89
+ hosts.set(pidFile, held);
90
+ }
91
+ finally {
92
+ closeSync(logFd);
93
+ }
94
+ if (!held.child.pid)
95
+ throw new Error('Redis child did not start');
96
+ const identity = await held.lifetime.identity;
97
+ if (!identity?.start)
98
+ throw new Error('Redis birth capture failed; retiring held child');
99
+ publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
100
+ publishProcessReceipt(pidFile, `${held.child.pid}\n`);
101
+ const deadline = Date.now() + READY_TIMEOUT_MS;
102
+ while (!(await listening(service.hostPort)) || !alive(held.child.pid)) {
103
+ if (!alive(held.child.pid))
104
+ throw new Error(`redis exited before serving — see ${log}`);
105
+ launch.signal?.throwIfAborted();
106
+ if (Date.now() > deadline)
107
+ throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
108
+ await sleep(200);
109
+ }
83
110
  launch.signal?.throwIfAborted();
84
- if (Date.now() > deadline)
85
- throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
86
- await sleep(200);
111
+ held.child.unref();
112
+ }
113
+ catch (error) {
114
+ if (held) {
115
+ try {
116
+ await redisTwinDown([service], dataDir);
117
+ }
118
+ catch (cleanup) {
119
+ throw new AggregateError([error, cleanup], 'Redis startup and retirement failed; ownership retained');
120
+ }
121
+ }
122
+ throw error;
87
123
  }
88
124
  }
89
125
  }
@@ -101,24 +137,52 @@ export async function redisTwinStatus(services, dataDir) {
101
137
  export async function redisTwinDown(services, dataDir) {
102
138
  for (const service of services) {
103
139
  const pidFile = pidPath(dataDir, service);
104
- if (!existsSync(pidFile))
105
- continue;
106
- const pid = Number(readFileSync(pidFile, 'utf8').trim());
107
- if (alive(pid)) {
108
- try {
109
- process.kill(pid, 'SIGTERM');
110
- }
111
- catch { /* already gone */ }
112
- const deadline = Date.now() + 5000;
113
- while (alive(pid) && Date.now() < deadline)
114
- await sleep(100);
115
- if (alive(pid)) {
140
+ const recorded = readProcessReceipt(pidFile, text => {
141
+ const pid = Number(text.trim());
142
+ if (!Number.isInteger(pid) || pid <= 0)
143
+ throw new Error('Invalid Redis ownership PID');
144
+ return pid;
145
+ });
146
+ const identity = readSpawnReceipt(pidFile);
147
+ const held = hosts.get(pidFile);
148
+ if (recorded.error || identity.error)
149
+ process.stderr.write(`${errorMessage(new AggregateError([recorded.error, identity.error].filter(Boolean), 'Redis ownership uncertain; retrying established ownership'))}\n`);
150
+ if (held) {
151
+ const retired = () => held.closed && (!held.child.pid || !survivingOwnedGroups([held.child.pid]).length);
152
+ if (!retired()) {
153
+ held.child.ref();
154
+ held.lifetime.cancel('SIGTERM');
116
155
  try {
117
- process.kill(pid, 'SIGKILL');
156
+ await held.lifetime.finish();
157
+ }
158
+ catch (error) {
159
+ if (!retired())
160
+ throw error;
118
161
  }
119
- catch { /* raced */ }
120
162
  }
121
163
  }
164
+ else {
165
+ if (!recorded.present && !identity.present)
166
+ continue;
167
+ const pid = identity.value?.pid ?? recorded.value;
168
+ if (!pid)
169
+ throw new AggregateError([recorded.error, identity.error].filter(Boolean), 'Redis ownership uncertain; receipts retained');
170
+ if (recorded.value && identity.value && recorded.value !== identity.value.pid)
171
+ throw new Error('Redis ownership receipts disagree; retirement refused; receipts retained');
172
+ const live = () => survivingOwnedGroups([pid]).length > 0;
173
+ for (const [signal, limit] of [['SIGTERM', COMMAND_GRACE_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
174
+ if (!live())
175
+ break;
176
+ await signalOwnedProcess(identity.value, signal, true);
177
+ const deadline = Date.now() + limit;
178
+ while (live() && Date.now() < deadline)
179
+ await sleep(20);
180
+ }
181
+ if (live())
182
+ throw new Error('Redis retirement unconfirmed; receipts retained');
183
+ }
122
184
  rmSync(pidFile, { force: true });
185
+ rmSync(spawnReceiptPath(pidFile), { force: true });
186
+ hosts.delete(pidFile);
123
187
  }
124
188
  }
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { serveHttp } from '@volter/world-core/runtime';
2
3
  import { websocketRelay } from "./websocket-relay.js";
3
4
  import { sessionTrustEnv } from "./ca-trust.js";
@@ -161,7 +162,7 @@ export async function startReflectFront(options) {
161
162
  world = options.envLoader().VOLTER_WORLD;
162
163
  }
163
164
  catch { /* the answer still goes out */ }
164
- return new Response(twinUnreachableMessage(target.vendor, origin.origin, error instanceof Error ? error : new Error(String(error)), world), { status: 502 });
165
+ return new Response(twinUnreachableMessage(target.vendor, origin.origin, error instanceof Error ? error : new Error(errorMessage(error)), world), { status: 502 });
165
166
  }
166
167
  },
167
168
  upgrade: websocketRelay(req => {
package/dist/src/root.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // never in a pack, never an env var). `volter world deploy` performs landed entries against each
8
8
  // root through the pack's adapters over the kernel executor, with the world's checks in front;
9
9
  // `deploy: auto` does the same the moment an entry is appended (the host installs the hook).
10
+ import { errorMessage } from "./error-message.js";
10
11
  import { randomBytes } from 'node:crypto';
11
12
  import { packEntry } from "./catalog.js";
12
13
  import { existsSync, readFileSync } from 'node:fs';
@@ -126,7 +127,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
126
127
  inventory = (await currentSealer().open(JSON.parse(heldInventory), INVENTORY_SLOT));
127
128
  }
128
129
  catch (error) {
129
- console.error(`[credential] the inventory beside the World does not open (${error.message}): nothing is admitted until it does`);
130
+ console.error(`[credential] the inventory beside the World does not open (${errorMessage(error)}): nothing is admitted until it does`);
130
131
  return { bound: [], failed: [], refused: [], ready: false };
131
132
  }
132
133
  }
@@ -137,7 +138,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
137
138
  return true;
138
139
  }
139
140
  catch (error) {
140
- console.error(`[credential] the inventory could not be kept (${error.message})`);
141
+ console.error(`[credential] the inventory could not be kept (${errorMessage(error)})`);
141
142
  return false;
142
143
  }
143
144
  };
@@ -182,7 +183,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
182
183
  bound.push(name);
183
184
  }
184
185
  catch (error) {
185
- console.warn(`[credential] ${name}: not bound to its destination (${error.message})`);
186
+ console.warn(`[credential] ${name}: not bound to its destination (${errorMessage(error)})`);
186
187
  failed.push(name);
187
188
  }
188
189
  }
@@ -210,7 +211,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
210
211
  bound.push(name);
211
212
  }
212
213
  catch (error) {
213
- console.warn(`[credential] ${name}: not moved to the vault (${error.message})`);
214
+ console.warn(`[credential] ${name}: not moved to the vault (${errorMessage(error)})`);
214
215
  failed.push(name);
215
216
  }
216
217
  }
@@ -298,7 +299,7 @@ export async function rootCredentialInput(worldRoot, vendor, env = process.env)
298
299
  response = await fetch(at, { headers: { 'x-vault-token': vault.token, ...(vault.namespace ? { 'x-vault-namespace': vault.namespace } : {}) }, signal: AbortSignal.timeout(10_000) });
299
300
  }
300
301
  catch (error) {
301
- notes.push(`the vault at ${vault.addr} did not answer (${error.message}; a private CA is not read from BAO_CACERT/VAULT_CACERT here)`);
302
+ notes.push(`the vault at ${vault.addr} did not answer (${errorMessage(error)}; a private CA is not read from BAO_CACERT/VAULT_CACERT here)`);
302
303
  }
303
304
  if (response) {
304
305
  if (response.status === 403)
@@ -526,7 +527,7 @@ export async function refreshTwin(opts) {
526
527
  // failure — its reason and how many in a row — so the status door and the twin's status say so
527
528
  const held = store.read(marker);
528
529
  const previous = held !== null ? JSON.parse(held) : {};
529
- const reason = error instanceof Error ? error.message : String(error);
530
+ const reason = errorMessage(error);
530
531
  store.write(marker, `${JSON.stringify({ ...previous, at: new Date().toISOString(), failed: { at, reason, count: (previous.failed?.count ?? 0) + 1 } })}\n`);
531
532
  throw error;
532
533
  }
@@ -1,4 +1,5 @@
1
1
  import { runWorldOwned } from "./runtime.js";
2
+ import { errorMessage } from "./error-message.js";
2
3
  // Only this process owns the IPC endpoint; child stdio explicitly excludes fd 3. A lost
3
4
  // caller requests cancellation, including while startup is still settling. No service restarts.
4
5
  if (!process.send)
@@ -27,7 +28,7 @@ process.on('message', async (message) => {
27
28
  reply = { result: await runWorldOwned(request.configId, request.command, request.options, abort.signal) };
28
29
  }
29
30
  catch (error) {
30
- reply = { error: error instanceof Error ? error.message : String(error) };
31
+ reply = { error: errorMessage(error) };
31
32
  }
32
33
  if ('error' in reply)
33
34
  process.stderr.write(`${reply.error}\n`);