@volter/world-runtime 3.0.38 → 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.
Files changed (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
@@ -0,0 +1,567 @@
1
+ // Substrate-backed light guests; architecture, "Other wires: … machines". Imports start nothing.
2
+ import { spawn } 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 } from '@volter/browser-sandboxes';
7
+ import { allocatePort } from "./tcp-port.js";
8
+ import { ownedPath, assertOwnedRealPath } from "./owned-path.js";
9
+ import { commandLifetime, commandProcessGroup } from "./command-lifetime.js";
10
+ import { beginGuestProcess, retainGuestProcess, readGuestProcesses, releaseGuestProcesses } from './guest-processes.mjs';
11
+ import { retireOwnedProcessGroups } from "./runtime.js";
12
+ import { guestControl } from './guest-control.mjs';
13
+ import { admitGuestArgv, guestCommandGap, guestEnvironment } from './guest-command.mjs';
14
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
15
+ // Re-enrolling image metadata must not lose ownership of guests already running in this World.
16
+ const pools = new Map();
17
+ const lifecycle = new Map();
18
+ /** Word splitting for the supported Dockerfile instructions; no shell is executed while parsing. */
19
+ function words(text) {
20
+ const out = [];
21
+ let word = '', quote = '', active = false;
22
+ for (let i = 0; i < text.length; i++) {
23
+ const ch = text[i];
24
+ if (ch === '\\' && quote !== "'") {
25
+ word += text[++i] ?? '';
26
+ active = true;
27
+ }
28
+ else if (quote) {
29
+ if (ch === quote)
30
+ quote = '';
31
+ else
32
+ word += ch;
33
+ }
34
+ else if (ch === '"' || ch === "'") {
35
+ quote = ch;
36
+ active = true;
37
+ }
38
+ else if (/\s/.test(ch)) {
39
+ if (active) {
40
+ out.push(word);
41
+ word = '';
42
+ active = false;
43
+ }
44
+ }
45
+ else {
46
+ word += ch;
47
+ active = true;
48
+ }
49
+ }
50
+ if (quote)
51
+ throw new Error('unsupported unterminated Dockerfile quote');
52
+ if (active)
53
+ out.push(word);
54
+ return out;
55
+ }
56
+ function command(text) {
57
+ if (!text.trim().startsWith('['))
58
+ throw guestCommandGap('guest shell-form CMD/ENTRYPOINT is unsupported; use Node argv with an image entry file');
59
+ const value = JSON.parse(text);
60
+ if (!Array.isArray(value) || !value.every(v => typeof v === 'string'))
61
+ throw new Error('invalid Dockerfile exec form');
62
+ return value;
63
+ }
64
+ function plan(image) {
65
+ const out = { runtime: image.runtime ?? '', cwd: '/', env: {}, command: [], ports: [], copies: [] };
66
+ let entry = [], cmd = [], stages = 0;
67
+ const path = resolve(image.context, image.dockerfile ?? 'Dockerfile');
68
+ if (image.dockerfile || existsSync(path)) {
69
+ for (const line of readFileSync(path, 'utf8').replace(/\\\r?\n/g, ' ').split(/\r?\n/)) {
70
+ if (!line.trim() || line.trimStart().startsWith('#'))
71
+ continue;
72
+ const hit = /^\s*(\w+)\s+(.+)$/.exec(line);
73
+ if (!hit)
74
+ throw new Error(`unsupported Dockerfile line: ${line}`);
75
+ const op = hit[1].toUpperCase(), body = hit[2];
76
+ const expand = (s) => s.replace(/\$\{(\w+)\}|\$(\w+)/g, (_, a, b) => out.env[a ?? b] ?? '');
77
+ switch (op) {
78
+ case 'FROM': {
79
+ if (++stages > 1)
80
+ throw new Error('light guests cannot build multi-stage images; enroll a prepared context and command');
81
+ const base = words(body)[0] ?? '';
82
+ const inferred = /(?:^|\/)(node|bun|python|deno)(?=[:@/]|$)/.exec(base)?.[1];
83
+ out.runtime ||= inferred ?? '';
84
+ if (!out.runtime)
85
+ throw guestCommandGap(`unsupported guest runtime in base image ${base}`);
86
+ break;
87
+ }
88
+ case 'WORKDIR':
89
+ out.cwd = posix.resolve(out.cwd, expand(body));
90
+ break;
91
+ case 'ENV': {
92
+ const values = words(body);
93
+ if (!values[0]?.includes('='))
94
+ out.env[values.shift() ?? ''] = expand(values.join(' '));
95
+ else
96
+ for (const value of values) {
97
+ const n = value.indexOf('=');
98
+ if (n < 1)
99
+ throw new Error('invalid ENV');
100
+ out.env[value.slice(0, n)] = expand(value.slice(n + 1));
101
+ }
102
+ break;
103
+ }
104
+ case 'EXPOSE':
105
+ for (const value of words(body)) {
106
+ if (value.endsWith('/udp'))
107
+ throw new Error('light guests support TCP only');
108
+ out.ports.push(Number(value.split('/')[0]));
109
+ }
110
+ break;
111
+ case 'CMD':
112
+ cmd = command(body);
113
+ break;
114
+ case 'ENTRYPOINT':
115
+ entry = command(body);
116
+ break;
117
+ case 'COPY': {
118
+ const values = body.trim().startsWith('[') ? command(body) : words(body);
119
+ if (values.some(v => v.startsWith('--') || /[*?]/.test(v)))
120
+ throw new Error('unsupported COPY flag, stage or glob');
121
+ const destination = expand(values.pop() ?? '');
122
+ if (!values.length || !destination)
123
+ throw new Error('invalid COPY');
124
+ out.copies.push({ sources: values.map(expand), destination, cwd: out.cwd });
125
+ break;
126
+ }
127
+ case 'RUN':
128
+ 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()))
129
+ throw new Error(`unsupported OS/build step: RUN ${body}`);
130
+ if (!image.prepared)
131
+ throw new Error(`RUN needs a prepared context: ${body}`);
132
+ break;
133
+ case 'LABEL': break;
134
+ default: throw new Error(`unsupported Dockerfile instruction ${op}`);
135
+ }
136
+ }
137
+ }
138
+ out.runtime = image.runtime ?? out.runtime;
139
+ if (out.runtime !== 'node')
140
+ throw guestCommandGap(`unsupported guest runtime ${out.runtime || '(unknown)'}`);
141
+ out.command = image.command ?? [...entry, ...cmd];
142
+ out.cwd = image.cwd ?? out.cwd;
143
+ out.env = { ...out.env, ...image.env };
144
+ return out;
145
+ }
146
+ function guestPath(dir, path, cwd = '/') {
147
+ // Reject traversal rather than let a normalization hide it. This is path hygiene, not isolation.
148
+ if (path.split('/').includes('..'))
149
+ throw new Error('guest path traversal is unsupported');
150
+ const target = join(dir, posix.resolve(cwd, path));
151
+ let existing = target;
152
+ while (!existsSync(existing) && existing !== dir)
153
+ existing = dirname(existing);
154
+ assertOwnedRealPath(realpathSync(dir), existing);
155
+ return target;
156
+ }
157
+ function copy(source, destination) {
158
+ mkdirSync(dirname(destination), { recursive: true });
159
+ cpSync(source, destination, { recursive: true, dereference: true, filter: path => !['.git', '.volter'].includes(basename(path)) });
160
+ }
161
+ function prepare(dir, image, p) {
162
+ mkdirSync(dir, { recursive: true });
163
+ const context = realpathSync(image.context);
164
+ if (p.copies.length)
165
+ for (const item of p.copies) {
166
+ mkdirSync(guestPath(dir, item.cwd), { recursive: true });
167
+ const destination = guestPath(dir, item.destination, item.cwd);
168
+ const directory = item.sources.length > 1 || item.destination.endsWith('/') || (existsSync(destination) && statSync(destination).isDirectory());
169
+ if (directory)
170
+ mkdirSync(destination, { recursive: true });
171
+ for (const source of item.sources) {
172
+ const from = realpathSync(resolve(context, source));
173
+ assertOwnedRealPath(context, from);
174
+ copy(from, directory && !statSync(from).isDirectory() ? join(destination, basename(source)) : destination);
175
+ }
176
+ }
177
+ else {
178
+ const target = guestPath(dir, p.cwd);
179
+ mkdirSync(target, { recursive: true });
180
+ for (const entry of readdirSync(context)) {
181
+ if (['.git', '.volter', 'node_modules'].includes(entry))
182
+ continue;
183
+ copy(join(context, entry), join(target, entry));
184
+ }
185
+ if (existsSync(join(context, 'node_modules')) && !existsSync(join(target, 'node_modules')))
186
+ symlinkSync(join(context, 'node_modules'), join(target, 'node_modules'));
187
+ }
188
+ mkdirSync(guestPath(dir, p.cwd), { recursive: true });
189
+ }
190
+ function worldEnv() {
191
+ const instancePath = process.env.VOLTER_WORLD_INSTANCE;
192
+ if (!instancePath)
193
+ throw new Error('guest execution requires a World instance');
194
+ const instance = JSON.parse(readFileSync(instancePath, 'utf8'));
195
+ // proxy-env has the completed vendor map; the early twin process env may not.
196
+ const current = JSON.parse(readFileSync(join(dirname(instancePath), 'proxy-env.json'), 'utf8'));
197
+ const env = { ...instance.env, ...current };
198
+ if (!env.VOLTER_WORLD || !env.NODE_OPTIONS)
199
+ throw new Error('World guest injection environment is incomplete');
200
+ return env;
201
+ }
202
+ function injected(base, deploy, dir) {
203
+ const env = { PATH: process.env.PATH, ...guestEnvironment(deploy) };
204
+ // Admission already happened at World boot; never inherit the twin process's ambient secrets.
205
+ for (const [key, value] of Object.entries(base)) {
206
+ 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))
207
+ env[key] = value;
208
+ }
209
+ for (const [key, value] of Object.entries(env)) {
210
+ if (value?.startsWith('/') && !/^(PATH|NODE_|VOLTER_|TWIN_|SSL_|REQUESTS_)/.test(key))
211
+ env[key] = join(dir, value);
212
+ }
213
+ if (!base.VOLTER_WORLD_GUEST_PROXY)
214
+ throw new Error('World guest proxy is missing');
215
+ for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'VOLTER_WORLD_PROXY'])
216
+ env[key] = base.VOLTER_WORLD_GUEST_PROXY;
217
+ env.NO_PROXY = env.no_proxy = '';
218
+ env.HOME = join(dir, 'home/user');
219
+ mkdirSync(env.HOME, { recursive: true });
220
+ return env;
221
+ }
222
+ // Resolve only the runtime's operator PATH, once; deployment/exec PATH is never consulted.
223
+ let nodeExecutable;
224
+ function trustedNodeExecutable() {
225
+ if (nodeExecutable)
226
+ return nodeExecutable;
227
+ if (!process.versions.bun)
228
+ return nodeExecutable = realpathSync(process.execPath);
229
+ for (const folder of (process.env.PATH ?? '').split(delimiter)) {
230
+ if (!folder.startsWith('/'))
231
+ continue;
232
+ const candidate = resolve(folder, process.platform === 'win32' ? 'node.exe' : 'node');
233
+ try {
234
+ accessSync(candidate, constants.X_OK);
235
+ if (statSync(candidate).isFile())
236
+ return nodeExecutable = realpathSync(candidate);
237
+ }
238
+ catch { /* next operator PATH entry */ }
239
+ }
240
+ throw new Error('trusted Node executable unavailable in runtime operator PATH');
241
+ }
242
+ let messageId = 0;
243
+ function ask(child, action, path, signal) {
244
+ return guestControl(control => new Promise((yes, no) => {
245
+ if (!child.connected) {
246
+ no(new Error('Substrate guest control disconnected'));
247
+ return;
248
+ }
249
+ const id = ++messageId;
250
+ const cleanup = () => { child.off('message', reply); child.off('exit', ended); child.off('disconnect', ended); child.off('error', failed); control.removeEventListener('abort', aborted); };
251
+ const failed = (error) => { cleanup(); no(error); };
252
+ const aborted = () => failed(control.reason);
253
+ const ended = () => failed(new Error('Substrate guest exited during control request'));
254
+ const reply = (message) => { if (message.id !== id)
255
+ return; cleanup(); message.error ? no(new Error(message.error)) : yes(message.value); };
256
+ child.on('message', reply);
257
+ child.once('exit', ended);
258
+ child.once('disconnect', ended);
259
+ child.once('error', failed);
260
+ control.addEventListener('abort', aborted, { once: true });
261
+ child.send({ id, action, path }, error => { if (error)
262
+ failed(error); });
263
+ }), `guest ${action}`, signal);
264
+ }
265
+ async function startAdmitted(g, args, options, signal) {
266
+ const cwd = options?.cwd ?? g.cwd;
267
+ const mapped = [...args];
268
+ if (mapped[1]?.startsWith('/'))
269
+ mapped[1] = guestPath(g.dir, mapped[1]);
270
+ const admitted = admitGuestArgv(mapped, g.imageRuntime, g.dir, guestPath(g.dir, cwd));
271
+ const key = await ask(g.child, 'prepare', JSON.stringify(admitted.argv), signal);
272
+ const handle = await guestControl(() => g.runtime.start(`__world_node_argv ${key}`, options), 'guest start transport', signal);
273
+ void handle.result.catch(() => { }); // observe a failure even if admission refuses first
274
+ try {
275
+ await ask(g.child, 'admission', key, signal);
276
+ } // valid held birth has been durably published
277
+ catch (error) {
278
+ handle.kill();
279
+ throw error;
280
+ }
281
+ return handle;
282
+ }
283
+ /** Local provider for Substrate's sandbox contract; all guest operations are vendor-neutral. */
284
+ export function createLightGuestPool(options) {
285
+ const instancePath = process.env.VOLTER_WORLD_INSTANCE;
286
+ if (!instancePath)
287
+ throw new Error('guest pool requires a World instance');
288
+ const instanceDir = dirname(instancePath), instance = JSON.parse(readFileSync(instancePath, 'utf8'));
289
+ const receiptsDir = ownedPath(instanceDir, 'guest-processes');
290
+ const receiptGuest = (name) => `${options.world}/${name}`;
291
+ const receipts = (name) => readGuestProcesses(instanceDir, instance.instance).filter(r => r.record.guest === receiptGuest(name));
292
+ const data = options.root ?? join(dirname(process.env.VOLTER_WORLD_INSTANCE ?? ''), 'data');
293
+ const root = ownedPath(data, `guests/${options.world}`);
294
+ const guests = pools.get(root) ?? new Map();
295
+ const states = lifecycle.get(root) ?? new Map();
296
+ pools.set(root, guests);
297
+ lifecycle.set(root, states);
298
+ const update = (g, status) => { g.status = status; states.set(g.name, status); };
299
+ const live = (name) => {
300
+ const g = guests.get(name);
301
+ if (!g || g.status.state !== 'started' || g.child.exitCode !== null || g.child.signalCode !== null)
302
+ throw new Error(g?.status.reason ?? `Substrate guest is ${g?.status.state ?? 'missing'}`);
303
+ return g;
304
+ };
305
+ const stop = async (name) => {
306
+ const g = guests.get(name);
307
+ if (!g)
308
+ return;
309
+ const terminal = g.status.state === 'exited' || g.status.state === 'failed' ? g.status : undefined;
310
+ g.stopping = true;
311
+ g.runtime.close();
312
+ // cancel resumes the agent's private group. Its retained Substrate handles retire commands;
313
+ // durable command groups use the same World grace/KILL/confirmation, even after agent loss.
314
+ g.lifetime.cancel('SIGCONT');
315
+ g.lifetime.cancel('SIGTERM');
316
+ try {
317
+ const outcomes = await Promise.allSettled([
318
+ g.lifetime.finish(),
319
+ retireOwnedProcessGroups(receipts(name).filter(r => r.record.kind !== 'agent').map(r => r.record)),
320
+ ]);
321
+ for (const outcome of outcomes)
322
+ if (outcome.status === 'rejected')
323
+ throw outcome.reason;
324
+ const remaining = receipts(name);
325
+ await retireOwnedProcessGroups(remaining.map(r => r.record));
326
+ releaseGuestProcesses(remaining);
327
+ }
328
+ catch (error) {
329
+ update(g, { state: 'failed', reason: `guest retirement unconfirmed; ownership retained: ${error}` });
330
+ throw error;
331
+ }
332
+ update(g, terminal ?? { state: 'stopped' });
333
+ guests.delete(name);
334
+ if (g.resetOnStop)
335
+ rmSync(ownedPath(data, `guests/${options.world}/${name}`), { recursive: true, force: true });
336
+ };
337
+ const provider = {
338
+ pauseMode: 'freeze-processes',
339
+ async start({ id }) { const g = guests.get(id); if (!g)
340
+ throw new Error('Substrate guest launch was not prepared'); return { name: id, url: `http://127.0.0.1:${g.agentPort}` }; },
341
+ async pause({ name }) { const g = live(name); await ask(g.child, 'pause'); update(g, { state: 'paused' }); },
342
+ async resume({ name }) { const g = guests.get(name); if (!g || g.status.state !== 'paused')
343
+ throw new Error('Substrate guest cannot resume'); await ask(g.child, 'resume'); update(g, { state: 'started' }); },
344
+ async stop({ name }) { await stop(name); },
345
+ running() { return new Set([...guests].filter(([, g]) => g.status.state === 'started').map(([name]) => name)); },
346
+ };
347
+ return {
348
+ kind: 'light',
349
+ async admitRun(spec) {
350
+ if (!/^[a-zA-Z0-9_-]+$/.test(spec.name))
351
+ throw new Error('invalid guest name');
352
+ const image = options.images?.[spec.image];
353
+ if (!image)
354
+ throw new Error(`image ${spec.image} has no light guest context`);
355
+ const p = plan(image), dir = ownedPath(data, `guests/${options.world}/${spec.name}`);
356
+ prepare(dir, image, p);
357
+ const args = [...(spec.command ?? p.command)];
358
+ if (args[1]?.startsWith('/'))
359
+ args[1] = guestPath(dir, args[1]);
360
+ if (args.length)
361
+ admitGuestArgv(args, p.runtime, dir, guestPath(dir, p.cwd));
362
+ },
363
+ async status(name) {
364
+ const g = guests.get(name);
365
+ if (g?.status.state === 'started' && g.launch && g.launch.state !== 'running')
366
+ await g.launch.result.catch(() => { });
367
+ return states.get(name) ?? { state: 'failed', reason: 'Substrate guest is missing' };
368
+ },
369
+ async ports(name) { return live(name).runtime.ports(); },
370
+ async files(name) {
371
+ const g = live(name);
372
+ return {
373
+ read: path => { live(name); return g.runtime.files.read(path); },
374
+ write: (path, bytes) => { live(name); return g.runtime.files.write(path, bytes); },
375
+ list: path => { live(name); return g.runtime.files.list(path); },
376
+ mkdir: path => { live(name); return g.runtime.files.mkdir(path); },
377
+ remove: path => { live(name); return g.runtime.files.remove(path); },
378
+ stat: path => { live(name); return ask(g.child, 'stat', path); },
379
+ };
380
+ },
381
+ async run(spec) {
382
+ if (!/^[a-zA-Z0-9_-]+$/.test(spec.name))
383
+ throw new Error('invalid guest name');
384
+ if (guests.has(spec.name))
385
+ throw new Error('guest already exists; stop before starting again');
386
+ let starting = true;
387
+ if (!Number.isSafeInteger(spec.startTimeoutMs) || spec.startTimeoutMs <= 0)
388
+ throw new Error('light guest requires a vendor startup observation budget');
389
+ const startup = new AbortController();
390
+ const timeoutReason = new Error(`guest startup exceeded ${spec.startTimeoutMs} ms before declared readiness`);
391
+ const deadline = setTimeout(() => startup.abort(timeoutReason), spec.startTimeoutMs);
392
+ spec = { ...spec, signal: spec.signal ? AbortSignal.any([spec.signal, startup.signal]) : startup.signal };
393
+ try {
394
+ spec.signal?.throwIfAborted();
395
+ const image = options.images?.[spec.image];
396
+ if (!image)
397
+ throw new Error(`image ${spec.image} has no light guest context`);
398
+ const p = plan(image), dir = ownedPath(data, `guests/${options.world}/${spec.name}`);
399
+ const base = worldEnv(), deploy = { ...p.env, ...spec.env }, env = injected(base, deploy, dir);
400
+ const args = spec.command ?? p.command;
401
+ prepare(dir, image, p);
402
+ if (args.length) {
403
+ const mapped = [...args];
404
+ if (mapped[1]?.startsWith('/'))
405
+ mapped[1] = guestPath(dir, mapped[1]);
406
+ admitGuestArgv(mapped, p.runtime, dir, guestPath(dir, p.cwd));
407
+ }
408
+ for (const mount of spec.mounts ?? []) {
409
+ if (!/^[a-zA-Z0-9_-]+$/.test(mount.name) || mount.path === '/')
410
+ throw new Error('unsupported guest mount');
411
+ const volume = ownedPath(data, `guests/${options.world}-volumes/${mount.name}`);
412
+ const target = guestPath(dir, mount.path);
413
+ mkdirSync(volume, { recursive: true });
414
+ rmSync(target, { recursive: true, force: true });
415
+ mkdirSync(dirname(target), { recursive: true });
416
+ symlinkSync(volume, target);
417
+ }
418
+ if (!existsSync(join(dir, 'workspace')))
419
+ mkdirSync(join(dir, 'workspace'), { recursive: true });
420
+ const ports = new Set([...(spec.ports ?? []), ...p.ports]);
421
+ for (const internal of ports)
422
+ if (!Number.isInteger(internal) || internal < 1 || internal > 65535)
423
+ throw new Error('invalid guest port');
424
+ if (ports.size && !args.length)
425
+ throw new Error('image has a listening port but no start command');
426
+ const agentPort = await allocatePort(new Set(ports)), token = deploy.SUBSTRATE_TOKEN ?? randomBytes(24).toString('hex');
427
+ // Substrate's macOS socket discovery uses lsof, installed in /usr/sbin. This agent's
428
+ // infrastructure PATH is separate from the declared environment passed to the product.
429
+ const agentPath = process.platform === 'darwin' ? `${process.env.PATH ?? ''}:/usr/sbin` : process.env.PATH;
430
+ const receipt = beginGuestProcess(receiptsDir, instance.instance, receiptGuest(spec.name), 'agent');
431
+ // The agent's own control transport remains host-side; only product commands use guest policy.
432
+ 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)));
433
+ const control = Object.fromEntries(Object.entries(base).filter(([key]) => /^(VOLTER_WORLD_PROXY$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$)/.test(key)));
434
+ 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 });
435
+ const lifetime = commandLifetime(child);
436
+ const runtime = new RemoteRuntime(`http://127.0.0.1:${agentPort}`, token, {
437
+ fetch: ((input, init) => guestControl(async (control) => {
438
+ const signals = [control, init?.signal, ...(starting ? [spec.signal] : [])].filter(Boolean);
439
+ const response = await fetch(input, { ...init, signal: AbortSignal.any(signals) });
440
+ // JSON control replies must complete inside the budget; event/product streams retain their own lifetime.
441
+ if (response.headers.get('content-type')?.includes('application/json')) {
442
+ const bytes = await response.arrayBuffer();
443
+ return new Response(bytes, { status: response.status, statusText: response.statusText, headers: response.headers });
444
+ }
445
+ return response;
446
+ }, 'guest control transport', starting ? spec.signal : undefined)),
447
+ });
448
+ const g = { lifetime, name: spec.name, dir, cwd: p.cwd, env: env, child, runtime, ports, status: { state: 'started' }, imageRuntime: p.runtime, agentPort, resetOnStop: spec.resetOnStop };
449
+ guests.set(spec.name, g);
450
+ update(g, g.status);
451
+ if (!child.pid) {
452
+ await lifetime.finish();
453
+ releaseGuestProcesses([receipt]);
454
+ throw new Error('Substrate guest process did not spawn');
455
+ }
456
+ retainGuestProcess(receipt, { pid: child.pid });
457
+ let diagnostic = '';
458
+ child.stderr?.on('data', bytes => { diagnostic = (diagnostic + String(bytes)).slice(-8192); });
459
+ child.on('error', error => { if (!g.stopping)
460
+ update(g, { state: 'failed', reason: error.message }); });
461
+ child.once('exit', (code, signal) => { if (!g.stopping)
462
+ update(g, { state: 'failed', ...(code !== null ? { exitCode: code } : {}), reason: `Substrate agent exited (${code ?? signal}): ${diagnostic.trim()}` }); });
463
+ await new Promise((yes, no) => {
464
+ const cleanup = () => { child.off('message', ready); child.off('error', failed); child.off('exit', ended); spec.signal?.removeEventListener('abort', aborted); };
465
+ const failed = (error) => { cleanup(); no(error); };
466
+ const ended = () => failed(new Error(g.status.reason ?? 'Substrate agent exited during startup'));
467
+ const aborted = () => failed(spec.signal?.reason ?? new Error('Substrate guest startup cancelled'));
468
+ const ready = (message) => { if (message.ready !== true)
469
+ return; cleanup(); yes(); };
470
+ child.on('message', ready);
471
+ child.once('error', failed);
472
+ child.once('exit', ended);
473
+ spec.signal?.addEventListener('abort', aborted, { once: true });
474
+ if (spec.signal?.aborted)
475
+ aborted();
476
+ });
477
+ const birth = await lifetime.identity;
478
+ if (!birth?.start || child.exitCode !== null || child.signalCode !== null)
479
+ throw new Error('Substrate agent birth identity unavailable; ownership retained');
480
+ retainGuestProcess(receipt, birth);
481
+ await provider.start({ id: spec.name, token });
482
+ await runtime.connect();
483
+ if (args.length) {
484
+ const commandEnv = { ...deploy, ...env };
485
+ delete commandEnv.SUBSTRATE_TOKEN;
486
+ delete commandEnv.SUBSTRATE_ROOT;
487
+ g.launch = await startAdmitted(g, args, { cwd: p.cwd, env: commandEnv }, spec.signal);
488
+ void g.launch.result.then(r => { if (!g.stopping)
489
+ update(g, { state: 'exited', exitCode: r.exitCode, ...(r.exitCode !== 0 ? { reason: r.stderr || `image command exited ${r.exitCode}` } : {}) }); }, error => { if (!g.stopping)
490
+ update(g, { state: 'failed', reason: String(error) }); });
491
+ }
492
+ for (const check of spec.checks?.length ? spec.checks : [...ports].map(port => ({ port, type: 'tcp' }))) {
493
+ if (!ports.has(check.port))
494
+ throw new Error('health port is not declared');
495
+ {
496
+ // Substrate discovery polls owned command groups every 200 ms (published linux-lane.js).
497
+ while (!(await runtime.ports()).includes(check.port)) {
498
+ spec.signal?.throwIfAborted();
499
+ live(spec.name);
500
+ if (g.launch && g.launch.state !== 'running')
501
+ throw new Error(g.status.reason ?? 'image command exited before listening');
502
+ await delay(200);
503
+ }
504
+ }
505
+ if (check.type === 'http') {
506
+ const response = await runtime.request(check.port, check.path ?? '/', { signal: spec.signal });
507
+ await response.body?.cancel();
508
+ if (!response.ok)
509
+ throw new Error(`HTTP health ${response.status}`);
510
+ }
511
+ }
512
+ spec.signal?.throwIfAborted();
513
+ return { ports: [...ports].map(internal => ({ internal, host: agentPort, url: runtime.portUrl(internal) })) };
514
+ }
515
+ catch (error) {
516
+ states.set(spec.name, { state: 'failed', reason: String(error.message ?? error) });
517
+ const g = guests.get(spec.name);
518
+ if (g) {
519
+ update(g, states.get(spec.name));
520
+ await provider.stop({ name: spec.name, url: '' });
521
+ }
522
+ throw error;
523
+ }
524
+ finally {
525
+ starting = false;
526
+ clearTimeout(deadline);
527
+ }
528
+ },
529
+ async exec(name, spec) {
530
+ const g = live(name);
531
+ const queue = [];
532
+ let wake, done = false;
533
+ const push = (item) => { queue.push(item); wake?.(); };
534
+ const env = guestEnvironment(spec.env ?? {});
535
+ for (const [key, value] of Object.entries(g.env))
536
+ if (/^(VOLTER_|TWIN_|NODE_OPTIONS$|NODE_EXTRA_CA_CERTS$|.*_TWIN_URL$|HTTPS?_PROXY$|https?_proxy$|NO_PROXY$|no_proxy$)/.test(key))
537
+ env[key] = value;
538
+ const handle = await startAdmitted(g, spec.args, { cwd: spec.cwd ?? g.cwd, env, onStdout: stdout => push({ stdout }), onStderr: stderr => push({ stderr }) });
539
+ void handle.result.then(result => { push({ exit: result.exitCode }); done = true; wake?.(); }, error => { push({ stderr: String(error) }); push({ exit: 1 }); done = true; wake?.(); });
540
+ const processes = await g.runtime.processes();
541
+ const pid = processes.filter(p => p.command === handle.command).at(-1)?.pid ?? handle.pid;
542
+ const events = { async *[Symbol.asyncIterator]() { for (;;) {
543
+ while (queue.length)
544
+ yield queue.shift();
545
+ if (done)
546
+ return;
547
+ await new Promise(r => { wake = r; });
548
+ } } };
549
+ return { pid, events };
550
+ },
551
+ async request(name, port, request) {
552
+ const g = live(name);
553
+ if (!g.ports.has(port))
554
+ throw new Error('guest port is not declared');
555
+ const url = new URL(request.url), headers = new Headers(request.headers);
556
+ for (const key of ['host', 'connection', 'content-length', 'transfer-encoding'])
557
+ headers.delete(key);
558
+ const init = { method: request.method, headers, body: ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(), signal: request.signal, redirect: 'manual' };
559
+ return g.runtime.request(port, url.pathname + url.search, init);
560
+ },
561
+ pause: name => provider.pause({ name, url: '' }),
562
+ resume: async (name) => { await provider.resume({ name, url: '' }); },
563
+ stop,
564
+ async remove(name) { await stop(name); if (!/^[a-zA-Z0-9_-]+$/.test(name))
565
+ throw new Error('invalid guest name'); states.delete(name); rmSync(ownedPath(data, `guests/${options.world}/${name}`), { recursive: true, force: true }); },
566
+ };
567
+ }
package/dist/src/host.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { getPack, registerPack } from '@volter/world-core/runtime';
2
3
  // Co-located twin host — run many twins in ONE process instead of one OS process
