@volter/world-runtime 3.0.38 → 3.0.40

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,33 +1 @@
1
- import { spawnSync } from 'node:child_process';
2
-
3
- /** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
4
- * descendants exited. Zombies hold no resources; unknown inspection remains live. This is
5
- * retirement evidence, never an ownership token or a way to discover processes to kill. */
6
- export function survivingOwnedGroups(pids: number[]): number[] {
7
- const candidates = pids.filter(pid => {
8
- try { process.kill(process.platform === 'win32' ? pid : -pid, 0); return true; }
9
- catch (error) {
10
- if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true;
11
- try { process.kill(pid, 0); return true; }
12
- catch (cause) { return (cause as NodeJS.ErrnoException).code !== 'ESRCH'; }
13
- }
14
- });
15
- if (!candidates.length || process.platform === 'win32') return candidates;
16
- const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
17
- encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
18
- });
19
- if (result.status !== 0 || result.error) return candidates;
20
- const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
21
- return candidates.filter(pid => {
22
- const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
23
- // Visibility restrictions can omit a live group. Only ESRCH proves absence.
24
- if (!members.length) {
25
- for (const target of [-pid, pid]) {
26
- try { process.kill(target, 0); return true; }
27
- catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true; }
28
- }
29
- return false;
30
- }
31
- return members.some(([, , state]) => !state || !state.startsWith('Z'));
32
- });
33
- }
1
+ export { survivingOwnedGroups, processStarts } from './process-groups.mjs';
@@ -0,0 +1,6 @@
1
+ import type { ReceiptRead, SpawnIdentity } from './process-receipt.js';
2
+ export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
3
+ export function publishProcessReceipt(path: string, text: string): void;
4
+ export function readProcessReceipt<T>(path: string, parse: (text: string) => T): ReceiptRead<T>;
5
+ export function spawnReceiptPath(path: string): string;
6
+ export function readSpawnReceipt(path: string): ReceiptRead<SpawnIdentity>;
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
5
+
6
+ /** A failed publication never truncates the previous receipt. */
7
+ export function publishProcessReceipt(path, text) {
8
+ const temporary = `${path}.${randomUUID()}.tmp`;
9
+ let descriptor;
10
+ const failures = [];
11
+ try {
12
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
13
+ fs.writeFileSync(descriptor, text);
14
+ fs.fsyncSync(descriptor);
15
+ fs.closeSync(descriptor); descriptor = undefined;
16
+ fs.renameSync(temporary, path);
17
+ descriptor = fs.openSync(dirname(path), 'r');
18
+ fs.fsyncSync(descriptor);
19
+ } catch (error) { failures.push(error); }
20
+ if (descriptor !== undefined) {
21
+ try { fs.closeSync(descriptor); } catch (error) { failures.push(error); }
22
+ }
23
+ try { fs.rmSync(temporary, { force: true }); } catch (error) { failures.push(error); }
24
+ if (failures.length) throw new AggregateError(failures, `Process receipt publication failed: ${path}`);
25
+ }
26
+
27
+ /** Unreadable is uncertainty, not absence; a separate spawn receipt may still establish ownership. */
28
+ export function readProcessReceipt(path, parse) {
29
+ try { return { present: true, value: parse(fs.readFileSync(path, 'utf8')) }; }
30
+ catch (cause) {
31
+ if (cause.code === 'ENOENT') return { present: false };
32
+ return { present: true, error: new Error(`Process receipt unreadable; ownership uncertain: ${path}`, { cause }) };
33
+ }
34
+ }
35
+
36
+ export const spawnReceiptPath = (path) => `${path}.spawn.json`;
37
+ export function readSpawnReceipt(path) {
38
+ return readProcessReceipt(spawnReceiptPath(path), text => {
39
+ const value = JSON.parse(text);
40
+ if (!Number.isInteger(value?.pid) || value.pid <= 0 || (typeof value.start !== 'string' || !value.start.trim())) {
41
+ throw new Error('Invalid process spawn identity');
42
+ }
43
+ return value;
44
+ });
45
+ }
@@ -0,0 +1,5 @@
1
+ // Native Node guests and the World use one atomic process-receipt implementation.
2
+ export { spawnIdentity, verifySpawnIdentity, signalOwnedProcess } from './process-signal.mjs';
3
+ export { publishProcessReceipt, readProcessReceipt, spawnReceiptPath, readSpawnReceipt } from './process-receipt.mjs';
4
+ export interface ReceiptRead<T> { present: boolean; value?: T; error?: Error }
5
+ export interface SpawnIdentity { pid: number; start?: string }
@@ -0,0 +1,9 @@
1
+ import type { SpawnIdentity } from './process-receipt.js';
2
+ import type { ChildProcess } from 'node:child_process';
3
+ export function processBirth(pid: number): Promise<string | undefined>;
4
+ export function spawnIdentity(pid: number): Promise<SpawnIdentity>;
5
+ export function verifySpawnIdentity(identity: SpawnIdentity | undefined): Promise<void>;
6
+ export function signalOwnedProcess(identity: SpawnIdentity | undefined, signal: NodeJS.Signals, group?: boolean): Promise<boolean>;
7
+ export function signalChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean): boolean;
8
+ export function signalHeldChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, birth?: Promise<SpawnIdentity | undefined>): Promise<boolean>;
9
+ export function childIdentity(child: ChildProcess): Promise<SpawnIdentity | undefined>;
@@ -0,0 +1,92 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ // Retain the existing 1000 ms inspection ceiling, not a new timeout chosen from one sample.
5
+ // Measured 2026-10-03 on shared Darwin 25.4.0 ARM64, Bun 1.3.11 (process.version
6
+ // v24.3.0), parent 38b5290 plus this change; no Cal server, one live driver PID:
7
+ // `/usr/bin/time -p /bin/ps -o pid=,lstart= -p <driver-pid>` with LC_ALL/LANG=C,
8
+ // TZ=UTC took 9.26 ms from spawn to close; time printed real/user/sys 0.00 s.
9
+ // This includes the time wrapper/event-loop overhead, not an isolated benchmark.
10
+ // Full command/output: cookbook/calcom/output/held-backing-lifecycle.txt.
11
+ const BIRTH_LOOKUP_MS = 1000;
12
+ /** No subprocess on Linux; elsewhere the lookup never blocks the lifecycle event loop. */
13
+ export async function processBirth(pid) {
14
+ if (process.platform === 'win32') return undefined;
15
+ if (process.platform === 'linux') {
16
+ try {
17
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
18
+ const tick = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
19
+ return /^\d+$/.test(tick ?? '') ? `tick ${tick}` : undefined;
20
+ } catch { return undefined; }
21
+ }
22
+ return new Promise(resolve => {
23
+ const child = spawn('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], {
24
+ windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
25
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
26
+ });
27
+ let output = '', settled = false;
28
+ const finish = value => { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(value); } };
29
+ // This is our still-held inspector, not a PID from a receipt.
30
+ const timer = setTimeout(() => {
31
+ try { signalChild(child, 'SIGKILL'); } catch { /* failed inspection remains unavailable */ }
32
+ finish(undefined); // an exited wrapper's descendant may keep stdout open indefinitely
33
+ }, BIRTH_LOOKUP_MS);
34
+ child.stdout.on('data', chunk => { output += chunk; });
35
+ child.once('error', () => finish(undefined));
36
+ child.once('close', code => {
37
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(output.trim());
38
+ finish(code === 0 && match && Number(match[1]) === pid ? match[2] : undefined);
39
+ });
40
+ });
41
+ }
42
+ /** Capture at spawn for durable ownership, never when retrying a retained PID. */
43
+ export async function spawnIdentity(pid) {
44
+ const start = await processBirth(pid);
45
+ return { pid, ...(start === undefined ? {} : { start }) };
46
+ }
47
+ /** An async capture is accepted only while its original child is still held and live. */
48
+ export async function childIdentity(child) {
49
+ if (!child.pid) return undefined;
50
+ const identity = await spawnIdentity(child.pid);
51
+ return child.exitCode === null && child.signalCode === null ? identity : undefined;
52
+ }
53
+ /** A birth token is required even when an absent leader leaves a surviving group. */
54
+ export async function verifySpawnIdentity(identity) {
55
+ if (!identity || !Number.isInteger(identity.pid) || identity.pid <= 0 || typeof identity.start !== 'string' || !identity.start.trim()) {
56
+ throw new Error('Process recorded birth token missing; retirement refused; ownership retained');
57
+ }
58
+ const current = await processBirth(identity.pid);
59
+ if (current !== undefined && current !== identity.start) {
60
+ throw new Error(`Process ${identity.pid} no longer matches its recorded spawn identity; retirement refused`);
61
+ }
62
+ if (current === undefined) {
63
+ try { process.kill(identity.pid, 0); }
64
+ catch (error) { if (error.code === 'ESRCH') return; throw error; }
65
+ throw new Error(`Process ${identity.pid} identity cannot be established; ownership retained`);
66
+ }
67
+ }
68
+ /** Stored or retained numbers use birth verification before every delivery. */
69
+ export async function signalOwnedProcess(identity, signal, group = false) {
70
+ await verifySpawnIdentity(identity);
71
+ try { return process.kill(group && process.platform !== 'win32' ? -identity.pid : identity.pid, signal); }
72
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
73
+ }
74
+ /** A live ChildProcess is ownership until exit. A group additionally needs its leader alive.
75
+ * False means no held delivery was possible; a caller with surviving descendants needs a birth. */
76
+ export function signalChild(child, signal, group = false) {
77
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
78
+ if (!group || process.platform === 'win32') return child.kill(signal);
79
+ try { process.kill(child.pid, 0); }
80
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
81
+ try { return process.kill(-child.pid, signal); }
82
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
83
+ }
84
+
85
+ /** Held first; an orphaned group then becomes retained numeric ownership. */
86
+ export async function signalHeldChild(child, signal, group = false, birth = Promise.resolve(undefined)) {
87
+ if (signalChild(child, signal, group)) return true;
88
+ if (!group || process.platform === 'win32' || !child.pid) return false;
89
+ try { process.kill(-child.pid, 0); }
90
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
91
+ return signalOwnedProcess(await birth, signal, true);
92
+ }
@@ -554,7 +554,7 @@ export function connectionProtocol(value: string): string | null {
554
554
  /** Native connections are independent of the vendor's HTTP SDK. Inspect ordinary URL schemes
555
555
  * and Prisma datasource declarations; never infer a selected Prisma adapter from its presence
556
556
  * in package.json. Dynamic/config-file-only datasources remain unknown, as do unbound drivers. */
557
- export function projectConnections(input = process.cwd(), attributedEnv = new Set<string>()): ProjectConnection[] {
557
+ export function projectConnections(input = process.cwd(), attributedEnv = new Set<string>(), boundProtocols: ReadonlyMap<string, string> = new Map()): ProjectConnection[] {
558
558
  const root = resolve(input);
559
559
  const found: ProjectConnection[] = [];
560
560
  const dependencies = projectDependencies(root);
@@ -575,7 +575,9 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
575
575
  // DATABASE_URL has none and must stay unknown, even beside an unrelated HTTP SDK.
576
576
  if (connectionProtocol(resolved) === null && attributedEnv.has(match[1]!) &&
577
577
  (/^https?:/i.test(resolved) || /_REST_URL$/.test(match[1]!))) continue;
578
- found.push({ protocol: connectionProtocol(resolved), env: match[1]!, sources: [`env: ${relative(root, resolve(dir, file))}:${index + 1} ${match[1]}`] });
578
+ // An empty example leaves the actual value to environment injection. Only an owned binding supplies its protocol.
579
+ const protocol = connectionProtocol(resolved) ?? (value.trim() === '' ? boundProtocols.get(match[1]!) ?? null : null);
580
+ found.push({ protocol, env: match[1]!, sources: [`env: ${relative(root, resolve(dir, file))}:${index + 1} ${match[1]}`] });
579
581
  }
580
582
  }
581
583
  }
