@volter/world-runtime 3.0.162 → 3.0.164

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 (140) hide show
  1. package/dist/src/ancestry.js +2 -2
  2. package/dist/src/app-url.d.ts +5 -1
  3. package/dist/src/app-url.js +14 -1
  4. package/dist/src/branch.js +28 -8
  5. package/dist/src/browser-files.d.ts +5 -0
  6. package/dist/src/browser-files.js +14 -0
  7. package/dist/src/browsers.d.ts +2 -3
  8. package/dist/src/browsers.js +4 -4
  9. package/dist/src/catalog.d.ts +2 -1
  10. package/dist/src/catalog.js +3 -2
  11. package/dist/src/changeset.js +1 -1
  12. package/dist/src/cli-commands.js +71 -18
  13. package/dist/src/cli-load-clock.d.ts +1 -0
  14. package/dist/src/cli-load-clock.js +2 -0
  15. package/dist/src/cli.js +5 -1
  16. package/dist/src/clickhouse-backing.d.ts +2 -0
  17. package/dist/src/clickhouse-backing.js +145 -0
  18. package/dist/src/command-lifetime.d.mts +1 -1
  19. package/dist/src/command-lifetime.mjs +5 -2
  20. package/dist/src/configs.js +1 -1
  21. package/dist/src/consumers.js +1 -1
  22. package/dist/src/covers.d.ts +4 -2
  23. package/dist/src/covers.js +8 -4
  24. package/dist/src/go-mysql-binding.d.ts +19 -0
  25. package/dist/src/go-mysql-binding.js +51 -0
  26. package/dist/src/guest-processes.d.mts +1 -1
  27. package/dist/src/guest-processes.mjs +22 -14
  28. package/dist/src/host-cli.js +8 -1
  29. package/dist/src/host-ready.d.ts +16 -2
  30. package/dist/src/host-ready.js +66 -39
  31. package/dist/src/host-worker.js +7 -1
  32. package/dist/src/host.d.ts +26 -0
  33. package/dist/src/host.js +93 -3
  34. package/dist/src/infra-definition.d.ts +35 -0
  35. package/dist/src/infra-definition.js +146 -0
  36. package/dist/src/infra.d.ts +3 -0
  37. package/dist/src/infra.js +375 -105
  38. package/dist/src/init.d.ts +46 -0
  39. package/dist/src/init.js +445 -18
  40. package/dist/src/lifecycle-record.d.ts +5 -1
  41. package/dist/src/lifecycle-record.js +27 -4
  42. package/dist/src/local-branches.js +1 -1
  43. package/dist/src/mariadb-bootstrap.d.ts +32 -0
  44. package/dist/src/mariadb-bootstrap.js +90 -0
  45. package/dist/src/mariadb-supplier.d.ts +23 -0
  46. package/dist/src/mariadb-supplier.js +215 -0
  47. package/dist/src/mongodb-backing.d.ts +4 -0
  48. package/dist/src/mongodb-backing.js +71 -1
  49. package/dist/src/native-mariadb-backing.d.ts +13 -0
  50. package/dist/src/native-mariadb-backing.js +512 -0
  51. package/dist/src/native-postgres-backing.d.ts +4 -10
  52. package/dist/src/native-postgres-backing.js +428 -43
  53. package/dist/src/native-server-inputs.d.ts +16 -0
  54. package/dist/src/native-server-inputs.js +204 -0
  55. package/dist/src/origin.js +3 -3
  56. package/dist/src/original-inputs.d.ts +14 -0
  57. package/dist/src/original-inputs.js +84 -0
  58. package/dist/src/pack-facts.d.ts +9 -1
  59. package/dist/src/pglite-backing.d.ts +41 -17
  60. package/dist/src/pglite-backing.js +213 -79
  61. package/dist/src/pglite-host.mjs +482 -10
  62. package/dist/src/process-groups.d.mts +6 -1
  63. package/dist/src/process-groups.d.ts +2 -1
  64. package/dist/src/process-groups.js +1 -1
  65. package/dist/src/process-groups.mjs +82 -15
  66. package/dist/src/process-signal.d.mts +2 -2
  67. package/dist/src/process-signal.mjs +29 -9
  68. package/dist/src/project-inspect.d.ts +12 -0
  69. package/dist/src/project-inspect.js +41 -4
  70. package/dist/src/proxy-env.d.ts +34 -0
  71. package/dist/src/proxy-env.js +114 -0
  72. package/dist/src/redirect-proxy.d.ts +1 -31
  73. package/dist/src/redirect-proxy.js +3 -112
  74. package/dist/src/redis-backing.d.ts +6 -4
  75. package/dist/src/redis-backing.js +20 -168
  76. package/dist/src/runtime.d.ts +1 -0
  77. package/dist/src/runtime.js +503 -218
  78. package/dist/src/schema.d.ts +1 -0
  79. package/dist/src/schema.js +5 -3
  80. package/dist/src/served-world.js +15 -10
  81. package/dist/src/service-exit.d.ts +3 -2
  82. package/dist/src/service-exit.js +7 -5
  83. package/dist/src/twin-backing.d.ts +47 -0
  84. package/dist/src/twin-backing.js +343 -0
  85. package/dist/src/up-cli.js +56 -3
  86. package/package.json +4 -4
  87. package/src/ancestry.ts +2 -2
  88. package/src/app-url.ts +9 -2
  89. package/src/branch.ts +26 -8
  90. package/src/browser-files.ts +15 -0
  91. package/src/browsers.ts +4 -4
  92. package/src/catalog.ts +4 -3
  93. package/src/changeset.ts +1 -1
  94. package/src/cli-commands.ts +85 -18
  95. package/src/cli-load-clock.ts +2 -0
  96. package/src/cli.ts +5 -1
  97. package/src/clickhouse-backing.ts +70 -0
  98. package/src/command-lifetime.d.mts +1 -1
  99. package/src/command-lifetime.mjs +5 -2
  100. package/src/configs.ts +1 -1
  101. package/src/consumers.ts +1 -1
  102. package/src/covers.ts +9 -4
  103. package/src/go-mysql-binding.ts +49 -0
  104. package/src/guest-processes.d.mts +1 -1
  105. package/src/guest-processes.mjs +22 -14
  106. package/src/host-cli.ts +7 -1
  107. package/src/host-ready.ts +62 -31
  108. package/src/host-worker.ts +6 -2
  109. package/src/host.ts +88 -6
  110. package/src/infra-definition.ts +135 -0
  111. package/src/infra.ts +250 -45
  112. package/src/init.ts +416 -17
  113. package/src/lifecycle-record.ts +18 -4
  114. package/src/local-branches.ts +1 -1
  115. package/src/mariadb-bootstrap.ts +107 -0
  116. package/src/mariadb-supplier.ts +199 -0
  117. package/src/mongodb-backing.ts +59 -1
  118. package/src/native-mariadb-backing.ts +418 -0
  119. package/src/native-postgres-backing.ts +353 -41
  120. package/src/native-server-inputs.ts +169 -0
  121. package/src/origin.ts +3 -3
  122. package/src/original-inputs.ts +72 -0
  123. package/src/pack-facts.ts +1 -1
  124. package/src/pglite-backing.ts +192 -80
  125. package/src/pglite-host.mjs +482 -10
  126. package/src/process-groups.d.mts +6 -1
  127. package/src/process-groups.mjs +82 -15
  128. package/src/process-groups.ts +2 -1
  129. package/src/process-signal.d.mts +2 -2
  130. package/src/process-signal.mjs +29 -9
  131. package/src/project-inspect.ts +37 -4
  132. package/src/proxy-env.ts +128 -0
  133. package/src/redirect-proxy.ts +3 -125
  134. package/src/redis-backing.ts +19 -136
  135. package/src/runtime.ts +342 -107
  136. package/src/schema.ts +5 -4
  137. package/src/served-world.ts +14 -10
  138. package/src/service-exit.ts +5 -3
  139. package/src/twin-backing.ts +300 -0
  140. package/src/up-cli.ts +52 -3
