@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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { errorMessage } from "./error-message.js";
|
|
1
2
|
import { serveHttp } from '@volter/world-core/runtime';
|
|
2
|
-
import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress } from '@volter/world-core/network-policy';
|
|
3
|
+
import { worldNetworkPolicyFromEnv, allowsWorldNetworkEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
|
|
3
4
|
import { websocketRelay } from "./websocket-relay.js";
|
|
4
5
|
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
5
6
|
// redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
|
|
@@ -26,6 +27,9 @@ import { lookup as dnsLookup } from 'node:dns/promises';
|
|
|
26
27
|
import { BlockList, isIP, connect as netConnect, createServer as createNetServer } from 'node:net';
|
|
27
28
|
import { networkInterfaces } from 'node:os';
|
|
28
29
|
import { connect as tlsConnect } from 'node:tls';
|
|
30
|
+
import { request as httpsRequest } from 'node:https';
|
|
31
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
32
|
+
import { Readable } from 'node:stream';
|
|
29
33
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
34
|
import { join } from 'node:path';
|
|
31
35
|
// The host→twin table is loaded through the shared inject-map module (one world-runtime home
|
|
@@ -33,6 +37,7 @@ import { join } from 'node:path';
|
|
|
33
37
|
// never re-encode the vendor host predicates — they stay one source of truth with the Node
|
|
34
38
|
// injector. External callers go through `proxyTargetFor`/`activeVendorMap`.
|
|
35
39
|
import { loadInject } from "./inject-map.js";
|
|
40
|
+
import { packFacts } from "./pack-facts.js";
|
|
36
41
|
import { sessionTrustEnv } from "./ca-trust.js";
|
|
37
42
|
/**
|
|
38
43
|
* The body served when a host was MITM'd but no twin serves the PATH.
|
|
@@ -168,7 +173,7 @@ export async function guestDestination(host) {
|
|
|
168
173
|
addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address);
|
|
169
174
|
}
|
|
170
175
|
catch (error) {
|
|
171
|
-
return { refused: `${host} does not resolve (${error
|
|
176
|
+
return { refused: `${host} does not resolve (${errorMessage(error)})` };
|
|
172
177
|
}
|
|
173
178
|
const bad = addresses.find(isGuestForbiddenAddress);
|
|
174
179
|
if (bad || !addresses.length)
|
|
@@ -277,7 +282,7 @@ export function leafCertFor(host, ca) {
|
|
|
277
282
|
* of which World to look at (`doctor`). Shared by every front that relays to twins. */
|
|
278
283
|
export function twinUnreachableMessage(vendor, origin, err, world) {
|
|
279
284
|
const cause = err.cause;
|
|
280
|
-
return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${err
|
|
285
|
+
return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${errorMessage(err)}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${errorMessage(cause)})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
|
|
281
286
|
}
|
|
282
287
|
/** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
|
|
283
288
|
* - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
|
|
@@ -308,6 +313,117 @@ export async function startRedirectProxy(options) {
|
|
|
308
313
|
// Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
|
|
309
314
|
// or real upstream would otherwise keep the server from closing).
|
|
310
315
|
const openSockets = new Set();
|
|
316
|
+
const publicBuildRequest = (target, env) => {
|
|
317
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
318
|
+
return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
|
|
319
|
+
};
|
|
320
|
+
// One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
|
|
321
|
+
// It never follows redirects and never lends the upstream socket to the caller.
|
|
322
|
+
const externalRequest = async (target, method, incoming, body, signal) => {
|
|
323
|
+
const env = currentEnv();
|
|
324
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
325
|
+
// the method is visible here: an egress grant reads, a write needs `writes`
|
|
326
|
+
if (!policy || !allowsWorldNetworkEgress(policy, target, method) || shouldBlockUntwinned(target, env))
|
|
327
|
+
throw new Error('World policy refuses the effective request');
|
|
328
|
+
const host = incoming.get('host');
|
|
329
|
+
if (host && new URL(`${target.protocol}//${host}`).host !== target.host)
|
|
330
|
+
throw new Error('request Host differs from authorized authority');
|
|
331
|
+
if (method === 'CONNECT' || incoming.has('upgrade'))
|
|
332
|
+
throw new Error('inspected egress cannot upgrade its transport');
|
|
333
|
+
if (!body && (Number(incoming.get('content-length') || 0) > 0 || incoming.has('transfer-encoding')))
|
|
334
|
+
throw new Error('transport cannot mediate this request body');
|
|
335
|
+
const headers = new Headers(incoming);
|
|
336
|
+
const hopHeaders = (headers.get('connection') ?? '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
|
|
337
|
+
for (const h of [...hopHeaders, 'host', 'proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'connection', 'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'x-twins-key', 'x-volter-twin-original-host'])
|
|
338
|
+
headers.delete(h);
|
|
339
|
+
return await new Promise((resolve, reject) => {
|
|
340
|
+
const upstream = httpsRequest({ hostname: target.hostname, port: Number(target.port || 443), servername: target.hostname,
|
|
341
|
+
path: target.pathname + target.search, method, headers: { ...Object.fromEntries(headers), host: target.host },
|
|
342
|
+
agent: false, ca: readFileSync(trust.SSL_CERT_FILE), signal, }, res => {
|
|
343
|
+
const out = new Headers();
|
|
344
|
+
const nominated = String(res.headers.connection ?? '').split(',').map(h => h.trim().toLowerCase());
|
|
345
|
+
for (const [key, value] of Object.entries(res.headers))
|
|
346
|
+
if (value !== undefined && ![...nominated, 'connection', 'transfer-encoding', 'keep-alive', 'trailer', 'upgrade'].includes(key)) {
|
|
347
|
+
for (const part of Array.isArray(value) ? value : [value])
|
|
348
|
+
out.append(key, String(part));
|
|
349
|
+
}
|
|
350
|
+
const empty = method === 'HEAD' || [204, 205, 304].includes(res.statusCode);
|
|
351
|
+
if (empty)
|
|
352
|
+
res.resume();
|
|
353
|
+
note(`inspected egress ${method} ${target.origin}${target.pathname} -> ${res.statusCode}`);
|
|
354
|
+
resolve(new Response(empty ? null : Readable.toWeb(res), { status: res.statusCode, headers: out }));
|
|
355
|
+
});
|
|
356
|
+
upstream.on('socket', socket => { openSockets.add(socket); socket.on('close', () => openSockets.delete(socket)); });
|
|
357
|
+
upstream.on('error', reject);
|
|
358
|
+
upstream.on('upgrade', (_res, socket) => { socket.destroy(); reject(new Error('upstream upgrade refused')); });
|
|
359
|
+
if (body) {
|
|
360
|
+
const source = Readable.fromWeb(body);
|
|
361
|
+
source.on('error', error => upstream.destroy(error));
|
|
362
|
+
upstream.on('error', () => source.destroy());
|
|
363
|
+
source.pipe(upstream);
|
|
364
|
+
}
|
|
365
|
+
else
|
|
366
|
+
upstream.end();
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
// Use the HTTP parser for framing; never forward trailing bytes as a second raw request.
|
|
370
|
+
const usedPlainSockets = new WeakSet();
|
|
371
|
+
const inspectedPlain = createHttpServer((req, res) => {
|
|
372
|
+
if (usedPlainSockets.has(req.socket)) {
|
|
373
|
+
note('inspected egress refused extra plain request');
|
|
374
|
+
req.socket.destroy();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
usedPlainSockets.add(req.socket);
|
|
378
|
+
res.shouldKeepAlive = false;
|
|
379
|
+
res.setHeader('connection', 'close');
|
|
380
|
+
res.on('finish', () => req.socket.end());
|
|
381
|
+
const abort = new AbortController();
|
|
382
|
+
req.on('aborted', () => abort.abort());
|
|
383
|
+
res.on('close', () => { if (!res.writableFinished)
|
|
384
|
+
abort.abort(); });
|
|
385
|
+
void (async () => {
|
|
386
|
+
if (/\\/.test(req.url.split(/[?#]/, 1)[0]))
|
|
387
|
+
throw new Error('backslash in proxy target');
|
|
388
|
+
const target = new URL(req.url);
|
|
389
|
+
if (proxyTargetFor(target.hostname, currentEnv()) && !publicBuildRequest(target, currentEnv()))
|
|
390
|
+
throw new Error('inspected external request names a twinned host');
|
|
391
|
+
const headers = new Headers();
|
|
392
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
|
393
|
+
headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
394
|
+
const body = headers.has('transfer-encoding') || Number(headers.get('content-length') || 0) > 0 ? Readable.toWeb(req) : null;
|
|
395
|
+
const response = await externalRequest(target, req.method, headers, body, abort.signal);
|
|
396
|
+
res.statusCode = response.status;
|
|
397
|
+
for (const [key, value] of response.headers)
|
|
398
|
+
res.setHeader(key, value);
|
|
399
|
+
if (response.body) {
|
|
400
|
+
const stream = Readable.fromWeb(response.body);
|
|
401
|
+
stream.on('error', () => res.destroy());
|
|
402
|
+
res.on('close', () => stream.destroy());
|
|
403
|
+
stream.pipe(res);
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
res.end();
|
|
407
|
+
})().catch(error => { if (!res.headersSent) {
|
|
408
|
+
res.statusCode = 502;
|
|
409
|
+
res.end(note(`${error.message}\n`));
|
|
410
|
+
}
|
|
411
|
+
else
|
|
412
|
+
res.destroy(); });
|
|
413
|
+
});
|
|
414
|
+
inspectedPlain.maxRequestsPerSocket = 1;
|
|
415
|
+
inspectedPlain.on('dropRequest', (req, socket) => { note('inspected egress refused extra plain request'); socket.destroy(); });
|
|
416
|
+
inspectedPlain.on('clientError', (_error, socket) => socket.destroy());
|
|
417
|
+
// Bun's Node HTTP adapter parses only on a listening server, not a synthetic connection event.
|
|
418
|
+
// Relays below carry bytes solely to this local parser; only externalRequest opens public sockets.
|
|
419
|
+
await new Promise((resolve, reject) => {
|
|
420
|
+
inspectedPlain.once('error', reject);
|
|
421
|
+
inspectedPlain.listen(0, '127.0.0.1', resolve);
|
|
422
|
+
});
|
|
423
|
+
const inspectedAddress = inspectedPlain.address();
|
|
424
|
+
if (!inspectedAddress || typeof inspectedAddress === 'string')
|
|
425
|
+
throw new Error('inspected HTTP parser has no TCP address');
|
|
426
|
+
const inspectedPort = inspectedAddress.port;
|
|
311
427
|
// The MITM TLS endpoint(s): a REAL listening TLS server PER vendor host (loopback, ephemeral port), each pinned
|
|
312
428
|
// to that host's leaf cert. On CONNECT to a vendor host we tunnel the client's raw bytes to the matching per-host
|
|
313
429
|
// server, which terminates TLS and forwards the decrypted request to the resolved twin. (One server per host
|
|
@@ -317,8 +433,9 @@ export async function startRedirectProxy(options) {
|
|
|
317
433
|
// websocket is relayed message by message to the twin's own websocket at the same path.
|
|
318
434
|
const tlsServers = new Map();
|
|
319
435
|
const tlsPorts = new Map();
|
|
320
|
-
const ensureHostServer = async (vendorHost) => {
|
|
321
|
-
const
|
|
436
|
+
const ensureHostServer = async (vendorHost, guest = false, originPort = 443) => {
|
|
437
|
+
const serverKey = `${vendorHost}:${originPort}:${guest}`;
|
|
438
|
+
const existing = tlsPorts.get(serverKey);
|
|
322
439
|
if (existing)
|
|
323
440
|
return existing;
|
|
324
441
|
const leaf = leafCertFor(vendorHost, ca);
|
|
@@ -326,6 +443,8 @@ export async function startRedirectProxy(options) {
|
|
|
326
443
|
hostname: '127.0.0.1',
|
|
327
444
|
port: 0,
|
|
328
445
|
tls: { key: leaf.key, cert: leaf.cert },
|
|
446
|
+
// Carry a twin's declared transport cap through TLS termination; public grants add no cap.
|
|
447
|
+
maxRequestBodySize: packFacts()[proxyTargetFor(vendorHost, currentEnv())?.vendor ?? '']?.maxRequestBodySize,
|
|
329
448
|
// A twin can think before its first byte (a git pack built from a cold cache took 19-28 s); Bun's 10 s idle
|
|
330
449
|
// default cut those connections and the client read an empty reply. Bun's maximum, as reflect.ts serves.
|
|
331
450
|
idleTimeout: 255,
|
|
@@ -336,13 +455,29 @@ export async function startRedirectProxy(options) {
|
|
|
336
455
|
// The request line is readable now, so resolve WITH the path: this is the point where a host two vendors
|
|
337
456
|
// share (www.googleapis.com → googleauth vs youtube) gets split correctly; on a shared host there IS a twin
|
|
338
457
|
// for the host and merely not for this path, and the bare message sends the next reader hunting.
|
|
339
|
-
const
|
|
458
|
+
const effective = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
459
|
+
const publicBuild = publicBuildRequest(effective, env);
|
|
460
|
+
const twin = publicBuild ? null : proxyTargetFor(hostHeader, env, u.pathname) ?? proxyTargetFor(vendorHost, env, u.pathname);
|
|
340
461
|
if (!twin) {
|
|
341
462
|
// a host the World refuses (sealed, or outside its network policy): answered here, over the World's TLS, so
|
|
342
463
|
// a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
|
|
343
464
|
// leaves the World
|
|
344
|
-
|
|
465
|
+
const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
466
|
+
if (shouldBlockUntwinned(target.href, env))
|
|
345
467
|
return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'));
|
|
468
|
+
if (!publicBuild && proxyTargetFor(vendorHost, env))
|
|
469
|
+
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
|
|
470
|
+
const policy = worldNetworkPolicyFromEnv(env);
|
|
471
|
+
if (policy && worldNetworkNeedsPathInspection(policy, target)) {
|
|
472
|
+
if (guest)
|
|
473
|
+
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), 'guest inspected egress cannot preserve its checked destination pin');
|
|
474
|
+
try {
|
|
475
|
+
return await externalRequest(target, req.method, req.headers, req.body, req.signal);
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
return new Response(note(`inspected egress refused: ${error.message}\n`), { status: 502 });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
346
481
|
const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
|
|
347
482
|
if (unreachable)
|
|
348
483
|
return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
|
|
@@ -395,8 +530,8 @@ export async function startRedirectProxy(options) {
|
|
|
395
530
|
}),
|
|
396
531
|
});
|
|
397
532
|
const p = hostServer.port ?? 0;
|
|
398
|
-
tlsServers.set(
|
|
399
|
-
tlsPorts.set(
|
|
533
|
+
tlsServers.set(serverKey, hostServer);
|
|
534
|
+
tlsPorts.set(serverKey, p);
|
|
400
535
|
return p;
|
|
401
536
|
};
|
|
402
537
|
// two listeners with one handler: the World's (host clients; a connection from elsewhere is still a guest's) and its
|
|
@@ -436,7 +571,7 @@ export async function startRedirectProxy(options) {
|
|
|
436
571
|
}
|
|
437
572
|
return;
|
|
438
573
|
}
|
|
439
|
-
handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest);
|
|
574
|
+
handlePlainProxyRequest(method, urlStr, version, headers, clientSocket, head, guest, buffered);
|
|
440
575
|
};
|
|
441
576
|
clientSocket.on('data', onData);
|
|
442
577
|
};
|
|
@@ -496,12 +631,13 @@ export async function startRedirectProxy(options) {
|
|
|
496
631
|
: netConnect(Number(origin.port || 80), connectHost, connected);
|
|
497
632
|
openSockets.add(upstream);
|
|
498
633
|
upstream.on('close', () => openSockets.delete(upstream));
|
|
499
|
-
upstream.on('error', (err) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${err
|
|
634
|
+
upstream.on('error', (err) => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', vendor ? twinUnreachable(vendor, origin.origin, err) : note(`upstream ${origin.host} unreachable: ${errorMessage(err)}\n`)));
|
|
500
635
|
};
|
|
501
|
-
const handlePlainProxyRequest = (method, urlStr, version, headers, socket, head, guest) => {
|
|
636
|
+
const handlePlainProxyRequest = (method, urlStr, version, headers, socket, head, guest, raw) => {
|
|
502
637
|
try {
|
|
503
638
|
const u = new URL(urlStr);
|
|
504
|
-
const
|
|
639
|
+
const publicBuild = publicBuildRequest(u, currentEnv());
|
|
640
|
+
const twin = publicBuild ? null : proxyTargetFor(u.hostname, currentEnv(), u.pathname);
|
|
505
641
|
const path = u.pathname + u.search;
|
|
506
642
|
if (twin) {
|
|
507
643
|
const target = new URL(twin.origin);
|
|
@@ -513,7 +649,7 @@ export async function startRedirectProxy(options) {
|
|
|
513
649
|
// No twin claims this PATH — but if the HOST is twinned (host-level candidacy), the path is
|
|
514
650
|
// an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
|
|
515
651
|
// handler's noTwinMessage), never pass through to the real vendor.
|
|
516
|
-
if (proxyTargetFor(u.hostname, currentEnv())) {
|
|
652
|
+
if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
|
|
517
653
|
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
|
|
518
654
|
return;
|
|
519
655
|
}
|
|
@@ -521,6 +657,25 @@ export async function startRedirectProxy(options) {
|
|
|
521
657
|
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`));
|
|
522
658
|
return;
|
|
523
659
|
}
|
|
660
|
+
const policy = worldNetworkPolicyFromEnv(currentEnv());
|
|
661
|
+
if (policy && worldNetworkNeedsPathInspection(policy, u)) {
|
|
662
|
+
if (guest) {
|
|
663
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
socket.pause();
|
|
667
|
+
const parser = netConnect(inspectedPort, '127.0.0.1', () => {
|
|
668
|
+
parser.write(raw);
|
|
669
|
+
socket.pipe(parser);
|
|
670
|
+
parser.pipe(socket);
|
|
671
|
+
socket.resume();
|
|
672
|
+
});
|
|
673
|
+
openSockets.add(parser);
|
|
674
|
+
parser.on('close', () => { openSockets.delete(parser); socket.destroy(); });
|
|
675
|
+
parser.on('error', () => socket.destroy());
|
|
676
|
+
socket.on('close', () => parser.destroy());
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
524
679
|
// Non-vendor: plain pass-through to the real host; for a World Machine, only off its host's own network, and to
|
|
525
680
|
// the address checked (architecture, "The scoped proxy's local pass-through")
|
|
526
681
|
if (guest) {
|
|
@@ -560,7 +715,7 @@ export async function startRedirectProxy(options) {
|
|
|
560
715
|
// a destination that cannot be reached at all (a name that does not resolve, a refused port) is answered by the
|
|
561
716
|
// caller's fallback when it has one, before the tunnel was ever established; otherwise the client's socket ends
|
|
562
717
|
upstream.on('error', (error) => {
|
|
563
|
-
if (!connected && unreachable && unreachable(error.code ?? error
|
|
718
|
+
if (!connected && unreachable && unreachable(error.code ?? errorMessage(error)))
|
|
564
719
|
return;
|
|
565
720
|
clientSocket.destroy();
|
|
566
721
|
});
|
|
@@ -572,13 +727,23 @@ export async function startRedirectProxy(options) {
|
|
|
572
727
|
note(`${connectHost}:${connectPort} did not answer (${reason})`);
|
|
573
728
|
refusedPages.add(connectHost);
|
|
574
729
|
unreachableHosts.set(connectHost, reason);
|
|
575
|
-
ensureHostServer(connectHost).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
|
|
730
|
+
ensureHostServer(connectHost, guest, connectPort).then((port) => tunnel(port, '127.0.0.1')).catch(() => clientSocket.destroy());
|
|
576
731
|
return true;
|
|
577
732
|
};
|
|
578
733
|
// MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
|
|
579
734
|
// the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
|
|
580
|
-
|
|
581
|
-
|
|
735
|
+
const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
|
|
736
|
+
const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
|
|
737
|
+
const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
|
|
738
|
+
const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
|
|
739
|
+
if (passthrough && !twin)
|
|
740
|
+
note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
|
|
741
|
+
if (inspected && guest && !twin) {
|
|
742
|
+
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if ((twin || (!strictEgressEnabled(currentEnv()) && inspected)) && connectHost) {
|
|
746
|
+
ensureHostServer(connectHost, guest, connectPort)
|
|
582
747
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
583
748
|
.catch(() => clientSocket.destroy());
|
|
584
749
|
}
|
|
@@ -590,7 +755,7 @@ export async function startRedirectProxy(options) {
|
|
|
590
755
|
// line, as before
|
|
591
756
|
if (connectPort === 443 && isDnsName(connectHost) && (refusedPages.has(connectHost) || refusedPages.size < REFUSED_PAGES_MAX)) {
|
|
592
757
|
refusedPages.add(connectHost);
|
|
593
|
-
ensureHostServer(connectHost)
|
|
758
|
+
ensureHostServer(connectHost, guest, connectPort)
|
|
594
759
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
595
760
|
.catch(() => clientSocket.destroy());
|
|
596
761
|
}
|
|
@@ -651,6 +816,8 @@ export async function startRedirectProxy(options) {
|
|
|
651
816
|
}
|
|
652
817
|
catch { /* ignore */ }
|
|
653
818
|
}
|
|
819
|
+
inspectedPlain.closeAllConnections();
|
|
820
|
+
inspectedPlain.close();
|
|
654
821
|
guestServer.close();
|
|
655
822
|
server.close(() => resolveClose());
|
|
656
823
|
}),
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
// in a container is state outside it (a branch of the World would share the container's queue and cache). The
|
|
9
9
|
// twin keeps each key a subject of the World (the Redis pack's storage module). It needs nothing
|
|
10
10
|
// installed: no container runtime, no redis-server binary.
|
|
11
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
12
12
|
import { connect } from 'node:net';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
|
+
import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
|
|
15
|
+
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, signalOwnedProcess } from "./process-receipt.js";
|
|
14
16
|
import { spawn } from 'node:child_process';
|
|
17
|
+
import { survivingOwnedGroups } from "./process-groups.js";
|
|
18
|
+
import { errorMessage } from "./error-message.js";
|
|
15
19
|
import { findInstalledPackage, packCli } from "./catalog.js";
|
|
16
20
|
import { vendorPackageDir } from '@volter/world-core/runtime';
|
|
21
|
+
const hosts = new Map();
|
|
17
22
|
const PACKAGE = '@volter/twin-redis';
|
|
18
23
|
const READY_TIMEOUT_MS = 60_000;
|
|
19
24
|
/** The redis twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
|
|
@@ -34,8 +39,10 @@ function alive(pid) {
|
|
|
34
39
|
process.kill(pid, 0);
|
|
35
40
|
return true;
|
|
36
41
|
}
|
|
37
|
-
catch {
|
|
38
|
-
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ESRCH')
|
|
44
|
+
return false;
|
|
45
|
+
throw error;
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
function listening(port) {
|
|
@@ -52,8 +59,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
52
59
|
export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
53
60
|
for (const service of services) {
|
|
54
61
|
const pidFile = pidPath(dataDir, service);
|
|
55
|
-
if (existsSync(pidFile)
|
|
56
|
-
|
|
62
|
+
if (hosts.has(pidFile) || existsSync(pidFile) || existsSync(spawnReceiptPath(pidFile))) {
|
|
63
|
+
await redisTwinDown([service], dataDir);
|
|
64
|
+
}
|
|
57
65
|
// a port that already answers is another process's (another World's twin, a redis-server): serving there would
|
|
58
66
|
// hand this World's app someone else's keys, so it is refused, never taken as ready
|
|
59
67
|
if (await listening(service.hostPort))
|
|
@@ -67,23 +75,51 @@ export async function redisTwinUp(services, dataDir, cli, launch = {}) {
|
|
|
67
75
|
// the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
|
|
68
76
|
// type transformation, not only stripping (the kernel declares constructor parameter properties)
|
|
69
77
|
const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
let held;
|
|
79
|
+
try {
|
|
80
|
+
try {
|
|
81
|
+
const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root], {
|
|
82
|
+
env: launch.env ?? process.env, detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd],
|
|
83
|
+
});
|
|
84
|
+
// Retain BEFORE descriptor close, birth capture or either publication can fail.
|
|
85
|
+
held = { child, lifetime: commandLifetime(child, launch.signal), closed: false };
|
|
86
|
+
const observed = held;
|
|
87
|
+
child.once('close', () => { observed.closed = true; });
|
|
88
|
+
child.on('error', () => { }); // lifetime joins spawn failure and close
|
|
89
|
+
hosts.set(pidFile, held);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
closeSync(logFd);
|
|
93
|
+
}
|
|
94
|
+
if (!held.child.pid)
|
|
95
|
+
throw new Error('Redis child did not start');
|
|
96
|
+
const identity = await held.lifetime.identity;
|
|
97
|
+
if (!identity?.start)
|
|
98
|
+
throw new Error('Redis birth capture failed; retiring held child');
|
|
99
|
+
publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify(identity) + '\n');
|
|
100
|
+
publishProcessReceipt(pidFile, `${held.child.pid}\n`);
|
|
101
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
102
|
+
while (!(await listening(service.hostPort)) || !alive(held.child.pid)) {
|
|
103
|
+
if (!alive(held.child.pid))
|
|
104
|
+
throw new Error(`redis exited before serving — see ${log}`);
|
|
105
|
+
launch.signal?.throwIfAborted();
|
|
106
|
+
if (Date.now() > deadline)
|
|
107
|
+
throw new Error(`redis did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
108
|
+
await sleep(200);
|
|
109
|
+
}
|
|
83
110
|
launch.signal?.throwIfAborted();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
held.child.unref();
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (held) {
|
|
115
|
+
try {
|
|
116
|
+
await redisTwinDown([service], dataDir);
|
|
117
|
+
}
|
|
118
|
+
catch (cleanup) {
|
|
119
|
+
throw new AggregateError([error, cleanup], 'Redis startup and retirement failed; ownership retained');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
87
123
|
}
|
|
88
124
|
}
|
|
89
125
|
}
|
|
@@ -101,24 +137,52 @@ export async function redisTwinStatus(services, dataDir) {
|
|
|
101
137
|
export async function redisTwinDown(services, dataDir) {
|
|
102
138
|
for (const service of services) {
|
|
103
139
|
const pidFile = pidPath(dataDir, service);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
const recorded = readProcessReceipt(pidFile, text => {
|
|
141
|
+
const pid = Number(text.trim());
|
|
142
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
143
|
+
throw new Error('Invalid Redis ownership PID');
|
|
144
|
+
return pid;
|
|
145
|
+
});
|
|
146
|
+
const identity = readSpawnReceipt(pidFile);
|
|
147
|
+
const held = hosts.get(pidFile);
|
|
148
|
+
if (recorded.error || identity.error)
|
|
149
|
+
process.stderr.write(`${errorMessage(new AggregateError([recorded.error, identity.error].filter(Boolean), 'Redis ownership uncertain; retrying established ownership'))}\n`);
|
|
150
|
+
if (held) {
|
|
151
|
+
const retired = () => held.closed && (!held.child.pid || !survivingOwnedGroups([held.child.pid]).length);
|
|
152
|
+
if (!retired()) {
|
|
153
|
+
held.child.ref();
|
|
154
|
+
held.lifetime.cancel('SIGTERM');
|
|
116
155
|
try {
|
|
117
|
-
|
|
156
|
+
await held.lifetime.finish();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!retired())
|
|
160
|
+
throw error;
|
|
118
161
|
}
|
|
119
|
-
catch { /* raced */ }
|
|
120
162
|
}
|
|
121
163
|
}
|
|
164
|
+
else {
|
|
165
|
+
if (!recorded.present && !identity.present)
|
|
166
|
+
continue;
|
|
167
|
+
const pid = identity.value?.pid ?? recorded.value;
|
|
168
|
+
if (!pid)
|
|
169
|
+
throw new AggregateError([recorded.error, identity.error].filter(Boolean), 'Redis ownership uncertain; receipts retained');
|
|
170
|
+
if (recorded.value && identity.value && recorded.value !== identity.value.pid)
|
|
171
|
+
throw new Error('Redis ownership receipts disagree; retirement refused; receipts retained');
|
|
172
|
+
const live = () => survivingOwnedGroups([pid]).length > 0;
|
|
173
|
+
for (const [signal, limit] of [['SIGTERM', COMMAND_GRACE_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
|
|
174
|
+
if (!live())
|
|
175
|
+
break;
|
|
176
|
+
await signalOwnedProcess(identity.value, signal, true);
|
|
177
|
+
const deadline = Date.now() + limit;
|
|
178
|
+
while (live() && Date.now() < deadline)
|
|
179
|
+
await sleep(20);
|
|
180
|
+
}
|
|
181
|
+
if (live())
|
|
182
|
+
throw new Error('Redis retirement unconfirmed; receipts retained');
|
|
183
|
+
}
|
|
122
184
|
rmSync(pidFile, { force: true });
|
|
185
|
+
rmSync(spawnReceiptPath(pidFile), { force: true });
|
|
186
|
+
hosts.delete(pidFile);
|
|
123
187
|
}
|
|
124
188
|
}
|
package/dist/src/reflect.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from "./error-message.js";
|
|
1
2
|
import { serveHttp } from '@volter/world-core/runtime';
|
|
2
3
|
import { websocketRelay } from "./websocket-relay.js";
|
|
3
4
|
import { sessionTrustEnv } from "./ca-trust.js";
|
|
@@ -161,7 +162,7 @@ export async function startReflectFront(options) {
|
|
|
161
162
|
world = options.envLoader().VOLTER_WORLD;
|
|
162
163
|
}
|
|
163
164
|
catch { /* the answer still goes out */ }
|
|
164
|
-
return new Response(twinUnreachableMessage(target.vendor, origin.origin, error instanceof Error ? error : new Error(
|
|
165
|
+
return new Response(twinUnreachableMessage(target.vendor, origin.origin, error instanceof Error ? error : new Error(errorMessage(error)), world), { status: 502 });
|
|
165
166
|
}
|
|
166
167
|
},
|
|
167
168
|
upgrade: websocketRelay(req => {
|
package/dist/src/root.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// never in a pack, never an env var). `volter world deploy` performs landed entries against each
|
|
8
8
|
// root through the pack's adapters over the kernel executor, with the world's checks in front;
|
|
9
9
|
// `deploy: auto` does the same the moment an entry is appended (the host installs the hook).
|
|
10
|
+
import { errorMessage } from "./error-message.js";
|
|
10
11
|
import { randomBytes } from 'node:crypto';
|
|
11
12
|
import { packEntry } from "./catalog.js";
|
|
12
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -126,7 +127,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
|
|
|
126
127
|
inventory = (await currentSealer().open(JSON.parse(heldInventory), INVENTORY_SLOT));
|
|
127
128
|
}
|
|
128
129
|
catch (error) {
|
|
129
|
-
console.error(`[credential] the inventory beside the World does not open (${error
|
|
130
|
+
console.error(`[credential] the inventory beside the World does not open (${errorMessage(error)}): nothing is admitted until it does`);
|
|
130
131
|
return { bound: [], failed: [], refused: [], ready: false };
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -137,7 +138,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
|
|
|
137
138
|
return true;
|
|
138
139
|
}
|
|
139
140
|
catch (error) {
|
|
140
|
-
console.error(`[credential] the inventory could not be kept (${error
|
|
141
|
+
console.error(`[credential] the inventory could not be kept (${errorMessage(error)})`);
|
|
141
142
|
return false;
|
|
142
143
|
}
|
|
143
144
|
};
|
|
@@ -182,7 +183,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
|
|
|
182
183
|
bound.push(name);
|
|
183
184
|
}
|
|
184
185
|
catch (error) {
|
|
185
|
-
console.warn(`[credential] ${name}: not bound to its destination (${error
|
|
186
|
+
console.warn(`[credential] ${name}: not bound to its destination (${errorMessage(error)})`);
|
|
186
187
|
failed.push(name);
|
|
187
188
|
}
|
|
188
189
|
}
|
|
@@ -210,7 +211,7 @@ export async function bindLegacyCredentials(worldRoot, current) {
|
|
|
210
211
|
bound.push(name);
|
|
211
212
|
}
|
|
212
213
|
catch (error) {
|
|
213
|
-
console.warn(`[credential] ${name}: not moved to the vault (${error
|
|
214
|
+
console.warn(`[credential] ${name}: not moved to the vault (${errorMessage(error)})`);
|
|
214
215
|
failed.push(name);
|
|
215
216
|
}
|
|
216
217
|
}
|
|
@@ -298,7 +299,7 @@ export async function rootCredentialInput(worldRoot, vendor, env = process.env)
|
|
|
298
299
|
response = await fetch(at, { headers: { 'x-vault-token': vault.token, ...(vault.namespace ? { 'x-vault-namespace': vault.namespace } : {}) }, signal: AbortSignal.timeout(10_000) });
|
|
299
300
|
}
|
|
300
301
|
catch (error) {
|
|
301
|
-
notes.push(`the vault at ${vault.addr} did not answer (${error
|
|
302
|
+
notes.push(`the vault at ${vault.addr} did not answer (${errorMessage(error)}; a private CA is not read from BAO_CACERT/VAULT_CACERT here)`);
|
|
302
303
|
}
|
|
303
304
|
if (response) {
|
|
304
305
|
if (response.status === 403)
|
|
@@ -526,7 +527,7 @@ export async function refreshTwin(opts) {
|
|
|
526
527
|
// failure — its reason and how many in a row — so the status door and the twin's status say so
|
|
527
528
|
const held = store.read(marker);
|
|
528
529
|
const previous = held !== null ? JSON.parse(held) : {};
|
|
529
|
-
const reason =
|
|
530
|
+
const reason = errorMessage(error);
|
|
530
531
|
store.write(marker, `${JSON.stringify({ ...previous, at: new Date().toISOString(), failed: { at, reason, count: (previous.failed?.count ?? 0) + 1 } })}\n`);
|
|
531
532
|
throw error;
|
|
532
533
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runWorldOwned } from "./runtime.js";
|
|
2
|
+
import { errorMessage } from "./error-message.js";
|
|
2
3
|
// Only this process owns the IPC endpoint; child stdio explicitly excludes fd 3. A lost
|
|
3
4
|
// caller requests cancellation, including while startup is still settling. No service restarts.
|
|
4
5
|
if (!process.send)
|
|
@@ -27,7 +28,7 @@ process.on('message', async (message) => {
|
|
|
27
28
|
reply = { result: await runWorldOwned(request.configId, request.command, request.options, abort.signal) };
|
|
28
29
|
}
|
|
29
30
|
catch (error) {
|
|
30
|
-
reply = { error:
|
|
31
|
+
reply = { error: errorMessage(error) };
|
|
31
32
|
}
|
|
32
33
|
if ('error' in reply)
|
|
33
34
|
process.stderr.write(`${reply.error}\n`);
|