3
4
  // per twin. The twin core is a pure, transport-agnostic handler (see a pack's
@@ -61,8 +62,8 @@ export async function startColocatedHost(specs, options = {}) {
61
62
  // the shared process. (A thrown *request* handler is already contained by Bun.serve → 500.)
62
63
  // We log and attribute rather than exit — state stays consistent because every kernel write
63
64
  // is atomic (temp→rename) and root-keyed, so a swallowed error can't corrupt a sibling.
64
- const onRejection = (reason) => onEvent({ twin: '(host)', kind: 'unhandledRejection', detail: String(reason) });
65
- const onUncaught = (err) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail: String(err) });
65
+ const onRejection = (reason) => onEvent({ twin: '(host)', kind: 'unhandledRejection', detail: errorMessage(reason) });
66
+ const onUncaught = (err) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail: errorMessage(err) });
66
67
  process.on('unhandledRejection', onRejection);
67
68
  process.on('uncaughtException', onUncaught);
68
69
  const servers = [];
@@ -112,7 +113,7 @@ export async function startColocatedHost(specs, options = {}) {
112
113
  // Every stop runs, even when a sibling hangs or throws. Host shutdown
113
114
  // still resolves within its budget so the owning runtime can retire it.
114
115
  for (const error of await stop())
115
- onEvent({ twin: '(host)', kind: 'stop-failed', detail: String(error) });
116
+ onEvent({ twin: '(host)', kind: 'stop-failed', detail: errorMessage(error) });
116
117
  },
