@volter/world-runtime 3.0.163 → 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.
- package/dist/src/ancestry.js +2 -2
- package/dist/src/app-url.d.ts +5 -1
- package/dist/src/app-url.js +14 -1
- package/dist/src/branch.js +28 -8
- package/dist/src/browser-files.d.ts +5 -0
- package/dist/src/browser-files.js +14 -0
- package/dist/src/browsers.d.ts +2 -3
- package/dist/src/browsers.js +4 -4
- package/dist/src/catalog.d.ts +2 -1
- package/dist/src/catalog.js +3 -2
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli-commands.js +71 -18
- package/dist/src/cli-load-clock.d.ts +1 -0
- package/dist/src/cli-load-clock.js +2 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/clickhouse-backing.d.ts +2 -0
- package/dist/src/clickhouse-backing.js +145 -0
- package/dist/src/command-lifetime.d.mts +1 -1
- package/dist/src/command-lifetime.mjs +5 -2
- package/dist/src/configs.js +1 -1
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +4 -2
- package/dist/src/covers.js +8 -4
- package/dist/src/go-mysql-binding.d.ts +19 -0
- package/dist/src/go-mysql-binding.js +51 -0
- package/dist/src/guest-processes.d.mts +1 -1
- package/dist/src/guest-processes.mjs +22 -14
- package/dist/src/host-cli.js +8 -1
- package/dist/src/host-ready.d.ts +16 -2
- package/dist/src/host-ready.js +66 -39
- package/dist/src/host-worker.js +7 -1
- package/dist/src/host.d.ts +26 -0
- package/dist/src/host.js +93 -3
- package/dist/src/infra-definition.d.ts +35 -0
- package/dist/src/infra-definition.js +146 -0
- package/dist/src/infra.d.ts +3 -0
- package/dist/src/infra.js +375 -105
- package/dist/src/init.d.ts +46 -0
- package/dist/src/init.js +445 -18
- package/dist/src/lifecycle-record.d.ts +5 -1
- package/dist/src/lifecycle-record.js +27 -4
- package/dist/src/local-branches.js +1 -1
- package/dist/src/mariadb-bootstrap.d.ts +32 -0
- package/dist/src/mariadb-bootstrap.js +90 -0
- package/dist/src/mariadb-supplier.d.ts +23 -0
- package/dist/src/mariadb-supplier.js +215 -0
- package/dist/src/mongodb-backing.d.ts +4 -0
- package/dist/src/mongodb-backing.js +71 -1
- package/dist/src/native-mariadb-backing.d.ts +13 -0
- package/dist/src/native-mariadb-backing.js +512 -0
- package/dist/src/native-postgres-backing.d.ts +4 -10
- package/dist/src/native-postgres-backing.js +428 -43
- package/dist/src/native-server-inputs.d.ts +16 -0
- package/dist/src/native-server-inputs.js +204 -0
- package/dist/src/origin.js +3 -3
- package/dist/src/original-inputs.d.ts +14 -0
- package/dist/src/original-inputs.js +84 -0
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pglite-backing.d.ts +41 -17
- package/dist/src/pglite-backing.js +213 -79
- package/dist/src/pglite-host.mjs +482 -10
- package/dist/src/process-groups.d.mts +6 -1
- package/dist/src/process-groups.d.ts +2 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/process-groups.mjs +82 -15
- package/dist/src/process-signal.d.mts +2 -2
- package/dist/src/process-signal.mjs +29 -9
- package/dist/src/project-inspect.d.ts +12 -0
- package/dist/src/project-inspect.js +41 -4
- package/dist/src/proxy-env.d.ts +34 -0
- package/dist/src/proxy-env.js +114 -0
- package/dist/src/redirect-proxy.d.ts +1 -31
- package/dist/src/redirect-proxy.js +3 -112
- package/dist/src/redis-backing.d.ts +6 -4
- package/dist/src/redis-backing.js +20 -168
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +503 -218
- package/dist/src/schema.d.ts +1 -0
- package/dist/src/schema.js +5 -3
- package/dist/src/served-world.js +3 -3
- package/dist/src/twin-backing.d.ts +47 -0
- package/dist/src/twin-backing.js +343 -0
- package/dist/src/up-cli.js +56 -3
- package/package.json +3 -3
- package/src/ancestry.ts +2 -2
- package/src/app-url.ts +9 -2
- package/src/branch.ts +26 -8
- package/src/browser-files.ts +15 -0
- package/src/browsers.ts +4 -4
- package/src/catalog.ts +4 -3
- package/src/changeset.ts +1 -1
- package/src/cli-commands.ts +85 -18
- package/src/cli-load-clock.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/clickhouse-backing.ts +70 -0
- package/src/command-lifetime.d.mts +1 -1
- package/src/command-lifetime.mjs +5 -2
- package/src/configs.ts +1 -1
- package/src/consumers.ts +1 -1
- package/src/covers.ts +9 -4
- package/src/go-mysql-binding.ts +49 -0
- package/src/guest-processes.d.mts +1 -1
- package/src/guest-processes.mjs +22 -14
- package/src/host-cli.ts +7 -1
- package/src/host-ready.ts +62 -31
- package/src/host-worker.ts +6 -2
- package/src/host.ts +88 -6
- package/src/infra-definition.ts +135 -0
- package/src/infra.ts +250 -45
- package/src/init.ts +416 -17
- package/src/lifecycle-record.ts +18 -4
- package/src/local-branches.ts +1 -1
- package/src/mariadb-bootstrap.ts +107 -0
- package/src/mariadb-supplier.ts +199 -0
- package/src/mongodb-backing.ts +59 -1
- package/src/native-mariadb-backing.ts +418 -0
- package/src/native-postgres-backing.ts +353 -41
- package/src/native-server-inputs.ts +169 -0
- package/src/origin.ts +3 -3
- package/src/original-inputs.ts +72 -0
- package/src/pack-facts.ts +1 -1
- package/src/pglite-backing.ts +192 -80
- package/src/pglite-host.mjs +482 -10
- package/src/process-groups.d.mts +6 -1
- package/src/process-groups.mjs +82 -15
- package/src/process-groups.ts +2 -1
- package/src/process-signal.d.mts +2 -2
- package/src/process-signal.mjs +29 -9
- package/src/project-inspect.ts +37 -4
- package/src/proxy-env.ts +128 -0
- package/src/redirect-proxy.ts +3 -125
- package/src/redis-backing.ts +19 -136
- package/src/runtime.ts +342 -107
- package/src/schema.ts +5 -4
- package/src/served-world.ts +3 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
package/src/process-groups.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
package/src/process-groups.ts
CHANGED
|
@@ -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';
|
package/src/process-signal.d.mts
CHANGED
|
@@ -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>;
|
package/src/process-signal.mjs
CHANGED
|
@@ -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 {
|
|
152
|
-
catch (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
|
-
|
|
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 {
|
|
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. */
|
package/src/project-inspect.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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*$/);
|
package/src/proxy-env.ts
ADDED
|
@@ -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
|
+
}
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
}
|