@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
@@ -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. */
@@ -57,6 +57,18 @@ export declare function nestedProjectRoots(root: string): string[];
57
57
  * env-name census: the root, its declared workspace members, and nested project
58
58
  * roots (plus THEIR members). Single source of truth for the walkers below. */
59
59
  export declare function projectManifestDirs(root: string): string[];
60
+ export declare function readOnlyWhatTheSourceHolds(root: string | undefined): void;
61
+ /** The files left unread since the rule was set, relative to the project, sorted. */
62
+ export declare function filesLeftAsThePersonsOwn(): string[];
63
+ /**
64
+ * 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
65
+ * set (a World made for an image), the text the SOURCE holds and nothing else: the committed blob (`git cat-file
66
+ * blob HEAD:./<file>`), never the folder's copy, which can carry a value a person typed and did not commit. The
67
+ * source of a folder is the repository that encloses it, its own or one above: git is asked from the folder. A file
68
+ * never committed, and every file of a folder under no repository, is the person's own: undefined, and recorded
69
+ * as left. A file's name decides nothing.
70
+ */
71
+ export declare function sourceText(path: string): string | undefined;
60
72
  /** Every dependency name declared anywhere in the repo — root manifest plus all
61
73
  * workspace members, across dependencies/devDependencies/peerDependencies — plus
62
74
  * the python manifest candidates. Exported raw for `covers`, which must also see
@@ -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';
@@ -224,6 +225,35 @@ function json(path) {
224
225
  return undefined;
225
226
  }
226
227
  }
228
+ /**
229
+ * WHAT THE PROJECT'S SOURCE HOLDS (architecture, "A World made for an image reads only what the project's source
230
+ * holds"). Set by a World's init made for an image, it stands in front of every reader of a project's env files
231
+ * here and in init. Unset, every file is read, as a person's own init reads.
232
+ */
233
+ let sourceOnly;
234
+ export function readOnlyWhatTheSourceHolds(root) {
235
+ sourceOnly = root === undefined ? undefined : { root: resolve(root), left: new Set() };
236
+ }
237
+ /** The files left unread since the rule was set, relative to the project, sorted. */
238
+ export function filesLeftAsThePersonsOwn() { return sourceOnly ? [...sourceOnly.left].sort() : []; }
239
+ /**
240
+ * 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
241
+ * set (a World made for an image), the text the SOURCE holds and nothing else: the committed blob (`git cat-file
242
+ * blob HEAD:./<file>`), never the folder's copy, which can carry a value a person typed and did not commit. The
243
+ * source of a folder is the repository that encloses it, its own or one above: git is asked from the folder. A file
244
+ * never committed, and every file of a folder under no repository, is the person's own: undefined, and recorded
245
+ * as left. A file's name decides nothing.
246
+ */
247
+ export function sourceText(path) {
248
+ if (!sourceOnly)
249
+ return readFileSync(path, 'utf8');
250
+ const absolute = resolve(path);
251
+ 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'] });
252
+ if (shown.status === 0)
253
+ return shown.stdout;
254
+ sourceOnly.left.add(relative(sourceOnly.root, absolute) || basename(absolute));
255
+ return undefined;
256
+ }
227
257
  function filesAt(root) {
228
258
  if (!existsSync(root))
229
259
  return [];
@@ -276,7 +306,9 @@ export function projectEnvNames(input = process.cwd()) {
276
306
  const envNames = new Set();
277
307
  for (const dir of projectManifestDirs(root)) {
278
308
  for (const name of filesAt(dir).filter((candidate) => candidate === '.env' || candidate.startsWith('.env.'))) {
279
- const text = readFileSync(resolve(dir, name), 'utf8');
309
+ const text = sourceText(resolve(dir, name));
310
+ if (text === undefined)
311
+ continue;
280
312
  for (const line of text.split(/\r?\n/)) {
281
313
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
282
314
  if (match)
@@ -302,8 +334,11 @@ function publicRegistryUrl(configured) {
302
334
  function inspectedEnv(dir, label, onlyFile) {
303
335
  const values = new Map();
304
336
  const sources = new Map();
305
- for (const name of filesAt(dir).filter((candidate) => onlyFile ? candidate === onlyFile : candidate === '.env' || candidate.startsWith('.env.')).sort()) {
306
- for (const [index, line] of readFileSync(resolve(dir, name), 'utf8').split(/\r?\n/).entries()) {
337
+ for (const name of filesAt(dir).filter((candidate) => (onlyFile ? candidate === onlyFile : candidate === '.env' || candidate.startsWith('.env.'))).sort()) {
338
+ const text = sourceText(resolve(dir, name));
339
+ if (text === undefined)
340
+ continue;
341
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
307
342
  const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
308
343
  if (!match)
309
344
  continue;
@@ -601,7 +636,9 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
601
636
  // Inspect each example/config separately: conflicting schemes must not silently overwrite
602
637
  // one another. inspectedEnv is only used for resolving ordinary references within a file.
603
638
  for (const file of filesAt(dir).filter((name) => name === '.env' || name.startsWith('.env.')).sort()) {
604
- const text = readFileSync(resolve(dir, file), 'utf8');
639
+ const text = sourceText(resolve(dir, file));
640
+ if (text === undefined)
641
+ continue;
605
642
  const env = inspectedEnv(dir, label, file);
606
643
  for (const [index, line] of text.split(/\r?\n/).entries()) {
607
644
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
@@ -0,0 +1,34 @@
1
+ import { type CaTrustInputs } from './ca-trust.js';
2
+ /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
3
+ export declare function activeVendorMap(env: Record<string, string | undefined>): Record<string, string>;
4
+ export type CaPaths = {
5
+ dir: string;
6
+ caKey: string;
7
+ caCert: string;
8
+ };
9
+ /** Whether this host opens raw sockets to other hosts, which the ambient TLS redirect proxy exists to intercept. A
10
+ * browser tab's Node (tabnode, which declares `process.versions.webcontainer`) opens none: its TLS is loopback pairing
11
+ * with no handshake, and a remote HTTPS call leaves through the page's HTTP transport. There no proxy can run and none
12
+ * is needed: the World's network policy is enforced by the injector in every Node program (world-core inject.cjs). */
13
+ export declare function hostHasRawEgress(versions?: Record<string, string | undefined>): boolean;
14
+ export declare function opensslAvailable(): boolean;
15
+ /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
16
+ * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
17
+ * is removed by `tearDownCa` on `down`. */
18
+ export declare function ensureCa(tlsDir: string): CaPaths;
19
+ /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
20
+ * the world (it was only ever trusted via the per-shell env, never system-wide). */
21
+ export declare function tearDownCa(tlsDir: string): void;
22
+ /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
23
+ * accepts it for that SNI. */
24
+ export declare function leafCertFor(host: string, ca: CaPaths): {
25
+ key: string;
26
+ cert: string;
27
+ };
28
+ /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
29
+ * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
30
+ * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
31
+ export declare function proxyEnvFor(proxyUrl: string, caCertPath: string, caller?: CaTrustInputs, guestUrl?: string): Record<string, string>;
32
+ /** The proxy's address env; `VOLTER_WORLD_GUEST_PROXY` is its guests' listener, which a World Machine's forwarder
33
+ * targets (architecture, "The scoped proxy's local pass-through"). */
34
+ export declare function proxyAddressEnv(proxyUrl: string, caCertPath: string, guestUrl?: string): Record<string, string>;
@@ -0,0 +1,114 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { loadInject } from "./inject-map.js";
6
+ import { sessionTrustEnv } from "./ca-trust.js";
7
+ /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
8
+ export function activeVendorMap(env) {
9
+ return loadInject().readMap(env);
10
+ }
11
+ function caPaths(tlsDir) {
12
+ return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
13
+ }
14
+ /** Whether this host opens raw sockets to other hosts, which the ambient TLS redirect proxy exists to intercept. A
15
+ * browser tab's Node (tabnode, which declares `process.versions.webcontainer`) opens none: its TLS is loopback pairing
16
+ * with no handshake, and a remote HTTPS call leaves through the page's HTTP transport. There no proxy can run and none
17
+ * is needed: the World's network policy is enforced by the injector in every Node program (world-core inject.cjs). */
18
+ export function hostHasRawEgress(versions = process.versions) {
19
+ return versions.webcontainer === undefined;
20
+ }
21
+ export function opensslAvailable() {
22
+ // TEST-ONLY seam (TWIN-64): forces "openssl unavailable" deterministically. A PATH-shim (fake
23
+ // `openssl` on PATH) doesn't reliably work here because `spawnSync('openssl', …)` below is called
24
+ // with no explicit `env`, and some runtimes resolve the executable against a PATH snapshotted at
25
+ // process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
26
+ // this env var in real usage.
27
+ if (process.env.VOLTER_TEST_NO_OPENSSL === '1')
28
+ return false;
29
+ const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
30
+ return r.status === 0;
31
+ }
32
+ function runOpenssl(args) {
33
+ const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
34
+ if (r.status !== 0)
35
+ throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
36
+ return r.stdout;
37
+ }
38
+ /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
39
+ * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
40
+ * is removed by `tearDownCa` on `down`. */
41
+ export function ensureCa(tlsDir) {
42
+ if (!opensslAvailable()) {
43
+ 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.');
44
+ }
45
+ const paths = caPaths(tlsDir);
46
+ if (existsSync(paths.caKey) && existsSync(paths.caCert))
47
+ return paths;
48
+ mkdirSync(tlsDir, { recursive: true });
49
+ runOpenssl(['genrsa', '-out', paths.caKey, '2048']);
50
+ runOpenssl([
51
+ 'req', '-x509', '-new', '-nodes', '-key', paths.caKey,
52
+ '-sha256', '-days', '3650', '-out', paths.caCert,
53
+ '-subj', '/CN=volter-world session CA/O=volter-world',
54
+ // A CA cert without keyUsage is refused by OpenSSL 3 (Python, curl on Debian 13: "CA cert does not
55
+ // include key usage extension"), so the CA states what a CA is for.
56
+ '-addext', 'basicConstraints=critical,CA:TRUE',
57
+ '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
58
+ '-addext', 'subjectKeyIdentifier=hash',
59
+ ]);
60
+ return paths;
61
+ }
62
+ /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
63
+ * the world (it was only ever trusted via the per-shell env, never system-wide). */
64
+ export function tearDownCa(tlsDir) {
65
+ leafCache.clear();
66
+ rmSync(tlsDir, { recursive: true, force: true });
67
+ }
68
+ const leafCache = new Map();
69
+ /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
70
+ * accepts it for that SNI. */
71
+ export function leafCertFor(host, ca) {
72
+ // A pathname can hold a replacement CA; its certificate identifies the signing generation.
73
+ const cacheKey = `${createHash('sha256').update(readFileSync(ca.caCert)).digest('hex')}:${host}`;
74
+ const cached = leafCache.get(cacheKey);
75
+ if (cached)
76
+ return cached;
77
+ const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
78
+ const keyPath = join(ca.dir, `leaf-${safe}-key.pem`);
79
+ const csrPath = join(ca.dir, `leaf-${safe}.csr`);
80
+ const certPath = join(ca.dir, `leaf-${safe}.pem`);
81
+ const extPath = join(ca.dir, `leaf-${safe}.ext`);
82
+ runOpenssl(['genrsa', '-out', keyPath, '2048']);
83
+ runOpenssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${host}`]);
84
+ writeFileSync(extPath, `subjectAltName=DNS:${host}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
85
+ runOpenssl([
86
+ // 397 days, NOT the CA's 3650: macOS's system verifier (used by Go — `gh`, and any Go/Swift
87
+ // client) rejects a LEAF cert valid >398 days as "certificate is not standards compliant"
88
+ // (CA/Browser-Forum max). The session CA itself is exempt and stays long-lived.
89
+ 'x509', '-req', '-in', csrPath, '-CA', ca.caCert, '-CAkey', ca.caKey,
90
+ '-CAcreateserial', '-out', certPath, '-days', '397', '-sha256', '-extfile', extPath,
91
+ ]);
92
+ const minted = { key: readFileSync(keyPath, 'utf8'), cert: readFileSync(certPath, 'utf8') };
93
+ leafCache.set(cacheKey, minted);
94
+ return minted;
95
+ }
96
+ /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
97
+ * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
98
+ * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
99
+ export function proxyEnvFor(proxyUrl, caCertPath, caller = process.env, guestUrl) {
100
+ return { ...proxyAddressEnv(proxyUrl, caCertPath, guestUrl), ...sessionTrustEnv(caCertPath, caller) };
101
+ }
102
+ /** The proxy's address env; `VOLTER_WORLD_GUEST_PROXY` is its guests' listener, which a World Machine's forwarder
103
+ * targets (architecture, "The scoped proxy's local pass-through"). */
104
+ export function proxyAddressEnv(proxyUrl, caCertPath, guestUrl) {
105
+ return {
106
+ ...(guestUrl ? { VOLTER_WORLD_GUEST_PROXY: guestUrl } : {}),
107
+ HTTPS_PROXY: proxyUrl,
108
+ HTTP_PROXY: proxyUrl,
109
+ https_proxy: proxyUrl,
110
+ http_proxy: proxyUrl,
111
+ VOLTER_WORLD_PROXY: proxyUrl,
112
+ VOLTER_WORLD_CA: caCertPath,
113
+ };
114
+ }
@@ -1,3 +1,4 @@
1
+ export { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, tearDownCa, leafCertFor, proxyEnvFor } from './proxy-env.js';
1
2
  import { type CaTrustInputs } from './ca-trust.js';
2
3
  /**
3
4
  * The body served when a host was MITM'd but no twin serves the PATH.
@@ -28,8 +29,6 @@ export declare function proxyTargetFor(hostname: string, env: Record<string, str
28
29
  vendor: string;
29
30
  origin: string;
30
31
  } | null;
31
- /** The vendor→twin map this world actively redirects (for diagnostics / activate output). */
32
- export declare function activeVendorMap(env: Record<string, string | undefined>): Record<string, string>;
33
32
  /** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
34
33
  * from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
35
34
  export declare function isGuestSource(address: string | undefined): boolean;
@@ -45,30 +44,6 @@ export declare function guestDestination(host: string): Promise<{
45
44
  hostNetwork?: true;
46
45
  address?: string;
47
46
  }>;
48
- type CaPaths = {
49
- dir: string;
50
- caKey: string;
51
- caCert: string;
52
- };
53
- /** Whether this host opens raw sockets to other hosts, which the ambient TLS redirect proxy exists to intercept. A
54
- * browser tab's Node (tabnode, which declares `process.versions.webcontainer`) opens none: its TLS is loopback pairing
55
- * with no handshake, and a remote HTTPS call leaves through the page's HTTP transport. There no proxy can run and none
56
- * is needed: the World's network policy is enforced by the injector in every Node program (world-core inject.cjs). */
57
- export declare function hostHasRawEgress(versions?: Record<string, string | undefined>): boolean;
58
- export declare function opensslAvailable(): boolean;
59
- /** Generate (once) a throwaway CA in `tlsDir`. Idempotent: reuses an existing CA so a re-activate in
60
- * the same world trusts the same cert. Session/project-scoped — it lives under the instance dir and
61
- * is removed by `tearDownCa` on `down`. */
62
- export declare function ensureCa(tlsDir: string): CaPaths;
63
- /** Remove the session CA + any minted leaf certs. Called on `down` so the trusted CA never outlives
64
- * the world (it was only ever trusted via the per-shell env, never system-wide). */
65
- export declare function tearDownCa(tlsDir: string): void;
66
- /** Mint (and cache) a leaf cert for `host`, signed by the session CA. SAN = the host so the client
67
- * accepts it for that SNI. */
68
- export declare function leafCertFor(host: string, ca: CaPaths): {
69
- key: string;
70
- cert: string;
71
- };
72
47
  type RedirectProxyOptions = {
73
48
  /** The world env — supplies the `*_TWIN_URL` host→twin map. */
74
49
  env?: Record<string, string | undefined>;
@@ -105,8 +80,3 @@ export declare function twinUnreachableMessage(vendor: string, origin: string, e
105
80
  * - `CONNECT host:443` to a vendor host → MITM-terminated with a session-signed leaf, decrypted, and
106
81
  * forwarded to the twin. CONNECT to any other host → blind-tunneled to the real origin untouched. */
107
82
  export declare function startRedirectProxy(options: RedirectProxyOptions): Promise<RedirectProxyHandle>;
108
- /** The cert-trust + proxy env that points an unmodified tool through the proxy and trusts the session
109
- * CA — and ONLY this session (these are per-shell exports; nothing is installed system-wide). Mirrors
110
- * the env list in WORLD_ACTIVATE.md §"The honest cost: a scoped CA". */
111
- export declare function proxyEnvFor(proxyUrl: string, caCertPath: string, caller?: CaTrustInputs, guestUrl?: string): Record<string, string>;
112
- export {};