@@ -1,10 +1,37 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';
3
+
4
+ /** WHERE A LIFECYCLE SAYS WHAT IT DID TO A PROCESS. Every signal this runtime delivers, every stop and exit of a
5
+ * backing service, and every read of the process table that failed or was slow is one JSON line in the World's
6
+ * events file (logs/events.jsonl), so a stop that went wrong is read from the World's own log. A lifecycle with no
7
+ * World's log to write in (a preparation run alone) says the same line on stderr. An instrument: it changes no
8
+ * outcome and never throws. */
9
+ let eventsFile;
10
+ export function setWorldEventsFile(path) { eventsFile = path && existsSync(path.slice(0, path.lastIndexOf('/'))) ? path : undefined; }
11
+ export function worldEvent(record) {
12
+ let line;
13
+ try { line = `${JSON.stringify({ ...record, process: process.pid, at: new Date().toISOString() })}\n`; } catch { return; }
14
+ if (eventsFile) { try { appendFileSync(eventsFile, line, { mode: 0o600 }); return; } catch { /* said on stderr instead */ } }
15
+ try { process.stderr.write(`[world-event] ${line}`); } catch { /* an instrument */ }
16
+ }
17
+ /** A read of the process table that did not answer, or took longer than this, is said with its time and outcome. */
18
+ const PROBE_SAID_MS = 250;
19
+ export function sayProbe(command, startedAt, outcome) {
20
+ const ms = Date.now() - startedAt;
21
+ if (outcome === 'answered' && ms < PROBE_SAID_MS) return;
22
+ worldEvent({ event: 'world.probe', command, ms, outcome });
23
+ }
24
+ const probeOutcome = result => result.error ? `error ${result.error.code ?? result.error.message}` : result.signal ? `killed ${result.signal}` : result.status === 0 ? 'answered' : `exit ${result.status}`;
3
25
 
