@volter/world-runtime 3.0.37 → 3.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/known-external-services.json +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- package/src/world-view.ts +2 -2
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { COMMAND_GRACE_MS } from './command-lifetime.mjs';
|
|
2
|
+
// Runtime teardown contract: existing 5000 ms grace, not a new vendor default.
|
|
3
|
+
export function guestControl(operation, label, signal, budget = COMMAND_GRACE_MS) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const abort = () => controller.abort(signal.reason);
|
|
6
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
7
|
+
const timer = setTimeout(() => controller.abort(new Error(`${label} exceeded ${budget} ms runtime control budget; ownership retained`)), budget);
|
|
8
|
+
let rejectAbort;
|
|
9
|
+
const failed = new Promise((_, reject) => { rejectAbort = reject; });
|
|
10
|
+
const cancelled = () => rejectAbort(controller.signal.reason);
|
|
11
|
+
controller.signal.addEventListener('abort', cancelled, { once: true });
|
|
12
|
+
if (controller.signal.aborted) cancelled();
|
|
13
|
+
return Promise.race([Promise.resolve().then(() => { controller.signal.throwIfAborted(); return operation(controller.signal); }), failed]).finally(() => {
|
|
14
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
15
|
+
controller.signal.removeEventListener('abort', cancelled);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// One pinned Substrate host; the interim adapter admits Node argv, never an OS shell.
|
|
2
|
+
import childProcess from 'node:child_process';
|
|
3
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createNodeHost, serveAgent } from '@volter/browser-host-node';
|
|
6
|
+
import { parseSimpleBrowserCommand } from '@volter/browser-runtime';
|
|
7
|
+
import { signalOwnedProcess } from './process-signal.mjs';
|
|
8
|
+
import { commandLifetime, COMMAND_GRACE_MS } from './command-lifetime.mjs';
|
|
9
|
+
import { beginGuestProcess, retainGuestProcess, releaseGuestProcesses } from './guest-processes.mjs';
|
|
10
|
+
import { admitGuestArgv, guestEnvironment } from './guest-command.mjs';
|
|
11
|
+
import { guestControl } from './guest-control.mjs';
|
|
12
|
+
|
|
13
|
+
const host = createNodeHost({ root: process.env.SUBSTRATE_ROOT });
|
|
14
|
+
const routing = JSON.parse(process.env.SUBSTRATE_GUEST_ENV);
|
|
15
|
+
const retained = new Map(), admissions = new Map();
|
|
16
|
+
let closing = false, launching;
|
|
17
|
+
const rawSpawn = childProcess.spawn;
|
|
18
|
+
// The pinned lane creates/retains children synchronously in run(). Replace exactly its
|
|
19
|
+
// launcher before spawning, and keep its own pipes, process tracking and socket discovery.
|
|
20
|
+
// Scope is synchronous: inspectors and future unrelated spawns never acquire this admission.
|
|
21
|
+
childProcess.spawn = (file, args, options) => {
|
|
22
|
+
if (file !== '/bin/sh') return rawSpawn(file, args, options);
|
|
23
|
+
if (!launching || args.length !== 2 || args[0] !== '-c' || args[1] !== 'world-owned-node') throw new Error('guest shell execution refused');
|
|
24
|
+
const launch = launching;
|
|
25
|
+
const admitted = admitGuestArgv(launch.argv, process.env.SUBSTRATE_GUEST_RUNTIME, host.root, options.cwd);
|
|
26
|
+
const receipt = beginGuestProcess(process.env.SUBSTRATE_RECEIPTS, process.env.SUBSTRATE_WORLD_INSTANCE, process.env.SUBSTRATE_GUEST_NAME, 'command');
|
|
27
|
+
const child = rawSpawn(process.execPath, admitted.argv.slice(1), { ...options, env: { ...guestEnvironment(options.env ?? {}), ...routing }, cwd: admitted.cwd, shell: false });
|
|
28
|
+
const lifetime = commandLifetime(child, launch.signal);
|
|
29
|
+
const held = { child, lifetime, receipt };
|
|
30
|
+
if (!child.pid) {
|
|
31
|
+
void lifetime.finish().then(() => releaseGuestProcesses([receipt])).catch(error => console.error('Unspawned guest receipt retained:', error));
|
|
32
|
+
throw new Error('guest Node process did not spawn');
|
|
33
|
+
}
|
|
34
|
+
retained.set(child.pid, held); // ownership before publication
|
|
35
|
+
retainGuestProcess(receipt, { pid: child.pid });
|
|
36
|
+
held.birth = lifetime.identity.then(birth => {
|
|
37
|
+
if (!birth?.start || child.exitCode !== null || child.signalCode !== null || retained.get(child.pid) !== held) {
|
|
38
|
+
throw new Error('guest spawn birth invalid: original child no longer held/live or birth unavailable; admission refused');
|
|
39
|
+
}
|
|
40
|
+
retainGuestProcess(receipt, birth); // readiness cannot precede this successful atomic publication
|
|
41
|
+
return birth;
|
|
42
|
+
});
|
|
43
|
+
held.birth.then(launch.resolve, launch.reject);
|
|
44
|
+
void lifetime.finish().then(() => {
|
|
45
|
+
retained.delete(child.pid);
|
|
46
|
+
releaseGuestProcesses([receipt]); // commandLifetime confirms close and whole-group absence
|
|
47
|
+
}).catch(error => console.error('Guest command retirement unconfirmed; receipt retained:', error));
|
|
48
|
+
launch.held = held;
|
|
49
|
+
return child;
|
|
50
|
+
};
|
|
51
|
+
syncBuiltinESMExports();
|
|
52
|
+
const laneRun = host.lane.run.bind(host.lane);
|
|
53
|
+
host.lane.run = () => Promise.reject(new Error('guest shell fallback refused; only admitted Node argv is supported'));
|
|
54
|
+
host.lane.openPty = () => Promise.reject(new Error('guest PTY/shell execution is unsupported by Node argv admission'));
|
|
55
|
+
const program = '__world_node_argv';
|
|
56
|
+
host.runtime.registerProgram(program, async (argv, options) => {
|
|
57
|
+
const launch = argv.length === 2 ? admissions.get(argv[1]) : undefined;
|
|
58
|
+
if (closing || !launch || launch.consumed) throw new Error('guest command has no live argv admission');
|
|
59
|
+
launch.consumed = true;
|
|
60
|
+
if (options.terminal || options.stdinStream) throw new Error('guest PTY/streamed stdin is unsupported');
|
|
61
|
+
const cwd = host.filesystem.real(options.cwd ?? '/');
|
|
62
|
+
admitGuestArgv(launch.argv, process.env.SUBSTRATE_GUEST_RUNTIME, host.root, cwd);
|
|
63
|
+
launch.signal = options.signal;
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
launching = launch;
|
|
67
|
+
// The pinned lane's raw abort callback signals a numeric PID; leave cancellation to
|
|
68
|
+
// our retained commandLifetime so every delivery uses the held child or verified birth.
|
|
69
|
+
result = laneRun('world-owned-node', { ...options, signal: undefined, env: { ...guestEnvironment(options.env ?? {}), ...routing } });
|
|
70
|
+
} catch (error) { launch.reject(error); throw error; }
|
|
71
|
+
finally { launching = undefined; }
|
|
72
|
+
if (!launch.held) { const error = new Error('Substrate did not retain the admitted Node child'); launch.reject(error); throw error; }
|
|
73
|
+
return (await Promise.all([result, launch.birth]))[0];
|
|
74
|
+
});
|
|
75
|
+
const runtimeStart = host.runtime.start.bind(host.runtime);
|
|
76
|
+
host.runtime.start = (command, options) => {
|
|
77
|
+
let argv = parseSimpleBrowserCommand(command);
|
|
78
|
+
// RemoteRuntime's wire envelope is decoded as data; neither envelope nor payload runs in a shell.
|
|
79
|
+
if (argv.length === 3 && argv[0] === 'sh' && argv[1] === '-c') argv = parseSimpleBrowserCommand(argv[2]);
|
|
80
|
+
if (closing || argv.length !== 2 || argv[0] !== program || !admissions.has(argv[1])) throw new Error('guest command is not an admitted Node argv operation');
|
|
81
|
+
return runtimeStart(`${program} ${argv[1]}`, options);
|
|
82
|
+
};
|
|
83
|
+
async function signalCommands(signal) {
|
|
84
|
+
await Promise.all([...retained.values()].map(async held => {
|
|
85
|
+
const birth = await held.birth; // rejected/unheld captures can never authorize a numeric signal
|
|
86
|
+
await signalOwnedProcess(birth, signal, true);
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
const agent = await guestControl(() => serveAgent({ host, token: process.env.SUBSTRATE_TOKEN, port: Number(process.env.SUBSTRATE_AGENT_PORT), bind: '127.0.0.1' }), 'guest agent startup');
|
|
90
|
+
const streams = new Set();
|
|
91
|
+
agent.server.on('request', (request, response) => {
|
|
92
|
+
if (!new URL(request.url, 'http://guest').pathname.endsWith('/events')) return;
|
|
93
|
+
streams.add(response); response.on('close', () => streams.delete(response));
|
|
94
|
+
});
|
|
95
|
+
async function close() {
|
|
96
|
+
if (closing) return;
|
|
97
|
+
closing = true;
|
|
98
|
+
const commands = [...retained.values()];
|
|
99
|
+
for (const held of commands) { held.lifetime.cancel('SIGCONT'); held.lifetime.cancel('SIGTERM'); }
|
|
100
|
+
// Every command retires through the same bounded grace/KILL/confirmation as World commands.
|
|
101
|
+
const results = await Promise.allSettled(commands.map(held => held.lifetime.finish()));
|
|
102
|
+
for (const result of results) if (result.status === 'rejected') throw result.reason;
|
|
103
|
+
await guestControl(async () => {
|
|
104
|
+
await host.close();
|
|
105
|
+
for (const response of streams) response.end();
|
|
106
|
+
await agent.close();
|
|
107
|
+
}, 'Substrate guest drain');
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
process.on('message', async ({ id, action, path }) => {
|
|
111
|
+
try {
|
|
112
|
+
let value;
|
|
113
|
+
if (action === 'prepare') {
|
|
114
|
+
if (closing) throw new Error('Substrate guest is retiring');
|
|
115
|
+
const argv = JSON.parse(path);
|
|
116
|
+
if (!Array.isArray(argv) || argv[0] !== 'node') throw new Error('admitted guest program is node');
|
|
117
|
+
const key = randomUUID();
|
|
118
|
+
let resolve, reject;
|
|
119
|
+
const birth = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
120
|
+
birth.catch(() => {});
|
|
121
|
+
admissions.set(key, { argv, birth, resolve, reject, consumed: false });
|
|
122
|
+
value = key;
|
|
123
|
+
} else if (action === 'admission') {
|
|
124
|
+
const launch = admissions.get(path);
|
|
125
|
+
if (!launch) throw new Error('guest launch admission missing');
|
|
126
|
+
try { value = await guestControl(() => launch.birth, 'guest birth admission'); }
|
|
127
|
+
finally { if (launch.consumed) admissions.delete(path); }
|
|
128
|
+
} else if (action === 'pause' || action === 'resume') {
|
|
129
|
+
await guestControl(() => signalCommands(action === 'pause' ? 'SIGSTOP' : 'SIGCONT'), `guest ${action}`);
|
|
130
|
+
} else if (action === 'stat') {
|
|
131
|
+
const s = host.filesystem.statSync(path);
|
|
132
|
+
value = { size: s.size, mode: s.mode, mtime: s.mtime.toISOString(), type: s.isDirectory() ? 'directory' : 'file' };
|
|
133
|
+
} else throw new Error(`unsupported guest control ${action}`);
|
|
134
|
+
if (process.connected) process.send?.({ id, value });
|
|
135
|
+
} catch (error) { if (process.connected) process.send?.({ id, error: error.message }); }
|
|
136
|
+
});
|
|
137
|
+
const retire = () => void close().catch(error => {
|
|
138
|
+
console.error('Substrate guest retirement refused; ownership retained:', error);
|
|
139
|
+
// No success or receipt deletion on uncertainty; exit closes control. World down owns retry.
|
|
140
|
+
process.exit(1);
|
|
141
|
+
});
|
|
142
|
+
process.on('disconnect', retire);
|
|
143
|
+
if (!process.connected) retire();
|
|
144
|
+
process.on('SIGTERM', retire);
|
|
145
|
+
process.on('SIGINT', retire);
|
|
146
|
+
process.send?.({ ready: true, port: agent.port, controlBudgetMs: COMMAND_GRACE_MS });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SpawnIdentity } from './process-receipt.js';
|
|
2
|
+
export interface GuestProcessRecord extends SpawnIdentity { instance: string; guest: string; kind: 'agent' | 'command' | 'pty'; launching: false }
|
|
3
|
+
export interface GuestProcessReceipt { path: string; record: GuestProcessRecord }
|
|
4
|
+
export interface GuestProcessLaunch { path: string; record: Omit<GuestProcessRecord, 'pid' | 'launching'> & { pid?: number; launching: boolean } }
|
|
5
|
+
export function guestProcessDirectory(instanceDir: string): string;
|
|
6
|
+
export function beginGuestProcess(directory: string, instance: string, guest: string, kind: GuestProcessRecord['kind']): GuestProcessLaunch;
|
|
7
|
+
export function retainGuestProcess(receipt: GuestProcessLaunch, identity: SpawnIdentity): void;
|
|
8
|
+
export function readGuestProcesses(instanceDir: string, expectedInstance?: string): GuestProcessReceipt[];
|
|
9
|
+
export function releaseGuestProcesses(receipts: Array<{ path: string }>): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Guest metadata over the existing atomic process receipt; no independent lifecycle.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { publishProcessReceipt, readProcessReceipt } from './process-receipt.mjs';
|
|
6
|
+
|
|
7
|
+
export const guestProcessDirectory = instanceDir => join(instanceDir, 'guest-processes');
|
|
8
|
+
export function beginGuestProcess(directory, instance, guest, kind) {
|
|
9
|
+
mkdirSync(directory, { recursive: true });
|
|
10
|
+
const path = join(directory, `${randomUUID()}.json`);
|
|
11
|
+
const record = { instance, guest, kind, launching: true };
|
|
12
|
+
publishProcessReceipt(path, JSON.stringify(record));
|
|
13
|
+
return { path, record };
|
|
14
|
+
}
|
|
15
|
+
export function retainGuestProcess(receipt, identity) {
|
|
16
|
+
receipt.record = { ...receipt.record, launching: false, ...identity };
|
|
17
|
+
publishProcessReceipt(receipt.path, JSON.stringify(receipt.record));
|
|
18
|
+
}
|
|
19
|
+
export function readGuestProcesses(instanceDir, expectedInstance) {
|
|
20
|
+
const directory = guestProcessDirectory(instanceDir);
|
|
21
|
+
if (!existsSync(directory)) return [];
|
|
22
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error('Guest receipt directory is a symbolic link; ownership retained');
|
|
23
|
+
const names = readdirSync(directory);
|
|
24
|
+
if (!names.length) return [];
|
|
25
|
+
if (!expectedInstance) {
|
|
26
|
+
try { expectedInstance = JSON.parse(readFileSync(join(instanceDir, 'instance.json'), 'utf8')).instance; }
|
|
27
|
+
catch { throw new Error('Guest instance identity unreadable; ownership retained'); }
|
|
28
|
+
}
|
|
29
|
+
return names.map(name => {
|
|
30
|
+
const path = join(directory, name);
|
|
31
|
+
if (!/^[0-9a-f-]+\.json$/.test(name) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unresolved guest receipt publication; ownership retained');
|
|
32
|
+
const receipt = readProcessReceipt(path, text => {
|
|
33
|
+
const r = JSON.parse(text);
|
|
34
|
+
if (r.instance !== expectedInstance || typeof r.guest !== 'string' || !['agent', 'command', 'pty'].includes(r.kind)
|
|
35
|
+
|| r.launching || !Number.isInteger(r.pid) || r.pid < 1 || (r.start !== undefined && (typeof r.start !== 'string' || !r.start))) {
|
|
36
|
+
throw new Error('Guest launch or spawn identity unresolved');
|
|
37
|
+
}
|
|
38
|
+
return r;
|
|
39
|
+
});
|
|
40
|
+
if (receipt.error || !receipt.value) throw receipt.error ?? new Error('Guest receipt disappeared during inspection; ownership retained');
|
|
41
|
+
return { path, record: receipt.value };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// The caller must have confirmed each recorded group absent before releasing these receipts.
|
|
45
|
+
export function releaseGuestProcesses(receipts) {
|
|
46
|
+
for (const { path } of receipts) rmSync(path);
|
|
47
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// Substrate-backed light guests; architecture, "Other wires: … machines". Imports start nothing.
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { accessSync, constants, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync } from 'node:fs';
|
|
5
|
+
import { dirname, basename, join, resolve, posix, delimiter } from 'node:path';
|
|
6
|
+
import { RemoteRuntime, type RemoteProcessHandle, type SandboxProvider } from '@volter/browser-sandboxes';
|
|
7
|
+
import type { MachinePool, MachineSpec, MachineProcess, MachineStatus, GuestImage, LightPoolOptions } from '@volter/world-core/runtime';
|
|
8
|
+
import { allocatePort } from './tcp-port.ts';
|
|
9
|
+
import { ownedPath, assertOwnedRealPath } from './owned-path.ts';
|
|
10
|
+
import { commandLifetime, commandProcessGroup } from './command-lifetime.ts';
|
|
11
|
+
import { beginGuestProcess, retainGuestProcess, readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
|
|
12
|
+
import { retireOwnedProcessGroups } from './runtime.ts';
|
|
13
|
+
import { guestControl } from './guest-control.mjs';
|
|
14
|
+
import { admitGuestArgv, guestCommandGap, guestEnvironment } from './guest-command.mjs';
|
|
15
|
+
type Plan = { runtime: string; cwd: string; env: Record<string, string>; command: string[]; ports: number[]; copies: Array<{ sources: string[]; destination: string; cwd: string }> };
|
|
16
|
+
type Guest = { name: string; dir: string; cwd: string; env: Record<string, string>; child: ChildProcess; lifetime: ReturnType<typeof commandLifetime>; runtime: RemoteRuntime; ports: Set<number>; status: MachineStatus; stopping?: boolean; launch?: RemoteProcessHandle; imageRuntime: string; agentPort: number; resetOnStop?: boolean };
|
|
17
|
+
const delay = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
|
18
|
+
// Re-enrolling image metadata must not lose ownership of guests already running in this World.
|
|
19
|
+
const pools = new Map<string, Map<string, Guest>>();
|
|
20
|
+
const lifecycle = new Map<string, Map<string, MachineStatus>>();
|
|
21
|
+
/** Word splitting for the supported Dockerfile instructions; no shell is executed while parsing. */
|
|
22
|
+
function words(text: string): string[] {
|
|
23
|
+
const out: string[] = []; let word = '', quote = '', active = false;
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
const ch = text[i]!;
|
|
26
|
+
if (ch === '\\' && quote !== "'") { word += text[++i] ?? ''; active = true; }
|
|
27
|
+
else if (quote) { if (ch === quote) quote = ''; else word += ch; }
|
|
28
|
+
else if (ch === '"' || ch === "'") { quote = ch; active = true; }
|
|
29
|
+
else if (/\s/.test(ch)) { if (active) { out.push(word); word = ''; active = false; } }
|
|
30
|
+
else { word += ch; active = true; }
|
|
31
|
+
}
|
|
32
|
+
if (quote) throw new Error('unsupported unterminated Dockerfile quote');
|
|
33
|
+
if (active) out.push(word);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function command(text: string): string[] {
|
|
37
|
+
if (!text.trim().startsWith('[')) throw guestCommandGap('guest shell-form CMD/ENTRYPOINT is unsupported; use Node argv with an image entry file');
|
|
38
|
+
const value: unknown = JSON.parse(text);
|
|
39
|
+
if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) throw new Error('invalid Dockerfile exec form');
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function plan(image: GuestImage): Plan {
|
|
43
|
+
const out: Plan = { runtime: image.runtime ?? '', cwd: '/', env: {}, command: [], ports: [], copies: [] };
|
|
44
|
+
let entry: string[] = [], cmd: string[] = [], stages = 0;
|
|
45
|
+
const path = resolve(image.context, image.dockerfile ?? 'Dockerfile');
|
|
46
|
+
if (image.dockerfile || existsSync(path)) {
|
|
47
|
+
for (const line of readFileSync(path, 'utf8').replace(/\\\r?\n/g, ' ').split(/\r?\n/)) {
|
|
48
|
+
if (!line.trim() || line.trimStart().startsWith('#')) continue;
|
|
49
|
+
const hit = /^\s*(\w+)\s+(.+)$/.exec(line);
|
|
50
|
+
if (!hit) throw new Error(`unsupported Dockerfile line: ${line}`);
|
|
51
|
+
const op = hit[1]!.toUpperCase(), body = hit[2]!;
|
|
52
|
+
const expand = (s: string): string => s.replace(/\$\{(\w+)\}|\$(\w+)/g, (_, a, b) => out.env[a ?? b] ?? '');
|
|
53
|
+
switch (op) {
|
|
54
|
+
case 'FROM': {
|
|
55
|
+
if (++stages > 1) throw new Error('light guests cannot build multi-stage images; enroll a prepared context and command');
|
|
56
|
+
const base = words(body)[0] ?? '';
|
|
57
|
+
const inferred = /(?:^|\/)(node|bun|python|deno)(?=[:@/]|$)/.exec(base)?.[1];
|
|
58
|
+
out.runtime ||= inferred ?? '';
|
|
59
|
+
if (!out.runtime) throw guestCommandGap(`unsupported guest runtime in base image ${base}`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'WORKDIR': out.cwd = posix.resolve(out.cwd, expand(body)); break;
|
|
63
|
+
case 'ENV': {
|
|
64
|
+
const values = words(body);
|
|
65
|
+
if (!values[0]?.includes('=')) out.env[values.shift() ?? ''] = expand(values.join(' '));
|
|
66
|
+
else for (const value of values) { const n = value.indexOf('='); if (n < 1) throw new Error('invalid ENV'); out.env[value.slice(0, n)] = expand(value.slice(n + 1)); }
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'EXPOSE': for (const value of words(body)) { if (value.endsWith('/udp')) throw new Error('light guests support TCP only'); out.ports.push(Number(value.split('/')[0])); } break;
|
|
70
|
+
case 'CMD': cmd = command(body); break;
|
|
71
|
+
case 'ENTRYPOINT': entry = command(body); break;
|
|
72
|
+
case 'COPY': {
|
|
73
|
+
const values = body.trim().startsWith('[') ? command(body) : words(body);
|
|
74
|
+
if (values.some(v => v.startsWith('--') || /[*?]/.test(v))) throw new Error('unsupported COPY flag, stage or glob');
|
|
75
|
+
const destination = expand(values.pop() ?? '');
|
|
76
|
+
if (!values.length || !destination) throw new Error('invalid COPY');
|
|
77
|
+
out.copies.push({ sources: values.map(expand), destination, cwd: out.cwd }); break;
|
|
78
|
+
}
|
|
79
|
+
case 'RUN':
|
|
80
|
+
if (/\b(apt|apt-get|apk|yum|dnf|pacman|brew|curl|wget)\b/.test(body) || !/^(npm|pnpm|yarn|bun|pip|pip3|python|python3)\b/.test(body.trim())) throw new Error(`unsupported OS/build step: RUN ${body}`);
|
|
81
|
+
if (!image.prepared) throw new Error(`RUN needs a prepared context: ${body}`);
|
|
82
|
+
break;
|
|
83
|
+
case 'LABEL': break;
|
|
84
|
+
default: throw new Error(`unsupported Dockerfile instruction ${op}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
out.runtime = image.runtime ?? out.runtime;
|
|
89
|
+
if (out.runtime !== 'node') throw guestCommandGap(`unsupported guest runtime ${out.runtime || '(unknown)'}`);
|
|
90
|
+
out.command = image.command ?? [...entry, ...cmd];
|
|
91
|
+
out.cwd = image.cwd ?? out.cwd;
|
|
92
|
+
out.env = { ...out.env, ...image.env };
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function guestPath(dir: string, path: string, cwd = '/'): string {
|
|
97
|
+
// Reject traversal rather than let a normalization hide it. This is path hygiene, not isolation.
|
|
98
|
+
if (path.split('/').includes('..')) throw new Error('guest path traversal is unsupported');
|
|
99
|
+
const target = join(dir, posix.resolve(cwd, path));
|
|
100
|
+
let existing = target;
|
|
101
|
+
while (!existsSync(existing) && existing !== dir) existing = dirname(existing);
|
|
102
|
+
assertOwnedRealPath(realpathSync(dir), existing);
|
|
103
|
+
return target;
|
|
104
|
+
}
|
|
105
|
+
function copy(source: string, destination: string): void {
|
|
106
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
107
|
+
cpSync(source, destination, { recursive: true, dereference: true, filter: path => !['.git', '.volter'].includes(basename(path)) });
|
|
108
|
+
}
|
|
109
|
+
function prepare(dir: string, image: GuestImage, p: Plan): void {
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const context = realpathSync(image.context);
|
|
112
|
+
if (p.copies.length) for (const item of p.copies) {
|
|
113
|
+
mkdirSync(guestPath(dir, item.cwd), { recursive: true });
|
|
114
|
+
const destination = guestPath(dir, item.destination, item.cwd);
|
|
115
|
+
const directory = item.sources.length > 1 || item.destination.endsWith('/') || (existsSync(destination) && statSync(destination).isDirectory());
|
|
116
|
+
if (directory) mkdirSync(destination, { recursive: true });
|
|
117
|
+
for (const source of item.sources) {
|
|
118
|
+
const from = realpathSync(resolve(context, source));
|
|
119
|
+
assertOwnedRealPath(context, from);
|
|
120
|
+
copy(from, directory && !statSync(from).isDirectory() ? join(destination, basename(source)) : destination);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const target = guestPath(dir, p.cwd); mkdirSync(target, { recursive: true });
|
|
124
|
+
for (const entry of readdirSync(context)) {
|
|
125
|
+
if (['.git', '.volter', 'node_modules'].includes(entry)) continue;
|
|
126
|
+
copy(join(context, entry), join(target, entry));
|
|
127
|
+
}
|
|
128
|
+
if (existsSync(join(context, 'node_modules')) && !existsSync(join(target, 'node_modules'))) symlinkSync(join(context, 'node_modules'), join(target, 'node_modules'));
|
|
129
|
+
}
|
|
130
|
+
mkdirSync(guestPath(dir, p.cwd), { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function worldEnv(): NodeJS.ProcessEnv {
|
|
134
|
+
const instancePath = process.env.VOLTER_WORLD_INSTANCE;
|
|
135
|
+
if (!instancePath) throw new Error('guest execution requires a World instance');
|
|
136
|
+
const instance = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
137
|
+
// proxy-env has the completed vendor map; the early twin process env may not.
|
|
138
|
+
const current = JSON.parse(readFileSync(join(dirname(instancePath), 'proxy-env.json'), 'utf8'));
|
|
139
|
+
const env: NodeJS.ProcessEnv = { ...instance.env, ...current };
|
|
140
|
+
if (!env.VOLTER_WORLD || !env.NODE_OPTIONS) throw new Error('World guest injection environment is incomplete');
|
|
141
|
+
return env;
|
|
142
|
+
}
|
|
143
|
+
function injected(base: NodeJS.ProcessEnv, deploy: Record<string, string>, dir: string): NodeJS.ProcessEnv {
|
|
144
|
+
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, ...guestEnvironment(deploy) };
|
|
145
|
+
// Admission already happened at World boot; never inherit the twin process's ambient secrets.
|
|
146
|
+
for (const [key, value] of Object.entries(base)) {
|
|
147
|
+
if (/^(VOLTER_|TWIN_|NODE_OPTIONS$|NODE_EXTRA_CA_CERTS$|.*_TWIN_URL$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$|SSL_CERT_FILE$|REQUESTS_CA_BUNDLE$)/.test(key)) env[key] = value;
|
|
148
|
+
}
|
|
149
|
+
for (const [key, value] of Object.entries(env)) {
|
|
150
|
+
if (value?.startsWith('/') && !/^(PATH|NODE_|VOLTER_|TWIN_|SSL_|REQUESTS_)/.test(key)) env[key] = join(dir, value);
|
|
151
|
+
}
|
|
152
|
+
if (!base.VOLTER_WORLD_GUEST_PROXY) throw new Error('World guest proxy is missing');
|
|
153
|
+
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'VOLTER_WORLD_PROXY']) env[key] = base.VOLTER_WORLD_GUEST_PROXY;
|
|
154
|
+
env.NO_PROXY = env.no_proxy = '';
|
|
155
|
+
env.HOME = join(dir, 'home/user'); mkdirSync(env.HOME, { recursive: true });
|
|
156
|
+
return env;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve only the runtime's operator PATH, once; deployment/exec PATH is never consulted.
|
|
160
|
+
let nodeExecutable: string | undefined;
|
|
161
|
+
function trustedNodeExecutable(): string {
|
|
162
|
+
if (nodeExecutable) return nodeExecutable;
|
|
163
|
+
if (!process.versions.bun) return nodeExecutable = realpathSync(process.execPath);
|
|
164
|
+
for (const folder of (process.env.PATH ?? '').split(delimiter)) {
|
|
165
|
+
if (!folder.startsWith('/')) continue;
|
|
166
|
+
const candidate = resolve(folder, process.platform === 'win32' ? 'node.exe' : 'node');
|
|
167
|
+
try { accessSync(candidate, constants.X_OK); if (statSync(candidate).isFile()) return nodeExecutable = realpathSync(candidate); } catch { /* next operator PATH entry */ }
|
|
168
|
+
}
|
|
169
|
+
throw new Error('trusted Node executable unavailable in runtime operator PATH');
|
|
170
|
+
}
|
|
171
|
+
let messageId = 0;
|
|
172
|
+
function ask(child: ChildProcess, action: string, path?: string, signal?: AbortSignal): Promise<any> {
|
|
173
|
+
return guestControl(control => new Promise((yes, no) => {
|
|
174
|
+
if (!child.connected) { no(new Error('Substrate guest control disconnected')); return; }
|
|
175
|
+
const id = ++messageId;
|
|
176
|
+
const cleanup = (): void => { child.off('message', reply); child.off('exit', ended); child.off('disconnect', ended); child.off('error', failed); control.removeEventListener('abort', aborted); };
|
|
177
|
+
const failed = (error: Error): void => { cleanup(); no(error); };
|
|
178
|
+
const aborted = (): void => failed(control.reason);
|
|
179
|
+
const ended = (): void => failed(new Error('Substrate guest exited during control request'));
|
|
180
|
+
const reply = (message: any): void => { if (message.id !== id) return; cleanup(); message.error ? no(new Error(message.error)) : yes(message.value); };
|
|
181
|
+
child.on('message', reply); child.once('exit', ended); child.once('disconnect', ended); child.once('error', failed);
|
|
182
|
+
control.addEventListener('abort', aborted, { once: true });
|
|
183
|
+
child.send({ id, action, path }, error => { if (error) failed(error); });
|
|
184
|
+
}), `guest ${action}`, signal);
|
|
185
|
+
}
|
|
186
|
+
async function startAdmitted(g: Guest, args: string[], options: Parameters<RemoteRuntime['start']>[1], signal?: AbortSignal): Promise<RemoteProcessHandle> {
|
|
187
|
+
const cwd = options?.cwd ?? g.cwd;
|
|
188
|
+
const mapped = [...args];
|
|
189
|
+
if (mapped[1]?.startsWith('/')) mapped[1] = guestPath(g.dir, mapped[1]);
|
|
190
|
+
const admitted = admitGuestArgv(mapped, g.imageRuntime, g.dir, guestPath(g.dir, cwd));
|
|
191
|
+
const key = await ask(g.child, 'prepare', JSON.stringify(admitted.argv), signal);
|
|
192
|
+
const handle = await guestControl(() => g.runtime.start(`__world_node_argv ${key}`, options), 'guest start transport', signal);
|
|
193
|
+
void handle.result.catch(() => {}); // observe a failure even if admission refuses first
|
|
194
|
+
try { await ask(g.child, 'admission', key, signal); } // valid held birth has been durably published
|
|
195
|
+
catch (error) { handle.kill(); throw error; }
|
|
196
|
+
return handle;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Local provider for Substrate's sandbox contract; all guest operations are vendor-neutral. */
|
|
200
|
+
export function createLightGuestPool(options: LightPoolOptions & { world: string; root?: string }): MachinePool {
|
|
201
|
+
const instancePath = process.env.VOLTER_WORLD_INSTANCE;
|
|
202
|
+
if (!instancePath) throw new Error('guest pool requires a World instance');
|
|
203
|
+
const instanceDir = dirname(instancePath), instance = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
204
|
+
const receiptsDir = ownedPath(instanceDir, 'guest-processes');
|
|
205
|
+
const receiptGuest = (name: string): string => `${options.world}/${name}`;
|
|
206
|
+
const receipts = (name: string) => readGuestProcesses(instanceDir, instance.instance).filter(r => r.record.guest === receiptGuest(name));
|
|
207
|
+
const data = options.root ?? join(dirname(process.env.VOLTER_WORLD_INSTANCE ?? ''), 'data');
|
|
208
|
+
const root = ownedPath(data, `guests/${options.world}`);
|
|
209
|
+
const guests = pools.get(root) ?? new Map<string, Guest>();
|
|
210
|
+
const states = lifecycle.get(root) ?? new Map<string, MachineStatus>();
|
|
211
|
+
pools.set(root, guests); lifecycle.set(root, states);
|
|
212
|
+
const update = (g: Guest, status: MachineStatus): void => { g.status = status; states.set(g.name, status); };
|
|
213
|
+
const live = (name: string): Guest => {
|
|
214
|
+
const g = guests.get(name);
|
|
215
|
+
if (!g || g.status.state !== 'started' || g.child.exitCode !== null || g.child.signalCode !== null) throw new Error(g?.status.reason ?? `Substrate guest is ${g?.status.state ?? 'missing'}`);
|
|
216
|
+
return g;
|
|
217
|
+
};
|
|
218
|
+
const stop = async (name: string): Promise<void> => {
|
|
219
|
+
const g = guests.get(name); if (!g) return;
|
|
220
|
+
const terminal = g.status.state === 'exited' || g.status.state === 'failed' ? g.status : undefined;
|
|
221
|
+
g.stopping = true;
|
|
222
|
+
g.runtime.close();
|
|
223
|
+
// cancel resumes the agent's private group. Its retained Substrate handles retire commands;
|
|
224
|
+
// durable command groups use the same World grace/KILL/confirmation, even after agent loss.
|
|
225
|
+
g.lifetime.cancel('SIGCONT');
|
|
226
|
+
g.lifetime.cancel('SIGTERM');
|
|
227
|
+
try {
|
|
228
|
+
const outcomes = await Promise.allSettled([
|
|
229
|
+
g.lifetime.finish(),
|
|
230
|
+
retireOwnedProcessGroups(receipts(name).filter(r => r.record.kind !== 'agent').map(r => r.record)),
|
|
231
|
+
]);
|
|
232
|
+
for (const outcome of outcomes) if (outcome.status === 'rejected') throw outcome.reason;
|
|
233
|
+
const remaining = receipts(name);
|
|
234
|
+
await retireOwnedProcessGroups(remaining.map(r => r.record));
|
|
235
|
+
releaseGuestProcesses(remaining);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
update(g, { state: 'failed', reason: `guest retirement unconfirmed; ownership retained: ${error}` });
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
update(g, terminal ?? { state: 'stopped' });
|
|
241
|
+
guests.delete(name);
|
|
242
|
+
if (g.resetOnStop) rmSync(ownedPath(data, `guests/${options.world}/${name}`), { recursive: true, force: true });
|
|
243
|
+
};
|
|
244
|
+
const provider: SandboxProvider = {
|
|
245
|
+
pauseMode: 'freeze-processes',
|
|
246
|
+
async start({ id }) { const g = guests.get(id); if (!g) throw new Error('Substrate guest launch was not prepared'); return { name: id, url: `http://127.0.0.1:${g.agentPort}` }; },
|
|
247
|
+
async pause({ name }) { const g = live(name); await ask(g.child, 'pause'); update(g, { state: 'paused' }); },
|
|
248
|
+
async resume({ name }) { const g = guests.get(name); if (!g || g.status.state !== 'paused') throw new Error('Substrate guest cannot resume'); await ask(g.child, 'resume'); update(g, { state: 'started' }); },
|
|
249
|
+
async stop({ name }) { await stop(name); },
|
|
250
|
+
running() { return new Set([...guests].filter(([, g]) => g.status.state === 'started').map(([name]) => name)); },
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
kind: 'light',
|
|
254
|
+
async admitRun(spec) {
|
|
255
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(spec.name)) throw new Error('invalid guest name');
|
|
256
|
+
const image = options.images?.[spec.image];
|
|
257
|
+
if (!image) throw new Error(`image ${spec.image} has no light guest context`);
|
|
258
|
+
const p = plan(image), dir = ownedPath(data, `guests/${options.world}/${spec.name}`);
|
|
259
|
+
prepare(dir, image, p);
|
|
260
|
+
const args = [...(spec.command ?? p.command)];
|
|
261
|
+
if (args[1]?.startsWith('/')) args[1] = guestPath(dir, args[1]);
|
|
262
|
+
if (args.length) admitGuestArgv(args, p.runtime, dir, guestPath(dir, p.cwd));
|
|
263
|
+
},
|
|
264
|
+
async status(name) {
|
|
265
|
+
const g = guests.get(name);
|
|
266
|
+
if (g?.status.state === 'started' && g.launch && g.launch.state !== 'running') await g.launch.result.catch(() => {});
|
|
267
|
+
return states.get(name) ?? { state: 'failed', reason: 'Substrate guest is missing' };
|
|
268
|
+
},
|
|
269
|
+
async ports(name) { return live(name).runtime.ports(); },
|
|
270
|
+
async files(name) {
|
|
271
|
+
const g = live(name);
|
|
272
|
+
return {
|
|
273
|
+
read: path => { live(name); return g.runtime.files.read(path); },
|
|
274
|
+
write: (path, bytes) => { live(name); return g.runtime.files.write(path, bytes); },
|
|
275
|
+
list: path => { live(name); return g.runtime.files.list(path); },
|
|
276
|
+
mkdir: path => { live(name); return g.runtime.files.mkdir(path); },
|
|
277
|
+
remove: path => { live(name); return g.runtime.files.remove(path); },
|
|
278
|
+
stat: path => { live(name); return ask(g.child, 'stat', path); },
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
async run(spec: MachineSpec) {
|
|
282
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(spec.name)) throw new Error('invalid guest name');
|
|
283
|
+
if (guests.has(spec.name)) throw new Error('guest already exists; stop before starting again');
|
|
284
|
+
let starting = true;
|
|
285
|
+
if (!Number.isSafeInteger(spec.startTimeoutMs) || spec.startTimeoutMs! <= 0) throw new Error('light guest requires a vendor startup observation budget');
|
|
286
|
+
const startup = new AbortController();
|
|
287
|
+
const timeoutReason = new Error(`guest startup exceeded ${spec.startTimeoutMs} ms before declared readiness`);
|
|
288
|
+
const deadline = setTimeout(() => startup.abort(timeoutReason), spec.startTimeoutMs);
|
|
289
|
+
spec = { ...spec, signal: spec.signal ? AbortSignal.any([spec.signal, startup.signal]) : startup.signal };
|
|
290
|
+
try {
|
|
291
|
+
spec.signal?.throwIfAborted();
|
|
292
|
+
const image = options.images?.[spec.image];
|
|
293
|
+
if (!image) throw new Error(`image ${spec.image} has no light guest context`);
|
|
294
|
+
const p = plan(image), dir = ownedPath(data, `guests/${options.world}/${spec.name}`);
|
|
295
|
+
const base = worldEnv(), deploy = { ...p.env, ...spec.env }, env = injected(base, deploy, dir);
|
|
296
|
+
const args = spec.command ?? p.command;
|
|
297
|
+
prepare(dir, image, p);
|
|
298
|
+
if (args.length) {
|
|
299
|
+
const mapped = [...args];
|
|
300
|
+
if (mapped[1]?.startsWith('/')) mapped[1] = guestPath(dir, mapped[1]);
|
|
301
|
+
admitGuestArgv(mapped, p.runtime, dir, guestPath(dir, p.cwd));
|
|
302
|
+
}
|
|
303
|
+
for (const mount of spec.mounts ?? []) {
|
|
304
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(mount.name) || mount.path === '/') throw new Error('unsupported guest mount');
|
|
305
|
+
const volume = ownedPath(data, `guests/${options.world}-volumes/${mount.name}`);
|
|
306
|
+
const target = guestPath(dir, mount.path);
|
|
307
|
+
mkdirSync(volume, { recursive: true }); rmSync(target, { recursive: true, force: true });
|
|
308
|
+
mkdirSync(dirname(target), { recursive: true }); symlinkSync(volume, target);
|
|
309
|
+
}
|
|
310
|
+
if (!existsSync(join(dir, 'workspace'))) mkdirSync(join(dir, 'workspace'), { recursive: true });
|
|
311
|
+
const ports = new Set([...(spec.ports ?? []), ...p.ports]);
|
|
312
|
+
for (const internal of ports) if (!Number.isInteger(internal) || internal < 1 || internal > 65535) throw new Error('invalid guest port');
|
|
313
|
+
if (ports.size && !args.length) throw new Error('image has a listening port but no start command');
|
|
314
|
+
const agentPort = await allocatePort(new Set(ports)), token = deploy.SUBSTRATE_TOKEN ?? randomBytes(24).toString('hex');
|
|
315
|
+
// Substrate's macOS socket discovery uses lsof, installed in /usr/sbin. This agent's
|
|
316
|
+
// infrastructure PATH is separate from the declared environment passed to the product.
|
|
317
|
+
const agentPath = process.platform === 'darwin' ? `${process.env.PATH ?? ''}:/usr/sbin` : process.env.PATH;
|
|
318
|
+
const receipt = beginGuestProcess(receiptsDir, instance.instance, receiptGuest(spec.name), 'agent');
|
|
319
|
+
// The agent's own control transport remains host-side; only product commands use guest policy.
|
|
320
|
+
const guestRouting = Object.fromEntries(Object.entries(env).filter(([key]) => /^(VOLTER_|TWIN_|NODE_OPTIONS$|NODE_EXTRA_CA_CERTS$|.*_TWIN_URL$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$)/.test(key)));
|
|
321
|
+
const control = Object.fromEntries(Object.entries(base).filter(([key]) => /^(VOLTER_WORLD_PROXY$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$)/.test(key)));
|
|
322
|
+
const child = spawn(trustedNodeExecutable(), [new URL('./guest-entry.mjs', import.meta.url).pathname], { detached: commandProcessGroup, env: { ...env, ...control, PATH: agentPath, SUBSTRATE_ROOT: dir, SUBSTRATE_GUEST_RUNTIME: p.runtime, SUBSTRATE_TOKEN: token, SUBSTRATE_AGENT_PORT: String(agentPort), SUBSTRATE_GUEST_ENV: JSON.stringify(guestRouting), SUBSTRATE_RECEIPTS: receiptsDir, SUBSTRATE_WORLD_INSTANCE: instance.instance, SUBSTRATE_GUEST_NAME: receiptGuest(spec.name) }, cwd: dir, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], windowsHide: true });
|
|
323
|
+
const lifetime = commandLifetime(child);
|
|
324
|
+
const runtime = new RemoteRuntime(`http://127.0.0.1:${agentPort}`, token, {
|
|
325
|
+
fetch: ((input: Parameters<typeof fetch>[0], init?: RequestInit) => guestControl(async control => {
|
|
326
|
+
const signals = [control, init?.signal, ...(starting ? [spec.signal] : [])].filter(Boolean) as AbortSignal[];
|
|
327
|
+
const response = await fetch(input, { ...init, signal: AbortSignal.any(signals) });
|
|
328
|
+
// JSON control replies must complete inside the budget; event/product streams retain their own lifetime.
|
|
329
|
+
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
330
|
+
const bytes = await response.arrayBuffer();
|
|
331
|
+
return new Response(bytes, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
332
|
+
}
|
|
333
|
+
return response;
|
|
334
|
+
}, 'guest control transport', starting ? spec.signal : undefined)) as typeof fetch,
|
|
335
|
+
});
|
|
336
|
+
const g: Guest = { lifetime, name: spec.name, dir, cwd: p.cwd, env: env as Record<string, string>, child, runtime, ports, status: { state: 'started' }, imageRuntime: p.runtime, agentPort, resetOnStop: spec.resetOnStop };
|
|
337
|
+
guests.set(spec.name, g); update(g, g.status);
|
|
338
|
+
if (!child.pid) { await lifetime.finish(); releaseGuestProcesses([receipt]); throw new Error('Substrate guest process did not spawn'); }
|
|
339
|
+
retainGuestProcess(receipt, { pid: child.pid });
|
|
340
|
+
let diagnostic = '';
|
|
341
|
+
child.stderr?.on('data', bytes => { diagnostic = (diagnostic + String(bytes)).slice(-8192); });
|
|
342
|
+
child.on('error', error => { if (!g.stopping) update(g, { state: 'failed', reason: error.message }); });
|
|
343
|
+
child.once('exit', (code, signal) => { if (!g.stopping) update(g, { state: 'failed', ...(code !== null ? { exitCode: code } : {}), reason: `Substrate agent exited (${code ?? signal}): ${diagnostic.trim()}` }); });
|
|
344
|
+
await new Promise<void>((yes, no) => {
|
|
345
|
+
const cleanup = (): void => { child.off('message', ready); child.off('error', failed); child.off('exit', ended); spec.signal?.removeEventListener('abort', aborted); };
|
|
346
|
+
const failed = (error: Error): void => { cleanup(); no(error); };
|
|
347
|
+
const ended = (): void => failed(new Error(g.status.reason ?? 'Substrate agent exited during startup'));
|
|
348
|
+
const aborted = (): void => failed(spec.signal?.reason ?? new Error('Substrate guest startup cancelled'));
|
|
349
|
+
const ready = (message: any): void => { if (message.ready !== true) return; cleanup(); yes(); };
|
|
350
|
+
child.on('message', ready); child.once('error', failed); child.once('exit', ended);
|
|
351
|
+
spec.signal?.addEventListener('abort', aborted, { once: true });
|
|
352
|
+
if (spec.signal?.aborted) aborted();
|
|
353
|
+
});
|
|
354
|
+
const birth = await lifetime.identity;
|
|
355
|
+
if (!birth?.start || child.exitCode !== null || child.signalCode !== null) throw new Error('Substrate agent birth identity unavailable; ownership retained');
|
|
356
|
+
retainGuestProcess(receipt, birth);
|
|
357
|
+
await provider.start({ id: spec.name, token }); await runtime.connect();
|
|
358
|
+
if (args.length) {
|
|
359
|
+
const commandEnv = { ...deploy, ...env } as Record<string, string>;
|
|
360
|
+
delete commandEnv.SUBSTRATE_TOKEN; delete commandEnv.SUBSTRATE_ROOT;
|
|
361
|
+
g.launch = await startAdmitted(g, args, { cwd: p.cwd, env: commandEnv }, spec.signal);
|
|
362
|
+
void g.launch.result.then(r => { if (!g.stopping) update(g, { state: 'exited', exitCode: r.exitCode, ...(r.exitCode !== 0 ? { reason: r.stderr || `image command exited ${r.exitCode}` } : {}) }); }, error => { if (!g.stopping) update(g, { state: 'failed', reason: String(error) }); });
|
|
363
|
+
}
|
|
364
|
+
for (const check of spec.checks?.length ? spec.checks : [...ports].map(port => ({ port, type: 'tcp' as const }))) {
|
|
365
|
+
if (!ports.has(check.port)) throw new Error('health port is not declared');
|
|
366
|
+
{
|
|
367
|
+
// Substrate discovery polls owned command groups every 200 ms (published linux-lane.js).
|
|
368
|
+
while (!(await runtime.ports()).includes(check.port)) {
|
|
369
|
+
spec.signal?.throwIfAborted(); live(spec.name);
|
|
370
|
+
if (g.launch && g.launch.state !== 'running') throw new Error(g.status.reason ?? 'image command exited before listening');
|
|
371
|
+
await delay(200);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (check.type === 'http') {
|
|
375
|
+
const response = await runtime.request(check.port, check.path ?? '/', { signal: spec.signal });
|
|
376
|
+
await response.body?.cancel();
|
|
377
|
+
if (!response.ok) throw new Error(`HTTP health ${response.status}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
spec.signal?.throwIfAborted();
|
|
381
|
+
return { ports: [...ports].map(internal => ({ internal, host: agentPort, url: runtime.portUrl(internal) })) };
|
|
382
|
+
} catch (error) {
|
|
383
|
+
states.set(spec.name, { state: 'failed', reason: String((error as Error).message ?? error) });
|
|
384
|
+
const g = guests.get(spec.name); if (g) { update(g, states.get(spec.name)!); await provider.stop({ name: spec.name, url: '' }); }
|
|
385
|
+
throw error;
|
|
386
|
+
} finally { starting = false; clearTimeout(deadline); }
|
|
387
|
+
},
|
|
388
|
+
async exec(name, spec) {
|
|
389
|
+
const g = live(name);
|
|
390
|
+
const queue: Array<{ stdout?: string } | { stderr?: string } | { exit: number }> = []; let wake: (() => void) | undefined, done = false;
|
|
391
|
+
const push = (item: (typeof queue)[number]): void => { queue.push(item); wake?.(); };
|
|
392
|
+
const env = guestEnvironment(spec.env ?? {});
|
|
393
|
+
for (const [key, value] of Object.entries(g.env)) if (/^(VOLTER_|TWIN_|NODE_OPTIONS$|NODE_EXTRA_CA_CERTS$|.*_TWIN_URL$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$)/.test(key)) env[key] = value;
|
|
394
|
+
const handle = await startAdmitted(g, spec.args, { cwd: spec.cwd ?? g.cwd, env, onStdout: stdout => push({ stdout }), onStderr: stderr => push({ stderr }) });
|
|
395
|
+
void handle.result.then(result => { push({ exit: result.exitCode }); done = true; wake?.(); }, error => { push({ stderr: String(error) }); push({ exit: 1 }); done = true; wake?.(); });
|
|
396
|
+
const processes = await g.runtime.processes();
|
|
397
|
+
const pid = processes.filter(p => p.command === handle.command).at(-1)?.pid ?? handle.pid;
|
|
398
|
+
const events: MachineProcess['events'] = { async *[Symbol.asyncIterator]() { for (;;) { while (queue.length) yield queue.shift()!; if (done) return; await new Promise<void>(r => { wake = r; }); } } };
|
|
399
|
+
return { pid, events };
|
|
400
|
+
},
|
|
401
|
+
async request(name, port, request) {
|
|
402
|
+
const g = live(name);
|
|
403
|
+
if (!g.ports.has(port)) throw new Error('guest port is not declared');
|
|
404
|
+
const url = new URL(request.url), headers = new Headers(request.headers);
|
|
405
|
+
for (const key of ['host', 'connection', 'content-length', 'transfer-encoding']) headers.delete(key);
|
|
406
|
+
const init = { method: request.method, headers, body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), signal: request.signal, redirect: 'manual' as const };
|
|
407
|
+
return g.runtime.request(port, url.pathname + url.search, init);
|
|
408
|
+
},
|
|
409
|
+
pause: name => provider.pause({ name, url: '' }) as Promise<void>,
|
|
410
|
+
resume: async name => { await provider.resume({ name, url: '' }); },
|
|
411
|
+
stop,
|
|
412
|
+
async remove(name) { await stop(name); if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error('invalid guest name'); states.delete(name); rmSync(ownedPath(data, `guests/${options.world}/${name}`), { recursive: true, force: true }); },
|
|
413
|
+
};
|
|
414
|
+
}
|