117
118
  };
118
119
  }
@@ -139,7 +140,7 @@ export async function startColocatedHost(specs, options = {}) {
139
140
  }
140
141
  });
141
142
  worker.on('error', (err) => {
142
- onEvent({ twin: spec.id, kind: 'error', detail: String(err) });
143
+ onEvent({ twin: spec.id, kind: 'error', detail: errorMessage(err) });
143
144
  if (!ready)
144
145
  rejectReady(err);
145
146
  });
@@ -53,4 +53,5 @@ export type { ServedWorld } from './served-world.js';
53
53
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.js';
54
54
  export { assertWorldStateRemovable } from './ancestry.js';
55
55
  export { withoutVaultSignIn } from './runtime.js';
56
+ export { errorMessage } from './error-message.js';
56
57
  export { materializeBrowserFiles, browsersDir, worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, inheritWorldBrowsers, type WorldBrowser } from './browsers.js';
package/dist/src/index.js CHANGED
@@ -37,4 +37,5 @@ export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOf
37
37
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
38
38
  export { assertWorldStateRemovable } from "./ancestry.js";
39
39
  export { withoutVaultSignIn } from "./runtime.js";
40
+ export { errorMessage } from "./error-message.js";
40
41
  export { materializeBrowserFiles, browsersDir, worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, inheritWorldBrowsers } from "./browsers.js";
@@ -2,8 +2,12 @@
2
2
  // `volter-world-infra up|status|down`: the World-managed infrastructure's declared lifecycle commands (infra.ts), as
3
3
  // a process. World configs invoke this declared service; application agents never operate or diagnose its backing.
4
4
  import { runInfrastructure } from "./infra.js";
5
+ import { errorMessage } from "./error-message.js";
5
6
  const code = await runInfrastructure(process.argv[2] ?? '', process.env, {
6
7
  out: (text) => { process.stdout.write(text); },
7
8
  err: (text) => { process.stderr.write(text); },
9
+ }).catch(error => {
10
+ process.stderr.write(`managed infrastructure ${process.argv[2] ?? ''} failed: ${errorMessage(error)}\n`);
11
+ return 1;
8
12
  });
9
13
  process.exit(code);