4
26
  /** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
5
27
  * descendants exited. Zombies hold no resources; unknown inspection remains live. This is
6
- * retirement evidence, never an ownership token or a way to discover processes to kill. */
7
- export function survivingOwnedGroups(pids) {
28
+ * retirement evidence, never an ownership token or a way to discover processes to kill.
29
+ *
30
+ * `listing` is one reading of the process table shared by the questions of one decision: an empty object from the
31
+ * caller, which the first question that needs the table fills and the rest read. A status asked three questions of
32
+ * the same table in a row, each its own `ps` over every process (Rallly run 209 in a tab: `attach` ran `ps -axo`
33
+ * twelve times, 0.74 s, before it started the command). Without it every call reads the table itself, as before. */
34
+ export function survivingOwnedGroups(pids, listing) {
8
35
  const candidates = pids.filter(pid => {
9
36
  try { process.kill(process.platform === 'win32' ? pid : -pid, 0); return true; }
10
37
  catch (error) {
@@ -14,8 +41,23 @@ export function survivingOwnedGroups(pids) {
14
41
  }
15
42
  });
16
43
  if (!candidates.length || process.platform === 'win32') return candidates;
44
+ // Linux says a process's group and state in /proc/<pid>/stat, which is what `ps` itself reads there: the table is
45
+ // read from the files, with no process started to read it. Each call started a `ps` (a World's `up` and `attach`
46
+ // twenty of them between the two, about 40 ms each where a process is costly to start), to learn three fields.
47
+ // A table that cannot be read whole falls to `ps` below, as before.
48
+ // Shared by the questions of one decision, as the `ps` listing below is: `null` is "read and not whole".
49
+ if (listing && !('proc' in listing)) listing.proc = linuxProcessTable() ?? null;
50
+ const table = listing ? listing.proc : linuxProcessTable();
51
+ if (table) return candidates.filter(pid => survives(pid, table));
17
52
  const options = { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 };
18
- const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], options);
53
+ // One listing answers every question of one decision: the first that needs it runs `ps`, said as a probe once.
54
+ const read = () => {
55
+ const tableAsked = Date.now();
56
+ const listed = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], options);
57
+ sayProbe('ps -axo pid=,pgid=,stat=', tableAsked, probeOutcome(listed));
58
+ return listed;
59
+ };
60
+ const result = listing ? (listing.table ??= read()) : read();
19
61
  // When the whole-table listing does not answer (a non-zero status or an error; which of the two it was in the
20
62
  // run this was written for, Cal build 43, was not read, and no measurement says why), every candidate would read
21
63
  // as live, a group of zombies among them. Where the system can list one group by its leader (Darwin's `ps -g`: "processes with the