@@ -2,6 +2,7 @@
2
2
  // Detached entry for the ambient TLS redirect proxy. `activate` spawns this as its own process so a
3
3
  // long-lived proxy can serve the activated shell(s) for the world's lifetime; it records its
4
4
  // pid/url/CA in the instance dir and is stopped by `volter-world down`. Not a user-facing command.
5
+ import { errorMessage } from './error-message.ts';
5
6
  import { runProxyDaemon } from './runtime.ts';
6
7
 
7
8
  const [name, ...rest] = process.argv.slice(2);
@@ -16,6 +17,6 @@ if (!name) {
16
17
  }
17
18
 
18
19
  runProxyDaemon(name, root, envFile).catch((error) => {
19
- process.stderr.write(`proxy-daemon: ${error instanceof Error ? error.message : String(error)}\n`);
20
+ process.stderr.write(`proxy-daemon: ${errorMessage(error)}\n`);
20
21
  process.exit(1);
21
22
  });
@@ -1,5 +1,6 @@
1
+ import { errorMessage } from './error-message.ts';
1
2
  import { serveHttp, type HttpServer } 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.ts';
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, Socket, 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.ts';
40
+ import { packFacts } from './pack-facts.ts';
36
41
  import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
37
42
 
38
43
  /**
@@ -160,7 +165,7 @@ export async function guestDestination(host: string): Promise<{ address: string
160
165
  const h = host.replace(/^\[|\]$/g, '');
161
166
  let addresses: string[];
162
167
  try { addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address); } catch (error) {
163
- return { refused: `${host} does not resolve (${(error as Error).message})` };
168
+ return { refused: `${host} does not resolve (${errorMessage(error)})` };
164
169
  }
165
170
  const bad = addresses.find(isGuestForbiddenAddress);
166
171
  if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach` };
@@ -309,7 +314,7 @@ type RedirectProxyHandle = {
309
314
  * of which World to look at (`doctor`). Shared by every front that relays to twins. */