@@ -24,7 +66,9 @@ export function survivingOwnedGroups(pids) {
24
66
  if (result.status !== 0 || result.error) {
25
67
  if (process.platform !== 'darwin') return candidates;
26
68
  return candidates.filter(pid => {
69
+ const groupAsked = Date.now();
27
70
  const own = spawnSync('ps', ['-o', 'pid=,pgid=,stat=', '-g', String(pid)], options);
71
+ sayProbe(`ps -g ${pid}`, groupAsked, own.status === 1 && !own.error && !own.signal && !own.stdout.trim() ? 'answered' : probeOutcome(own));
28
72
  // Status 1 with no row is `ps` saying the group has no process; any other failure says nothing.
29
73
  if (own.error || own.signal || (own.status !== 0 && !(own.status === 1 && !own.stdout.trim()))) return true;
30
74
  const members = own.stdout.trim() ? own.stdout.trim().split('\n').map(line => line.trim().split(/\s+/)) : [];
@@ -40,18 +84,41 @@ export function survivingOwnedGroups(pids) {
40
84
  });
41
85
  }
42
86
  const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
43
- return candidates.filter(pid => {
44
- const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
45
- // Visibility restrictions can omit a live group. Only ESRCH proves absence.
46
- if (!members.length) {
47
- for (const target of [-pid, pid]) {
48
- try { process.kill(target, 0); return true; }
49
- catch (error) { if (error.code !== 'ESRCH') return true; }
50
- }
51
- return false;
87
+ return candidates.filter(pid => survives(pid, rows));
88
+ }
89
+
90
+ /** Whether a group's leader or any member is in the table and not a zombie; a group the table omits is asked of the kernel. */
91
+ function survives(pid, rows) {
92
+ const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
93
+ // Visibility restrictions can omit a live group. Only ESRCH proves absence.
94
+ if (!members.length) {
95
+ for (const target of [-pid, pid]) {
96
+ try { process.kill(target, 0); return true; }
97
+ catch (error) { if (error.code !== 'ESRCH') return true; }
52
98
  }
53
- return members.some(([, , state]) => !state || !state.startsWith('Z'));
54
- });
99
+ return false;
100
+ }
101
+ return members.some(([, , state]) => !state || !state.startsWith('Z'));
102
+ }
103
+
104
+ /** The process table as `ps -axo pid=,pgid=,stat=` gives it, read from /proc: [pid, pgid, state] rows; undefined
105
+ * where this is not Linux, /proc is not there, or the directory cannot be listed. A process that ends between the
106
+ * listing and its read is not in the table, as it would not be in a `ps` taken a moment later. */
107
+ function linuxProcessTable() {
108
+ if (process.platform !== 'linux' || !existsSync('/proc/self/stat')) return undefined;
109
+ let names;
110
+ try { names = readdirSync('/proc'); } catch { return undefined; }
111
+ const rows = [];
112
+ for (const name of names) {
113
+ if (!/^[0-9]+$/.test(name)) continue;
114
+ let stat;
115
+ try { stat = readFileSync(`/proc/${name}/stat`, 'utf8'); } catch { continue; }
116
+ // `pid (comm) state ppid pgrp …`: the command may hold spaces and parentheses, so the fields are read after its last `)`.
117
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
118
+ if (fields.length < 3) return undefined;
119
+ rows.push([name, fields[2], fields[0]]);
120
+ }
121
+ return rows.length ? rows : undefined;
55
122
  }
56
123
 
57
124
  /** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
@@ -1 +1,2 @@
1
- export { survivingOwnedGroups, processStarts } from './process-groups.mjs';
1
+ export { survivingOwnedGroups, processStarts, setWorldEventsFile, worldEvent } from './process-groups.mjs';
2
+ export type { ProcessListing } from './process-groups.mjs';
@@ -3,7 +3,7 @@ import type { ChildProcess } from 'node:child_process';
3
3
  export function processBirth(pid: number): Promise<string | undefined>;
4
4
  export function spawnIdentity(pid: number): Promise<SpawnIdentity>;
5
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;
6
+ export function signalOwnedProcess(identity: SpawnIdentity | undefined, signal: NodeJS.Signals, group?: boolean, by?: string): Promise<boolean>;
7
+ export function signalChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, by?: string): boolean;
8
8
  export function signalHeldChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, birth?: Promise<SpawnIdentity | undefined>): Promise<boolean>;
9
9
  export function childIdentity(child: ChildProcess): Promise<SpawnIdentity | undefined>;
@@ -1,7 +1,20 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { survivingOwnedGroups } from './process-groups.mjs';
4
+ import { survivingOwnedGroups, worldEvent, sayProbe } from './process-groups.mjs';
5
+
6
+ /** Who asked for a signal, where the caller did not say: the first frame outside this file. */
7
+ function askedBy(by) {
8
+ if (by) return by;
9
+ const frame = (new Error().stack ?? '').split('\n').slice(2).find(line => !line.includes('process-signal.')) ?? '';
10
+ return frame.trim().replace(/^at\s+/, '').replace(/\(?(?:file:\/\/)?[^()\s]*\/([^/()\s]+:\d+):\d+\)?$/, '$1').trim() || 'unknown';
11
+ }
12
+ /** EVERY SIGNAL THIS RUNTIME DELIVERS IS SAID: the pid, whether to it alone or to its group, the signal, who asked,
13
+ * and what the system answered. Signal 0 is a question, not a delivery, and is not said. */
14
+ function saySignal(pid, toGroup, signal, by, result) {
15
+ if (signal === 0) return;
16
+ worldEvent({ event: 'world.signal', signal, pid, to: toGroup ? 'group' : 'pid', by: askedBy(by), result });
17
+ }
5
18
 
6
19
  // Retain the existing 1000 ms inspection ceiling, not a new timeout chosen from one sample.
7
20
  // Measured 2026-10-03 on shared Darwin 25.4.0 ARM64, Bun 1.3.11 (process.version
@@ -75,9 +88,11 @@ export async function processBirth(pid) {
75
88
  return;
76
89
  }
77
90
  let output = '', settled = false;
91
+ const probeAsked = Date.now();
78
92
  const finish = (value, error, code, signal, timedOut, reason) => {
79
93
  if (settled) return;
80
94
  settled = true;
95
+ sayProbe(`${probe.command} ${probe.args.join(' ')}`, probeAsked, value === undefined ? `${reason}${timedOut ? ' (timed out)' : ''}` : 'answered');
81
96
  clearTimeout(timer);
82
97
  child.stdout.destroy();
83
98
  if (value === undefined) sayProbeFailure(probe, error, code, signal, timedOut, reason);
@@ -96,7 +111,7 @@ export async function processBirth(pid) {
96
111
  // exited pid>` prints nothing and exits 1; its manual gives `-p` no other way to say it. Everything else is
97
112
  // the probe failing: it could not be spawned, was killed, ran out of time, exited otherwise, or printed
98
113
  // something that is not a birth. That is said once and is also undefined.
99
- if (code === 1 && signal === null && output.trim() === '') { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(undefined); } return; }
114
+ if (code === 1 && signal === null && output.trim() === '') { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); sayProbe(`${probe.command} ${probe.args.join(' ')}`, probeAsked, 'answered'); resolve(undefined); } return; }
100
115
  finish(code === 0 ? probe.read(output.trim()) : undefined, undefined, code, signal, false,
101
116
  code === 0 ? 'unrecognized birth output' : 'nonzero exit');
102
117
  });
@@ -145,21 +160,26 @@ function saySignalFailure(error, target, signal, child) {
145
160
  } catch { /* the failure itself is still thrown by the caller */ }
146
161
  }
147
162
  /** Stored or retained numbers use birth verification before every delivery. */
148
- export async function signalOwnedProcess(identity, signal, group = false) {
163
+ export async function signalOwnedProcess(identity, signal, group = false, by = undefined) {
164
+ const asker = askedBy(by);
149
165
  await verifySpawnIdentity(identity);
150
166
  const target = group && process.platform !== 'win32' ? -identity.pid : identity.pid;
151
- try { return process.kill(target, signal); }
152
- catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(identity.pid)) return false; saySignalFailure(error, target, signal); throw error; }
167
+ try { const delivered = process.kill(target, signal); saySignal(identity.pid, target < 0, signal, asker, 'delivered'); return delivered; }
168
+ catch (error) {
169
+ saySignal(identity.pid, target < 0, signal, asker, error.code ?? 'failed');
170
+ if (error.code === 'ESRCH' || nothingLeftToSignal(identity.pid)) return false; saySignalFailure(error, target, signal); throw error;
171
+ }
153
172
  }
154
173
  /** A live ChildProcess is ownership until exit. A group additionally needs its leader alive.
155
174
  * False means no held delivery was possible; a caller with surviving descendants needs a birth. */
156
- export function signalChild(child, signal, group = false) {
175
+ export function signalChild(child, signal, group = false, by = undefined) {
157
176
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
158
- if (!group || process.platform === 'win32') return child.kill(signal);
177
+ const asker = askedBy(by);
178
+ if (!group || process.platform === 'win32') { const delivered = child.kill(signal); saySignal(child.pid, false, signal, asker, delivered ? 'delivered' : 'not delivered'); return delivered; }
159
179
  try { process.kill(child.pid, 0); }
160
180
  catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, child.pid, 0, child); throw error; }
161
- try { return process.kill(-child.pid, signal); }
162
- catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, -child.pid, signal, child); throw error; }
181
+ try { const delivered = process.kill(-child.pid, signal); saySignal(child.pid, true, signal, asker, 'delivered'); return delivered; }
182
+ catch (error) { saySignal(child.pid, true, signal, asker, error.code ?? 'failed'); if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, -child.pid, signal, child); throw error; }
163
183
  }
164
184
 
165
185
  /** Held first; an orphaned group then becomes retained numeric ownership. */
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
3
  import { parse as parseToml } from 'smol-toml';
3
4
  import { basename, dirname, relative, resolve } from 'node:path';
@@ -215,6 +216,34 @@ function json(path: string): Record<string, any> | undefined {
215
216
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return undefined; }
216
217
  }
217
218
 