310
315
  export function twinUnreachableMessage(vendor: string, origin: string, err: Error, world?: string): string {
311
316
  const cause = (err as { cause?: { code?: string; message?: string } }).cause;
312
- 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`;
317
+ 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`;
313
318
  }
314
319
 
315
320
  /** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
@@ -336,6 +341,94 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
336
341
  // or real upstream would otherwise keep the server from closing).
337
342
  const openSockets = new Set<Socket>();
338
343
 
344
+ const publicBuildRequest = (target: URL, env: Record<string, string | undefined>): boolean => {
345
+ const policy = worldNetworkPolicyFromEnv(env);
346
+ return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
347
+ };
348
+
349
+ // One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
350
+ // It never follows redirects and never lends the upstream socket to the caller.
351
+ const externalRequest = async (target: URL, method: string, incoming: Headers, body: ReadableStream | null, signal?: AbortSignal): Promise<Response> => {
352
+ const env = currentEnv();
353
+ const policy = worldNetworkPolicyFromEnv(env);
354
+ // the method is visible here: an egress grant reads, a write needs `writes`
355
+ if (!policy || !allowsWorldNetworkEgress(policy, target, method) || shouldBlockUntwinned(target, env)) throw new Error('World policy refuses the effective request');
356
+ const host = incoming.get('host');
357
+ if (host && new URL(`${target.protocol}//${host}`).host !== target.host) throw new Error('request Host differs from authorized authority');
358
+ if (method === 'CONNECT' || incoming.has('upgrade')) throw new Error('inspected egress cannot upgrade its transport');
359
+ if (!body && (Number(incoming.get('content-length') || 0) > 0 || incoming.has('transfer-encoding'))) throw new Error('transport cannot mediate this request body');
360
+ const headers = new Headers(incoming);
361
+ const hopHeaders = (headers.get('connection') ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
362
+ 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']) headers.delete(h);
363
+ return await new Promise<Response>((resolve, reject) => {
364
+ const upstream = httpsRequest({ hostname: target.hostname, port: Number(target.port || 443), servername: target.hostname,
365
+ path: target.pathname + target.search, method, headers: { ...Object.fromEntries(headers), host: target.host },
366
+ agent: false, ca: readFileSync(trust.SSL_CERT_FILE!), signal,
367
+ }, res => {
368
+ const out = new Headers();
369
+ const nominated = String(res.headers.connection ?? '').split(',').map(h => h.trim().toLowerCase());
370
+ for (const [key, value] of Object.entries(res.headers)) if (value !== undefined && ![...nominated, 'connection', 'transfer-encoding', 'keep-alive', 'trailer', 'upgrade'].includes(key)) {
371
+ for (const part of Array.isArray(value) ? value : [value]) out.append(key, String(part));
372
+ }
373
+ const empty = method === 'HEAD' || [204, 205, 304].includes(res.statusCode!);
374
+ if (empty) res.resume();
375
+ note(`inspected egress ${method} ${target.origin}${target.pathname} -> ${res.statusCode}`);
376
+ resolve(new Response(empty ? null : Readable.toWeb(res) as unknown as ReadableStream, { status: res.statusCode!, headers: out }));
377
+ });
378
+ upstream.on('socket', socket => { openSockets.add(socket); socket.on('close', () => openSockets.delete(socket)); });
379
+ upstream.on('error', reject);
380
+ upstream.on('upgrade', (_res, socket) => { socket.destroy(); reject(new Error('upstream upgrade refused')); });
381
+ if (body) {
382
+ const source = Readable.fromWeb(body as unknown as import('node:stream/web').ReadableStream);
383
+ source.on('error', error => upstream.destroy(error));
384
+ upstream.on('error', () => source.destroy());
385
+ source.pipe(upstream);
386
+ } else upstream.end();
387
+ });
388
+ };
389
+
390
+ // Use the HTTP parser for framing; never forward trailing bytes as a second raw request.
391
+ const usedPlainSockets = new WeakSet<Socket>();
392
+ const inspectedPlain = createHttpServer((req, res) => {
393
+ if (usedPlainSockets.has(req.socket)) { note('inspected egress refused extra plain request'); req.socket.destroy(); return; }
394
+ usedPlainSockets.add(req.socket);
395
+ res.shouldKeepAlive = false;
396
+ res.setHeader('connection', 'close');
397
+ res.on('finish', () => req.socket.end());
398
+ const abort = new AbortController();
399
+ req.on('aborted', () => abort.abort());
400
+ res.on('close', () => { if (!res.writableFinished) abort.abort(); });
401
+ void (async () => {
402
+ if (/\\/.test(req.url!.split(/[?#]/, 1)[0]!)) throw new Error('backslash in proxy target');
403
+ const target = new URL(req.url!);
404
+ if (proxyTargetFor(target.hostname, currentEnv()) && !publicBuildRequest(target, currentEnv())) throw new Error('inspected external request names a twinned host');
405
+ const headers = new Headers();
406
+ for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i]!, req.rawHeaders[i + 1]!);
407
+ const body = headers.has('transfer-encoding') || Number(headers.get('content-length') || 0) > 0 ? Readable.toWeb(req) as unknown as ReadableStream : null;
408
+ const response = await externalRequest(target, req.method!, headers, body, abort.signal);
409
+ res.statusCode = response.status;
410
+ for (const [key, value] of response.headers) res.setHeader(key, value);
411
+ if (response.body) {
412
+ const stream = Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream);
413
+ stream.on('error', () => res.destroy());
414
+ res.on('close', () => stream.destroy());
415
+ stream.pipe(res);
416
+ } else res.end();
417
+ })().catch(error => { if (!res.headersSent) { res.statusCode = 502; res.end(note(`${(error as Error).message}\n`)); } else res.destroy(); });
418
+ });
419
+ inspectedPlain.maxRequestsPerSocket = 1;
420
+ inspectedPlain.on('dropRequest', (req, socket) => { note('inspected egress refused extra plain request'); socket.destroy(); });
421
+ inspectedPlain.on('clientError', (_error, socket) => socket.destroy());
422
+ // Bun's Node HTTP adapter parses only on a listening server, not a synthetic connection event.
423
+ // Relays below carry bytes solely to this local parser; only externalRequest opens public sockets.
424
+ await new Promise<void>((resolve, reject) => {
425
+ inspectedPlain.once('error', reject);
426
+ inspectedPlain.listen(0, '127.0.0.1', resolve);
427
+ });
428
+ const inspectedAddress = inspectedPlain.address();
429
+ if (!inspectedAddress || typeof inspectedAddress === 'string') throw new Error('inspected HTTP parser has no TCP address');
430
+ const inspectedPort = inspectedAddress.port;
431
+
339
432
  // The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
340
433
  // to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
341
434
  // server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
@@ -345,14 +438,17 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
345
438
  // websocket is relayed message by message to the twin's own websocket at the same path.
346
439
  const tlsServers = new Map<string, HttpServer>();
347
440
  const tlsPorts = new Map<string, number>();
348
- const ensureHostServer = async (vendorHost: string): Promise<number> => {
349
- const existing = tlsPorts.get(vendorHost);
441
+ const ensureHostServer = async (vendorHost: string, guest = false, originPort = 443): Promise<number> => {
442
+ const serverKey = `${vendorHost}:${originPort}:${guest}`;
443
+ const existing = tlsPorts.get(serverKey);
350
444
  if (existing) return existing;
351
445
  const leaf = leafCertFor(vendorHost, ca);
352
446
  const hostServer = await serveHttp({
353
447
  hostname: '127.0.0.1',
354
448
  port: 0,
355
449
  tls: { key: leaf.key, cert: leaf.cert },
450
+ // Carry a twin's declared transport cap through TLS termination; public grants add no cap.
451
+ maxRequestBodySize: (packFacts()[proxyTargetFor(vendorHost, currentEnv())?.vendor ?? ''] as { maxRequestBodySize?: number } | undefined)?.maxRequestBodySize,
356
452
  // 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
357
453
  // default cut those connections and the client read an empty reply. Bun's maximum, as reflect.ts serves.
358
454
  idleTimeout: 255,
@@ -363,12 +459,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
363
459
  // The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
364
460
  // share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
365
461
  // for the host and merely not for this path, and the bare message sends the next reader hunting.
366
- const twin = proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
462
+ const effective = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
463
+ const publicBuild = publicBuildRequest(effective, env);
464
+ const twin = publicBuild ? null : proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
367
465
  if (!twin) {
368
466
  // a host the World refuses (sealed, or outside its network policy): answered here, over the World's TLS, so
369
467
  // a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
370
468
  // leaves the World
371
- if (shouldBlockUntwinned(`https://${hostHeader}`, env)) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
469
+ const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
470
+ if (shouldBlockUntwinned(target.href, env)) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
471
+ if (!publicBuild && proxyTargetFor(vendorHost, env)) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
472
+ const policy = worldNetworkPolicyFromEnv(env);
473
+ if (policy && worldNetworkNeedsPathInspection(policy, target)) {
474
+ if (guest) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), 'guest inspected egress cannot preserve its checked destination pin');
475
+ try { return await externalRequest(target, req.method, req.headers, req.body, req.signal); }
476
+ catch (error) { return new Response(note(`inspected egress refused: ${(error as Error).message}\n`), { status: 502 }); }
477
+ }
372
478
  const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
373
479
  if (unreachable) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
374
480
  return new Response(note(noTwinMessage(hostHeader, u.pathname)), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8' } });
@@ -414,8 +520,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
414
520
  }),
415
521
  });
416
522
  const p = hostServer.port ?? 0;
417
- tlsServers.set(vendorHost, hostServer);
418
- tlsPorts.set(vendorHost, p);
523
+ tlsServers.set(serverKey, hostServer);
524
+ tlsPorts.set(serverKey, p);
419
525
  return p;
420
526
  };
421
527
 
@@ -453,7 +559,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
453
559
  return;
454
560
  }
455
561
 
456
- handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest);
562
+ handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest, buffered);
457
563
  };
458
564
  clientSocket.on('data', onData);
459
565
  };
@@ -524,7 +630,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
524
630
  : netConnect(Number(origin.port || 80), connectHost, connected);
525
631
  openSockets.add(upstream);
526
632
  upstream.on('close', () => openSockets.delete(upstream));
527
- upstream.on('error', (err: Error) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${err.message}\n`)));
633
+ upstream.on('error', (err: Error) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${errorMessage(err)}\n`)));
528
634
  };
529
635
 
530
636
  const handlePlainProxyRequest = (
@@ -535,10 +641,12 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
535
641
  socket: Socket,
536
642
  head: Buffer,
537
643
  guest: boolean,
644
+ raw: Buffer,
538
645
  ): void => {
539
646
  try {
540
647
  const u = new URL(urlStr);
541
- const twin = proxyTargetFor(u.hostname, currentEnv(), u.pathname);
648
+ const publicBuild = publicBuildRequest(u, currentEnv());
649
+ const twin = publicBuild ? null : proxyTargetFor(u.hostname, currentEnv(), u.pathname);
542
650
  const path = u.pathname + u.search;
543
651
  if (twin) {
544
652
  const target = new URL(twin.origin);
@@ -550,7 +658,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
550
658
  // No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
551
659
  // an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
552
660
  // handler's noTwinMessage), never pass through to the real vendor.
553
- if (proxyTargetFor(u.hostname, currentEnv())) {
661
+ if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
554
662
  writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
555
663
  return;
556
664
  }
@@ -558,6 +666,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
558
666
  writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
559
667
  return;
560
668
  }
669
+ const policy = worldNetworkPolicyFromEnv(currentEnv());
670
+ if (policy && worldNetworkNeedsPathInspection(policy, u)) {
671
+ if (guest) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n')); return; }
672
+ socket.pause();
673
+ const parser = netConnect(inspectedPort, '127.0.0.1', () => {
674
+ parser.write(raw);
675
+ socket.pipe(parser);
676
+ parser.pipe(socket);
677
+ socket.resume();
678
+ });
679
+ openSockets.add(parser);
680
+ parser.on('close', () => { openSockets.delete(parser); socket.destroy(); });
681
+ parser.on('error', () => socket.destroy());
682
+ socket.on('close', () => parser.destroy());
683
+ return;
684
+ }
561
685
  // Non-vendor: plain pass-through to the real host; for a World Machine, only off its host's own network, and to
562
686
  // the address checked (architecture, "The scoped proxy's local pass-through")
563
687
  if (guest) {
@@ -593,7 +717,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
593
717
  // a destination that cannot be reached at all (a name that does not resolve, a refused port) is answered by the
594
718
  // caller's fallback when it has one, before the tunnel was ever established; otherwise the client's socket ends
595
719
  upstream.on('error', (error: NodeJS.ErrnoException) => {
596
- if (!connected && unreachable && unreachable(error.code ?? error.message)) return;
720
+ if (!connected && unreachable && unreachable(error.code ?? errorMessage(error))) return;
597
721
  clientSocket.destroy();
598
722
  });
599
723
  };
@@ -603,13 +727,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
603
727
  note(`${connectHost}:${connectPort} did not answer (${reason})`);
604
728
  refusedPages.add(connectHost);
605
729
  unreachableHosts.set(connectHost, reason);
606
- 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());
607
731
  return true;
608
732
  };
609
733
  // MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
610
734
  // the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
611
- if (twin && connectHost) {
612
- 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) note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
740
+ if (inspected && guest && !twin) {
741
+ writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
742
+ return;
743
+ }
744
+ if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
745
+ ensureHostServer(connectHost, guest, connectPort)
613
746
  .then((port) => tunnel(port, '127.0.0.1'))
614
747
  .catch(() => clientSocket.destroy());
615
748
  } else if (connectHost && shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv())) {
@@ -620,7 +753,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
620
753
  // line, as before
621
754
  if (connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX)) {
622
755
  refusedPages.add(connectHost);
623
- ensureHostServer(connectHost)
756
+ ensureHostServer(connectHost, guest, connectPort)
624
757
  .then((port) => tunnel(port, '127.0.0.1'))
625
758
  .catch(() => clientSocket.destroy());
626
759
  } else {
@@ -669,6 +802,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
669
802
  for (const s of openSockets) { try { s.destroy(); } catch { /* ignore */ } }
670
803
  openSockets.clear();
671
804
  for (const hostServer of tlsServers.values()) { try { hostServer.stop(true); } catch { /* ignore */ } }
805
+ inspectedPlain.closeAllConnections();
806
+ inspectedPlain.close();
672
807
  guestServer.close();
673
808
  server.close(() => resolveClose());
674
809
  }),