219
+ /**
220
+ * WHAT THE PROJECT'S SOURCE HOLDS (architecture, "A World made for an image reads only what the project's source
221
+ * holds"). Set by a World's init made for an image, it stands in front of every reader of a project's env files
222
+ * here and in init. Unset, every file is read, as a person's own init reads.
223
+ */
224
+ let sourceOnly: { root: string; left: Set<string> } | undefined;
225
+ export function readOnlyWhatTheSourceHolds(root: string | undefined): void {
226
+ sourceOnly = root === undefined ? undefined : { root: resolve(root), left: new Set() };
227
+ }
228
+ /** The files left unread since the rule was set, relative to the project, sorted. */
229
+ export function filesLeftAsThePersonsOwn(): string[] { return sourceOnly ? [...sourceOnly.left].sort() : []; }
230
+ /**
231
+ * THE TEXT OF A PROJECT'S VALUE FILE, AS THE ONE READER OF IT. With no rule set, the folder's file. With the rule
232
+ * set (a World made for an image), the text the SOURCE holds and nothing else: the committed blob (`git cat-file
233
+ * blob HEAD:./<file>`), never the folder's copy, which can carry a value a person typed and did not commit. The
234
+ * source of a folder is the repository that encloses it, its own or one above: git is asked from the folder. A file
235
+ * never committed, and every file of a folder under no repository, is the person's own: undefined, and recorded
236
+ * as left. A file's name decides nothing.
237
+ */
238
+ export function sourceText(path: string): string | undefined {
239
+ if (!sourceOnly) return readFileSync(path, 'utf8');
240
+ const absolute = resolve(path);
241
+ const shown = spawnSync('git', ['-C', sourceOnly.root, 'cat-file', 'blob', `HEAD:./${relative(sourceOnly.root, absolute)}`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
242
+ if (shown.status === 0) return shown.stdout;
243
+ sourceOnly.left.add(relative(sourceOnly.root, absolute) || basename(absolute));
244
+ return undefined;
245
+ }
246
+
218
247
  function filesAt(root: string): string[] {
219
248
  if (!existsSync(root)) return [];
220
249
  return readdirSync(root, { withFileTypes: true })
@@ -264,7 +293,8 @@ export function projectEnvNames(input = process.cwd()): string[] {
264
293
  const envNames = new Set<string>();
265
294
  for (const dir of projectManifestDirs(root)) {
266
295
  for (const name of filesAt(dir).filter((candidate) => candidate === '.env' || candidate.startsWith('.env.'))) {
267
- const text = readFileSync(resolve(dir, name), 'utf8');
296
+ const text = sourceText(resolve(dir, name));
297
+ if (text === undefined) continue;
268
298
  for (const line of text.split(/\r?\n/)) {
269
299
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
270
300
  if (match) envNames.add(match[1]!);
@@ -296,8 +326,10 @@ type InspectedEnv = { values: Map<string, string>; sources: Map<string, string>
296
326
  function inspectedEnv(dir: string, label: string, onlyFile?: string): InspectedEnv {
297
327
  const values = new Map<string, string>();
298
328
  const sources = new Map<string, string>();
299
- for (const name of filesAt(dir).filter((candidate) => onlyFile ? candidate === onlyFile : candidate === '.env' || candidate.startsWith('.env.')).sort()) {
300
- for (const [index, line] of readFileSync(resolve(dir, name), 'utf8').split(/\r?\n/).entries()) {
329
+ for (const name of filesAt(dir).filter((candidate) => (onlyFile ? candidate === onlyFile : candidate === '.env' || candidate.startsWith('.env.'))).sort()) {
330
+ const text = sourceText(resolve(dir, name));
331
+ if (text === undefined) continue;
332
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
301
333
  const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
302
334
  if (!match) continue;
303
335
  let value = match[2]!;
@@ -563,7 +595,8 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
563
595
  // Inspect each example/config separately: conflicting schemes must not silently overwrite
564
596
  // one another. inspectedEnv is only used for resolving ordinary references within a file.
565
597
  for (const file of filesAt(dir).filter((name) => name === '.env' || name.startsWith('.env.')).sort()) {
566
- const text = readFileSync(resolve(dir, file), 'utf8');
598
+ const text = sourceText(resolve(dir, file));
599
+ if (text === undefined) continue;
567
600
  const env = inspectedEnv(dir, label, file);
568
601
  for (const [index, line] of text.split(/\r?\n/).entries()) {
569
602
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
@@ -0,0 +1,128 @@
1
+ // Synchronous proxy metadata and session trust; the server loads separately at first use.
2
+ import { errorMessage } from './error-message.ts';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { loadInject } from './inject-map.ts';
8
+ import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
9
+
10
+ /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
11
+ export function activeVendorMap(env: Record<string, string | undefined>): Record<string, string> {
12
+ return loadInject().readMap(env);
13
+ }
14
+
15
+ // --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
16
+
17
+ // Shared by the server through ensureCa and leafCertFor; callers keep their inferred types.
18
+ export type CaPaths = { dir: string; caKey: string; caCert: string };
19
+
20
+ function caPaths(tlsDir: string): CaPaths {
21
+ return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
22
+ }
23
+
24
+ /** Whether this host opens raw sockets to other hosts, which the ambient TLS redirect proxy exists to intercept. A
25
+ * browser tab's Node (tabnode, which declares `process.versions.webcontainer`) opens none: its TLS is loopback pairing
26
+ * with no handshake, and a remote HTTPS call leaves through the page's HTTP transport. There no proxy can run and none
27
+ * is needed: the World's network policy is enforced by the injector in every Node program (world-core inject.cjs). */
28
+ export function hostHasRawEgress(versions: Record<string, string | undefined> = process.versions): boolean {
29
+ return versions.webcontainer === undefined;
30
+ }
31
+
32
+ export function opensslAvailable(): boolean {
33
+ // TEST-ONLY seam (TWIN-64): forces "openssl unavailable" deterministically. A PATH-shim (fake
34
+ // `openssl` on PATH) doesn't reliably work here because `spawnSync('openssl', …)` below is called
35
+ // with no explicit `env`, and some runtimes resolve the executable against a PATH snapshotted at
36
+ // process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
37
+ // this env var in real usage.
38
+ if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
39
+ const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
40
+ return r.status === 0;
41
+ }
42
+
43
+ function runOpenssl(args: string[]): string {
44
+ const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
45
+ if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
46
+ return r.stdout;
47
+ }
48
+
49
+ /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
50
+ * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
51
+ * is removed by `tearDownCa` on `down`. */
52
+ export function ensureCa(tlsDir: string): CaPaths {
53
+ if (!opensslAvailable()) {
54
+ throw new Error('openssl not found — the TLS redirect proxy needs openssl to mint a session CA. Install openssl, or use per-CLI endpoint env (cliRedirect) instead.');
55
+ }
56
+ const paths = caPaths(tlsDir);
57
+ if (existsSync(paths.caKey) && existsSync(paths.caCert)) return paths;
58
+ mkdirSync(tlsDir, { recursive: true });
59
+ runOpenssl(['genrsa', '-out', paths.caKey, '2048']);
60
+ runOpenssl([
61
+ 'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
62
+ '-sha256', '-days', '3650', '-out', paths.caCert,
63
+ '-subj', '/CN=volter-world session CA/O=volter-world',
64
+ // A CA cert without keyUsage is refused by OpenSSL 3 (Python, curl on Debian 13: "CA cert does not
65
+ // include key usage extension"), so the CA states what a CA is for.
66
+ '-addext', 'basicConstraints=critical,CA:TRUE',
67
+ '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
68
+ '-addext', 'subjectKeyIdentifier=hash',
69
+ ]);
70
+ return paths;
71
+ }
72
+
73
+ /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
74
+ * the world (it was only ever trusted via the per-shell env, never system-wide). */
75
+ export function tearDownCa(tlsDir: string): void {
76
+ leafCache.clear();
77
+ rmSync(tlsDir, { recursive: true, force: true });
78
+ }
79
+
80
+ const leafCache = new Map<string, { key: string; cert: string }>();
81
+
82
+ /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
83
+ * accepts it for that SNI. */
84
+ export function leafCertFor(host: string, ca: CaPaths): { key: string; cert: string } {
85
+ // A pathname can hold a replacement CA; its certificate identifies the signing generation.
86
+ const cacheKey = `${createHash('sha256').update(readFileSync(ca.caCert)).digest('hex')}:${host}`;
87
+ const cached = leafCache.get(cacheKey);
88
+ if (cached) return cached;
89
+ const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
90
+ const keyPath = join(ca.dir, `leaf-${safe}-key.pem`);
91
+ const csrPath = join(ca.dir, `leaf-${safe}.csr`);
92
+ const certPath = join(ca.dir, `leaf-${safe}.pem`);
93
+ const extPath = join(ca.dir, `leaf-${safe}.ext`);
94
+ runOpenssl(['genrsa', '-out', keyPath, '2048']);
95
+ runOpenssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${host}`]);
96
+ writeFileSync(extPath, `subjectAltName=DNS:${host}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
97
+ runOpenssl([
98
+ // 397 days, NOT the CA's 3650: macOS's system verifier (used by Go — `gh`, and any Go/Swift
99
+ // client) rejects a LEAF cert valid >398 days as "certificate is not standards compliant"
100
+ // (CA/Browser-Forum max). The session CA itself is exempt and stays long-lived.
101
+ 'x509', '-req', '-in', csrPath, '-CA', ca.caCert, '-CAkey', ca.caKey,
102
+ '-CAcreateserial', '-out', certPath, '-days', '397', '-sha256', '-extfile', extPath,
103
+ ]);
104
+ const minted = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') };
105
+ leafCache.set(cacheKey, minted);
106
+ return minted;
107
+ }
108
+
109
+ /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
110
+ * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
111
+ * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
112
+ export function proxyEnvFor(proxyUrl: string, caCertPath: string, caller: CaTrustInputs = process.env, guestUrl?: string): Record<string, string> {
113
+ return { ...proxyAddressEnv(proxyUrl, caCertPath, guestUrl), ...sessionTrustEnv(caCertPath, caller) };
114
+ }
115
+
116
+ /** The proxy's address env; `VOLTER_WORLD_GUEST_PROXY` is its guests' listener, which a World Machine's forwarder
117
+ * targets (architecture, "The scoped proxy's local pass-through"). */
118
+ export function proxyAddressEnv(proxyUrl: string, caCertPath: string, guestUrl?: string): Record<string, string> {
119
+ return {
120
+ ...(guestUrl ? { VOLTER_WORLD_GUEST_PROXY: guestUrl } : {}),
121
+ HTTPS_PROXY: proxyUrl,
122
+ HTTP_PROXY: proxyUrl,
123
+ https_proxy: proxyUrl,
124
+ http_proxy: proxyUrl,
125
+ VOLTER_WORLD_PROXY: proxyUrl,
126
+ VOLTER_WORLD_CA: caCertPath,
127
+ };
128
+ }
@@ -1,3 +1,5 @@
1
+ import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, tearDownCa, leafCertFor, proxyAddressEnv } from './proxy-env.ts';
2
+ export { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, tearDownCa, leafCertFor, proxyEnvFor } from './proxy-env.ts';
1
3
  import { errorMessage } from './error-message.ts';
2
4
  import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
3
5
  import { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
@@ -22,8 +24,6 @@ import { websocketRelay } from './websocket-relay.ts';
22
24
  //
23
25
  // Cert tooling: we shell out to `openssl` (already a dev prerequisite; node:crypto can generate keys
24
26
  // but cannot mint X.509 certs without a third-party lib). This keeps world-runtime dependency-light.
25
- import { spawnSync } from 'node:child_process';
26
- import { createHash } from 'node:crypto';
27
27
  import { lookup as dnsLookup } from 'node:dns/promises';
28
28
  import { BlockList, Socket, isIP, connect as netConnect, createServer as createNetServer } from 'node:net';
29
29
  import { networkInterfaces } from 'node:os';
@@ -31,8 +31,7 @@ import { connect as tlsConnect } from 'node:tls';
31
31
  import { request as httpsRequest } from 'node:https';
32
32
  import { createServer as createHttpServer } from 'node:http';
33
33
  import { Readable } from 'node:stream';
34
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
35
- import { join } from 'node:path';
34
+ import { readFileSync } from 'node:fs';
36
35
  // The host→twin table is loaded through the shared inject-map module (one world-runtime home
37
36
  // for the injector's data), so this proxy, `covers`, and `up`'s inert-injectEnv warning can
38
37
  // never re-encode the vendor host predicates — they stay one source of truth with the Node
@@ -84,11 +83,6 @@ export function proxyTargetFor(
84
83
  return inject.resolveTwin(hostname, inject.readMap(env), pathname);
85
84
  }
86
85
 
87
- /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
88
- export function activeVendorMap(env: Record<string, string | undefined>): Record<string, string> {
89
- return loadInject().readMap(env);
90
- }
91
-
92
86
  /** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
93
87
  * from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
94
88
  export function isGuestSource(address: string | undefined): boolean {
@@ -199,101 +193,6 @@ function guestPinRefusal(target: string): WorldRefusal {
199
193
  reason: 'inspected egress cannot keep a guest\'s checked destination pin' };
200
194
  }
201
195
 
202
- // --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
203
-
204
- // Not exported: no importer outside this file references the type by name (`ensureCa`/`leafCertFor`
205
- // callers rely on inference).
206
- type CaPaths = { dir: string; caKey: string; caCert: string };
207
-
208
- function caPaths(tlsDir: string): CaPaths {
209
- return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
210
- }
211
-
212
- /** Whether this host opens raw sockets to other hosts, which the ambient TLS redirect proxy exists to intercept. A
213
- * browser tab's Node (tabnode, which declares `process.versions.webcontainer`) opens none: its TLS is loopback pairing
214
- * with no handshake, and a remote HTTPS call leaves through the page's HTTP transport. There no proxy can run and none
215
- * is needed: the World's network policy is enforced by the injector in every Node program (world-core inject.cjs). */
216
- export function hostHasRawEgress(versions: Record<string, string | undefined> = process.versions): boolean {
217
- return versions.webcontainer === undefined;
218
- }
219
-
220
- export function opensslAvailable(): boolean {
221
- // TEST-ONLY seam (TWIN-64): forces "openssl unavailable" deterministically. A PATH-shim (fake
222
- // `openssl` on PATH) doesn't reliably work here because `spawnSync('openssl', …)` below is called
223
- // with no explicit `env`, and some runtimes resolve the executable against a PATH snapshotted at
224
- // process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
225
- // this env var in real usage.
226
- if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
227
- const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
228
- return r.status === 0;
229
- }
230
-
231
- function runOpenssl(args: string[]): string {
232
- const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
233
- if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
234
- return r.stdout;
235
- }
236
-
237
- /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
238
- * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
239
- * is removed by `tearDownCa` on `down`. */
240
- export function ensureCa(tlsDir: string): CaPaths {
241
- if (!opensslAvailable()) {
242
- throw new Error('openssl not found — the TLS redirect proxy needs openssl to mint a session CA. Install openssl, or use per-CLI endpoint env (cliRedirect) instead.');
243
- }
244
- const paths = caPaths(tlsDir);
245
- if (existsSync(paths.caKey) && existsSync(paths.caCert)) return paths;
246
- mkdirSync(tlsDir, { recursive: true });
247
- runOpenssl(['genrsa', '-out', paths.caKey, '2048']);
248
- runOpenssl([
249
- 'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
250
- '-sha256', '-days', '3650', '-out', paths.caCert,
251
- '-subj', '/CN=volter-world session CA/O=volter-world',
252
- // A CA cert without keyUsage is refused by OpenSSL 3 (Python, curl on Debian 13: "CA cert does not
253
- // include key usage extension"), so the CA states what a CA is for.
254
- '-addext', 'basicConstraints=critical,CA:TRUE',
255
- '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
256
- '-addext', 'subjectKeyIdentifier=hash',
257
- ]);
258
- return paths;
259
- }
260
-
261
- /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
262
- * the world (it was only ever trusted via the per-shell env, never system-wide). */
263
- export function tearDownCa(tlsDir: string): void {
264
- leafCache.clear();
265
- rmSync(tlsDir, { recursive: true, force: true });
266
- }
267
-
268
- const leafCache = new Map<string, { key: string; cert: string }>();
269
-
270
- /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
271
- * accepts it for that SNI. */
272
- export function leafCertFor(host: string, ca: CaPaths): { key: string; cert: string } {
273
- // A pathname can hold a replacement CA; its certificate identifies the signing generation.
274
- const cacheKey = `${createHash('sha256').update(readFileSync(ca.caCert)).digest('hex')}:${host}`;
275
- const cached = leafCache.get(cacheKey);
276
- if (cached) return cached;
277
- const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
278
- const keyPath = join(ca.dir, `leaf-${safe}-key.pem`);
279
- const csrPath = join(ca.dir, `leaf-${safe}.csr`);
280
- const certPath = join(ca.dir, `leaf-${safe}.pem`);
281
- const extPath = join(ca.dir, `leaf-${safe}.ext`);
282
- runOpenssl(['genrsa', '-out', keyPath, '2048']);
283
- runOpenssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${host}`]);
284
- writeFileSync(extPath, `subjectAltName=DNS:${host}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
285
- runOpenssl([
286
- // 397 days, NOT the CA's 3650: macOS's system verifier (used by Go — `gh`, and any Go/Swift
287
- // client) rejects a LEAF cert valid >398 days as "certificate is not standards compliant"
288
- // (CA/Browser-Forum max). The session CA itself is exempt and stays long-lived.
289
- 'x509', '-req', '-in', csrPath, '-CA', ca.caCert, '-CAkey', ca.caKey,
290
- '-CAcreateserial', '-out', certPath, '-days', '397', '-sha256', '-extfile', extPath,
291
- ]);
292
- const minted = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') };
293
- leafCache.set(cacheKey, minted);
294
- return minted;
295
- }
296
-
297
196
  // --- the proxy server ------------------------------------------------------------------------
298
197
 
299
198
  // Neither type below is exported: `startRedirectProxy` is the only consumer of these names outside
@@ -852,24 +751,3 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
852
751
  }),
853
752
  };
854
753
  }
855
-
856
- /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
857
- * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
858
- * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
859
- export function proxyEnvFor(proxyUrl: string, caCertPath: string, caller: CaTrustInputs = process.env, guestUrl?: string): Record<string, string> {
860
- return { ...proxyAddressEnv(proxyUrl, caCertPath, guestUrl), ...sessionTrustEnv(caCertPath, caller) };
861
- }
862
-
863
- /** The proxy's address env; `VOLTER_WORLD_GUEST_PROXY` is its guests' listener, which a World Machine's forwarder
864
- * targets (architecture, "The scoped proxy's local pass-through"). */
865
- function proxyAddressEnv(proxyUrl: string, caCertPath: string, guestUrl?: string): Record<string, string> {
866
- return {
867
- ...(guestUrl ? { VOLTER_WORLD_GUEST_PROXY: guestUrl } : {}),
868
- HTTPS_PROXY: proxyUrl,
869
- HTTP_PROXY: proxyUrl,
870
- https_proxy: proxyUrl,
871
- http_proxy: proxyUrl,
872
- VOLTER_WORLD_PROXY: proxyUrl,
873
- VOLTER_WORLD_CA: caCertPath,
874
- };
875
- }