@volter/world-runtime 2.0.55 → 3.0.1
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 +5 -4
- package/dist/src/actions-runner-cli.d.ts +2 -0
- package/dist/src/actions-runner-cli.js +25 -0
- package/dist/src/actions-runner.d.ts +18 -0
- package/dist/src/actions-runner.js +281 -0
- package/dist/src/ancestry.js +1 -1
- package/dist/src/app-url.js +1 -1
- package/dist/src/backings.d.ts +29 -0
- package/dist/src/backings.js +45 -0
- package/dist/src/branch.js +3 -2
- package/dist/src/browser-proxy-cli.js +1 -1
- package/dist/src/catalog-index.d.ts +38 -0
- package/dist/src/catalog-index.js +37 -0
- package/dist/src/catalog.d.ts +16 -3
- package/dist/src/catalog.js +69 -26
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +9 -11
- package/dist/src/cli-args.d.ts +4 -0
- package/dist/src/cli-args.js +18 -0
- package/dist/src/cli-commands.d.ts +1 -0
- package/dist/src/cli-commands.js +1216 -0
- package/dist/src/cli.js +8 -1300
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.d.ts +2 -2
- package/dist/src/console-apart.js +5 -5
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +1 -1
- package/dist/src/covers.js +94 -30
- package/dist/src/env-output.d.ts +1 -1
- package/dist/src/env-output.js +2 -2
- package/dist/src/fixture-env.js +18 -26
- package/dist/src/host-cli.js +4 -2
- package/dist/src/host-fault-fixture.js +6 -0
- package/dist/src/host-ready.d.ts +8 -2
- package/dist/src/host-ready.js +16 -4
- package/dist/src/host-worker.js +6 -1
- package/dist/src/host.d.ts +2 -0
- package/dist/src/host.js +9 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +33 -23
- package/dist/src/init.js +228 -96
- package/dist/src/lifecycle-record.js +2 -2
- package/dist/src/local-branches.js +1 -1
- package/dist/src/origin.d.ts +1 -1
- package/dist/src/origin.js +12 -10
- package/dist/src/pack-facts.d.ts +30 -6
- package/dist/src/pack-facts.js +13 -3
- package/dist/src/pglite-backing.js +2 -1
- package/dist/src/placeholder.d.ts +1 -1
- package/dist/src/placeholder.js +12 -4
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +2 -2
- package/dist/src/redirect-proxy.d.ts +5 -0
- package/dist/src/redirect-proxy.js +8 -1
- package/dist/src/redis-backing.js +4 -2
- package/dist/src/reflect.js +1 -1
- package/dist/src/root.d.ts +11 -33
- package/dist/src/root.js +28 -59
- package/dist/src/runtime-test-support.js +1 -1
- package/dist/src/runtime.d.ts +7 -1
- package/dist/src/runtime.js +317 -53
- package/dist/src/schema.d.ts +27 -5
- package/dist/src/schema.js +13 -12
- package/dist/src/served-world.d.ts +11 -35
- package/dist/src/served-world.js +54 -141
- package/dist/src/tail.js +1 -1
- package/dist/src/up-cli.d.ts +3 -0
- package/dist/src/up-cli.js +55 -0
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/world-origins.d.ts +1 -1
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.d.ts +1 -1
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +5 -4
- package/package.json +11 -11
- package/src/actions-runner-cli.ts +24 -0
- package/src/actions-runner.ts +244 -0
- package/src/ancestry.ts +1 -1
- package/src/app-url.ts +1 -1
- package/src/backings.ts +55 -0
- package/src/branch.ts +3 -2
- package/src/browser-proxy-cli.ts +1 -1
- package/src/catalog-index.ts +44 -0
- package/src/catalog.ts +67 -24
- package/src/changeset.ts +10 -13
- package/src/cli-args.ts +17 -0
- package/src/cli-commands.ts +1070 -0
- package/src/cli.ts +9 -1139
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +5 -5
- package/src/consumers.ts +1 -1
- package/src/covers.ts +83 -30
- package/src/env-output.ts +2 -2
- package/src/fixture-env.ts +18 -26
- package/src/host-cli.ts +4 -2
- package/src/host-ready.ts +13 -4
- package/src/host-worker.ts +6 -2
- package/src/host.ts +11 -2
- package/src/index.ts +2 -2
- package/src/init.ts +222 -101
- package/src/lifecycle-record.ts +2 -2
- package/src/local-branches.ts +1 -1
- package/src/origin.ts +12 -10
- package/src/pack-facts.ts +22 -6
- package/src/pglite-backing.ts +2 -1
- package/src/placeholder.ts +13 -4
- package/src/project-inspect.ts +2 -2
- package/src/redirect-proxy.ts +9 -1
- package/src/redis-backing.ts +4 -2
- package/src/reflect.ts +1 -1
- package/src/root.ts +33 -65
- package/src/runtime-test-support.ts +1 -1
- package/src/runtime.ts +261 -55
- package/src/schema.ts +26 -13
- package/src/served-world.ts +50 -130
- package/src/tail.ts +1 -1
- package/src/up-cli.ts +55 -0
- package/src/websocket-relay.ts +1 -1
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +3 -3
package/dist/src/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { parallelStartup } from "./startup.js";
|
|
|
4
4
|
import { waitForHostReady } from "./host-ready.js";
|
|
5
5
|
import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
|
|
6
6
|
import { packCli, packEntry } from "./catalog.js";
|
|
7
|
+
import { vendorOfPackModule } from '@volter/world-core/runtime';
|
|
7
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
9
|
import { get as httpGet } from 'node:http';
|
|
9
10
|
import { siblingScript } from "./sibling.js";
|
|
@@ -21,14 +22,15 @@ import { packFacts } from "./pack-facts.js";
|
|
|
21
22
|
import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
|
|
22
23
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from "./service-exit.js";
|
|
23
24
|
import { Socket } from 'node:net';
|
|
24
|
-
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core';
|
|
25
|
+
import { getActiveWorldStore, withFileLock, stateDirName, PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding, WORLD_BOOT_PATH, WORLD_DATA_ENV, WORLD_ENV_NAMES_ENV } from '@volter/world-core/runtime';
|
|
25
26
|
import { loadWorldConfig } from "./configs.js";
|
|
26
27
|
import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
27
|
-
import { resolveServicePackage } from "./catalog.js";
|
|
28
|
+
import { overlayPackFactsFile, resolveServicePackage } from "./catalog.js";
|
|
28
29
|
import { fakeEnvValue } from "./fixture-env.js";
|
|
29
|
-
import { MINT_ENV } from "./schema.js";
|
|
30
|
+
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from "./schema.js";
|
|
31
|
+
import { readAppUrl } from "./app-url.js";
|
|
30
32
|
import { inertInjectEnvWarnings } from "./inject-map.js";
|
|
31
|
-
import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
|
|
33
|
+
import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from "./redirect-proxy.js";
|
|
32
34
|
import { CaTrustError, sessionTrustEnv } from "./ca-trust.js";
|
|
33
35
|
import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from "./consumers.js";
|
|
34
36
|
import { assertLifecycleStartable, beginWorldLifecycle, handoffWorldLifecycle, recordWorldLifecycleEvent, releaseWorldLifecycle, retainWorldLifecycleForCleanup, hasWorldLifecycle, isWorldLifecycleRunning, lifecycleLogPath, releaseAbandonedWorldLifecycle } from "./lifecycle-record.js";
|
|
@@ -150,6 +152,7 @@ async function confirmOwnPort(port, boot, ended, twin, what, log, early = false,
|
|
|
150
152
|
function worldBootOf(port, abort) {
|
|
151
153
|
return new Promise((resolveBoot) => {
|
|
152
154
|
let request;
|
|
155
|
+
// biome-ignore lint/style/useConst: `finish` reads this, and an already-aborted signal calls `finish` before the timer is armed, where a const would still be in its temporal dead zone
|
|
153
156
|
let deadline;
|
|
154
157
|
let settled = false;
|
|
155
158
|
let responseStarted = false;
|
|
@@ -463,6 +466,8 @@ function shellQuote(value) {
|
|
|
463
466
|
}
|
|
464
467
|
function envFileContents(env) {
|
|
465
468
|
return `${Object.entries(env)
|
|
469
|
+
// the application's own origin is resolved where a command attaches, never written as the literal marker
|
|
470
|
+
.filter(([, value]) => value !== APP_URL_ENV)
|
|
466
471
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
467
472
|
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
|
468
473
|
.join('\n')}\n`;
|
|
@@ -525,6 +530,30 @@ export function withoutVaultSignIn(env) {
|
|
|
525
530
|
return out;
|
|
526
531
|
}
|
|
527
532
|
/** The process's environment as the world admits it: what `stripEnv` names stays outside. */
|
|
533
|
+
/**
|
|
534
|
+
* The network policy a World publishes: its own, under the ceiling of the World it was started inside (docs/contributing/
|
|
535
|
+
* architecture.md, "Worlds within a World"). A World started by a process of a World whose egress is closed or listed
|
|
536
|
+
* (the parent's published policy) reaches at most what the parent reaches: its egress is its own list intersected with
|
|
537
|
+
* the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
|
|
538
|
+
* so a vendor the inner World does not twin reaches the parent's twin of it.
|
|
539
|
+
*/
|
|
540
|
+
function networkOf(name, own, parentEnv = process.env) {
|
|
541
|
+
const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
|
|
542
|
+
let ceiling;
|
|
543
|
+
if (serialized !== undefined) {
|
|
544
|
+
// an unreadable parent policy closes the child entirely, as it closes the parent
|
|
545
|
+
try {
|
|
546
|
+
ceiling = JSON.parse(serialized).egress ?? [];
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
ceiling = [];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (own === undefined && ceiling === undefined)
|
|
553
|
+
return undefined;
|
|
554
|
+
const egress = own === undefined ? ceiling : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling.includes(origin));
|
|
555
|
+
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
|
|
556
|
+
}
|
|
528
557
|
export function admittedEnv(strip, env = process.env) {
|
|
529
558
|
const out = {};
|
|
530
559
|
const rules = (strip ?? []).map((s) => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
|
|
@@ -542,7 +571,24 @@ export function admittedEnv(strip, env = process.env) {
|
|
|
542
571
|
return withoutVaultSignIn(out);
|
|
543
572
|
}
|
|
544
573
|
// The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
|
|
574
|
+
/** The names a twin of this World was to issue and did not (its door failed): kept out of every command's env, so a
|
|
575
|
+
* caller's own value (a real key in their shell) never stands in for the credential the World meant to issue. */
|
|
576
|
+
export const UNISSUED_ENV = 'VOLTER_WORLD_UNISSUED';
|
|
577
|
+
function unissuedOf(instanceEnv) {
|
|
578
|
+
try {
|
|
579
|
+
const names = JSON.parse(instanceEnv[UNISSUED_ENV] ?? '[]');
|
|
580
|
+
return Array.isArray(names) ? names.filter((n) => typeof n === 'string') : [];
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
585
|
+
}
|
|
545
586
|
function stripEnvOf(instanceEnv) {
|
|
587
|
+
const unissued = unissuedOf(instanceEnv);
|
|
588
|
+
const rules = savedStripEnvOf(instanceEnv);
|
|
589
|
+
return unissued.length === 0 ? rules : [...(rules ?? []), ...unissued];
|
|
590
|
+
}
|
|
591
|
+
function savedStripEnvOf(instanceEnv) {
|
|
546
592
|
// Freeze the admission policy for the instance; editing/removing its source cannot widen it.
|
|
547
593
|
const saved = instanceEnv.VOLTER_WORLD_STRIP_ENV;
|
|
548
594
|
if (saved !== undefined) {
|
|
@@ -578,7 +624,19 @@ function serviceProcessEnv(config, service, worldEnv, port) {
|
|
|
578
624
|
for (const key of CONTROL_PLANE_EGRESS_ENV)
|
|
579
625
|
delete env[key];
|
|
580
626
|
}
|
|
581
|
-
|
|
627
|
+
dropUnissued(env);
|
|
628
|
+
const out = { ...env, ...(service.env ?? {}) };
|
|
629
|
+
// the application's own origin: the World's own application service is reached where it listens (the URL
|
|
630
|
+
// `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
|
|
631
|
+
for (const [key, value] of Object.entries(out)) {
|
|
632
|
+
if (value !== APP_URL_ENV)
|
|
633
|
+
continue;
|
|
634
|
+
if (service.id === 'app')
|
|
635
|
+
out[key] = `http://127.0.0.1:${port}`;
|
|
636
|
+
else
|
|
637
|
+
delete out[key];
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
582
640
|
}
|
|
583
641
|
async function waitForTcp(port, timeoutMs = 15_000, abort) {
|
|
584
642
|
const started = Date.now();
|
|
@@ -659,8 +717,11 @@ function writeOwnedPids(root, name, pids, fresh = []) {
|
|
|
659
717
|
function identityPath(root, name) {
|
|
660
718
|
return join(instanceDir(root, name), 'pids.identity');
|
|
661
719
|
}
|
|
662
|
-
/** Each running pid's start time
|
|
663
|
-
*
|
|
720
|
+
/** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
|
|
721
|
+
* process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
|
|
722
|
+
* given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
|
|
723
|
+
* processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
|
|
724
|
+
* paid. */
|
|
664
725
|
function processStarts(pids) {
|
|
665
726
|
const starts = new Map();
|
|
666
727
|
if (!pids.length || process.platform === 'win32')
|
|
@@ -676,11 +737,12 @@ function processStarts(pids) {
|
|
|
676
737
|
}
|
|
677
738
|
return starts;
|
|
678
739
|
}
|
|
679
|
-
const
|
|
680
|
-
for (const
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
740
|
+
const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
|
|
741
|
+
for (const pid of pids) {
|
|
742
|
+
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
|
|
743
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
|
|
744
|
+
if (match && Number(match[1]) === pid)
|
|
745
|
+
starts.set(pid, match[2]);
|
|
684
746
|
}
|
|
685
747
|
return starts;
|
|
686
748
|
}
|
|
@@ -1108,7 +1170,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1108
1170
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1109
1171
|
const serviceDataDir = join(paths.data, service.id);
|
|
1110
1172
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1111
|
-
// R17 — the lockfile holds on the spawn path too: a twin that runs
|
|
1173
|
+
// R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
|
|
1112
1174
|
// is checked against its pin and its protocol standing before it is spawned.
|
|
1113
1175
|
const spawnedModule = (service.type ?? 'twin') === 'twin' ? spawnedTwinModule(service, paths.root) : undefined;
|
|
1114
1176
|
const mounted = spawnedModule !== undefined ? lockfileCheck(service, spawnedModule) : {};
|
|
@@ -1118,6 +1180,10 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1118
1180
|
if (binding !== undefined)
|
|
1119
1181
|
args.push(binding.arg, binding.url);
|
|
1120
1182
|
}
|
|
1183
|
+
const sides = (service.type ?? 'twin') === 'twin' ? await sidePortsFor(vendorOfModule(spawnedModule, service.id), args, allocatedPorts) : [];
|
|
1184
|
+
for (const side of sides)
|
|
1185
|
+
if (!side.pinned)
|
|
1186
|
+
args.push(side.flag, String(side.port));
|
|
1121
1187
|
if (service.portArg !== false)
|
|
1122
1188
|
args.push(service.portArg ?? '--port', String(port));
|
|
1123
1189
|
if (service.rootArg !== false)
|
|
@@ -1199,6 +1265,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1199
1265
|
command: [service.command, ...args],
|
|
1200
1266
|
port,
|
|
1201
1267
|
url,
|
|
1268
|
+
...sidesRecord(sides),
|
|
1202
1269
|
...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
|
|
1203
1270
|
...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
|
|
1204
1271
|
pid: child.pid ?? 0,
|
|
@@ -1235,43 +1302,53 @@ function mountedPackage(modulePath, serviceId) {
|
|
|
1235
1302
|
break;
|
|
1236
1303
|
dir = up;
|
|
1237
1304
|
}
|
|
1238
|
-
const
|
|
1239
|
-
const vendor = m?.[1] ?? serviceId;
|
|
1305
|
+
const vendor = vendorOfPackModule(modulePath) ?? serviceId;
|
|
1240
1306
|
try {
|
|
1241
1307
|
const facts = packFacts()[vendor];
|
|
1242
1308
|
if (facts?.protocol)
|
|
1243
|
-
out.protocol = facts.protocol;
|
|
1309
|
+
out.protocol = { ...protocolStanding(facts.protocol.declared), declared: facts.protocol.declared };
|
|
1244
1310
|
}
|
|
1245
1311
|
catch { /* no artifact: the standing is unknown, not refused */ }
|
|
1246
1312
|
return out;
|
|
1247
1313
|
}
|
|
1248
1314
|
/** R17 + R16 at boot, on BOTH paths (the colocated host and the spawned process): the mounted
|
|
1249
|
-
* package must satisfy
|
|
1250
|
-
*
|
|
1315
|
+
* package must satisfy its pin and its recorded declaration must match the kernel. Registration and the host
|
|
1316
|
+
* check the loaded descriptor again before a factory runs; compiled facts are only preflight evidence. */
|
|
1251
1317
|
function lockfileCheck(service, modulePath) {
|
|
1252
1318
|
const mounted = mountedPackage(modulePath, service.id);
|
|
1253
1319
|
if (service.version !== undefined) {
|
|
1254
1320
|
if (mounted.version === undefined)
|
|
1255
1321
|
throw new Error(`Twin "${service.id}" pins version ${service.version} but no package.json was found above ${modulePath} — nothing to hold the pin against`);
|
|
1256
1322
|
if (!versionSatisfies(mounted.version, service.version))
|
|
1257
|
-
throw new Error(`Twin "${service.id}" pins version ${service.version} and the mounted package is ${mounted.version} (${mounted.packagePath}) —
|
|
1323
|
+
throw new Error(`Twin "${service.id}" pins version ${service.version} and the mounted package is ${mounted.version} (${mounted.packagePath}) — install the version the World names (npm install -D @volter/twin-${service.id}@${service.version}, as a committed lockfile does), or move the pin to it: "version" on the service in .volter/world.json (volter-world outdated lists both)`);
|
|
1258
1324
|
}
|
|
1259
1325
|
if (mounted.protocol?.standing === 'refused')
|
|
1260
|
-
throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION}
|
|
1261
|
-
if (mounted.protocol?.standing === 'deprecated')
|
|
1262
|
-
process.stderr.write(`volter-world: twin "${service.id}" targets protocol major ${mounted.protocol.major}, one behind this platform's ${PROTOCOL_VERSION} — served for this cycle under deprecation (R16)\n`);
|
|
1326
|
+
throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION} only (runtime contract R16)`);
|
|
1263
1327
|
return mounted;
|
|
1264
1328
|
}
|
|
1265
|
-
/** The module a spawned twin runs from: its first arg
|
|
1329
|
+
/** The module a spawned twin runs from: its first arg in a vendor package, from a checkout or an installed
|
|
1266
1330
|
* @volter/twin-<vendor>/ package, resolved. */
|
|
1267
1331
|
function spawnedTwinModule(service, root) {
|
|
1268
|
-
const first = (service.args ?? []).find((a) =>
|
|
1332
|
+
const first = (service.args ?? []).find((a) => !a.startsWith('-') && /\.(?:[cm]?js|tsx?)$/.test(a) && vendorOfPackModule(resolve(root, a)) !== undefined);
|
|
1269
1333
|
return first === undefined ? undefined : (first.startsWith('/') ? first : resolve(root, first));
|
|
1270
1334
|
}
|
|
1271
|
-
/**
|
|
1335
|
+
/** A twin's side ports (TwinPack.sidePorts), each allocated before boot unless the service's own args already name it
|
|
1336
|
+
* (a World that pins the port keeps its pin), with the URL the World records for it. */
|
|
1337
|
+
async function sidePortsFor(vendor, args, allocatedPorts) {
|
|
1338
|
+
const declared = packFacts()[vendor]?.sidePorts ?? [];
|
|
1339
|
+
const out = [];
|
|
1340
|
+
for (const side of declared) {
|
|
1341
|
+
const at = args.indexOf(side.flag);
|
|
1342
|
+
const given = at >= 0 ? Number(args[at + 1]) : Number.NaN;
|
|
1343
|
+
const pinned = Number.isInteger(given) && given > 0;
|
|
1344
|
+
const port = pinned ? given : await allocatePort(allocatedPorts);
|
|
1345
|
+
out.push({ name: side.name, option: side.option, flag: side.flag, port, pinned, url: `http://127.0.0.1:${port}${side.path ?? ''}` });
|
|
1346
|
+
}
|
|
1347
|
+
return out;
|
|
1348
|
+
}
|
|
1349
|
+
const sidesRecord = (sides) => sides.length ? { sides: Object.fromEntries(sides.map((side) => [side.name, { port: side.port, url: side.url }])) } : {};
|
|
1272
1350
|
function vendorOfModule(modulePath, serviceId) {
|
|
1273
|
-
|
|
1274
|
-
return m?.[1] ?? serviceId;
|
|
1351
|
+
return (modulePath === undefined ? undefined : vendorOfPackModule(modulePath)) ?? serviceId;
|
|
1275
1352
|
}
|
|
1276
1353
|
/**
|
|
1277
1354
|
* THE MANAGED-DATABASE BINDING (architecture.md, managed infrastructure: the data plane's declared exception): for a
|
|
@@ -1295,12 +1372,107 @@ export function managedDatabaseFor(vendor, configPath, env) {
|
|
|
1295
1372
|
const url = Object.keys(connections).sort().map((name) => connections[name])[0];
|
|
1296
1373
|
return url === undefined ? undefined : { arg: declared.arg, url };
|
|
1297
1374
|
}
|
|
1375
|
+
/** The facts files the World's installed packs carry (a released pack's `generated/pack-facts.json`), which the kernel's
|
|
1376
|
+
* pack-facts loader reads through the instance file. */
|
|
1377
|
+
function packFactsFiles(config, root) {
|
|
1378
|
+
const files = [];
|
|
1379
|
+
for (const service of config.services) {
|
|
1380
|
+
if (service.package === undefined)
|
|
1381
|
+
continue;
|
|
1382
|
+
let dir;
|
|
1383
|
+
try {
|
|
1384
|
+
dir = resolveServicePackage(root, service.package);
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
const file = join(dir, 'generated', 'pack-facts.json');
|
|
1390
|
+
if (existsSync(file))
|
|
1391
|
+
files.push(file);
|
|
1392
|
+
}
|
|
1393
|
+
return [...new Set(files)].sort();
|
|
1394
|
+
}
|
|
1298
1395
|
/**
|
|
1299
1396
|
* The config as the runtime boots it (docs/concepts/worlds.md#the-config-and-the-running-world): a service
|
|
1300
1397
|
* naming a `package` gets its entry and colocate module resolved from the world root NOW, by
|
|
1301
1398
|
* node's own walk, and a `$mint` env value becomes a freshly minted throwaway credential for this
|
|
1302
1399
|
* boot. Everything downstream sees ordinary command/args/module/env.
|
|
1303
1400
|
*/
|
|
1401
|
+
/** An env name whose value is still `$issue:<service>` is not set yet: no twin, host or application is handed the
|
|
1402
|
+
* marker. */
|
|
1403
|
+
function dropUnissued(env) {
|
|
1404
|
+
for (const [key, value] of Object.entries(env))
|
|
1405
|
+
if (value.startsWith(ISSUE_ENV_PREFIX))
|
|
1406
|
+
delete env[key];
|
|
1407
|
+
}
|
|
1408
|
+
/** The names the World's twins issue (`$issue:<service>` in its config), taken out of the boot env until issued. */
|
|
1409
|
+
function takeIssued(env) {
|
|
1410
|
+
const pending = new Map();
|
|
1411
|
+
for (const [key, value] of Object.entries(env))
|
|
1412
|
+
if (value.startsWith(ISSUE_ENV_PREFIX))
|
|
1413
|
+
pending.set(key, value.slice(ISSUE_ENV_PREFIX.length));
|
|
1414
|
+
dropUnissued(env);
|
|
1415
|
+
return pending;
|
|
1416
|
+
}
|
|
1417
|
+
/** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
|
|
1418
|
+
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1419
|
+
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1420
|
+
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1421
|
+
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1422
|
+
* door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
|
|
1423
|
+
async function issueCredentials(service, started, env, pending, unissued) {
|
|
1424
|
+
const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
|
|
1425
|
+
if (wanted.length === 0)
|
|
1426
|
+
return;
|
|
1427
|
+
for (const name of wanted)
|
|
1428
|
+
pending.delete(name);
|
|
1429
|
+
// the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
|
|
1430
|
+
let door = service.issues;
|
|
1431
|
+
try {
|
|
1432
|
+
door = packFacts()[service.id]?.credentialDoor ?? door;
|
|
1433
|
+
}
|
|
1434
|
+
catch { /* no artifact: the saved copy */ }
|
|
1435
|
+
try {
|
|
1436
|
+
if (!door)
|
|
1437
|
+
throw new Error('its service names no credential door');
|
|
1438
|
+
if (!started.url)
|
|
1439
|
+
throw new Error('it has no URL to ask');
|
|
1440
|
+
const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
|
|
1441
|
+
if (!res.ok)
|
|
1442
|
+
throw new Error(`answered ${res.status}`);
|
|
1443
|
+
const answer = (await res.json());
|
|
1444
|
+
for (const name of wanted) {
|
|
1445
|
+
const spec = door.fill[name] ?? '';
|
|
1446
|
+
// `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
|
|
1447
|
+
// beside the World's instance file, the name set to its path
|
|
1448
|
+
const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
|
|
1449
|
+
const value = answer[field];
|
|
1450
|
+
if (spec.startsWith('file:')) {
|
|
1451
|
+
if (value === undefined || value === null)
|
|
1452
|
+
throw new Error(`answered no ${field}`);
|
|
1453
|
+
// beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
|
|
1454
|
+
if (!env.VOLTER_WORLD_INSTANCE)
|
|
1455
|
+
throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
|
|
1456
|
+
const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
|
|
1457
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1458
|
+
const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
|
|
1459
|
+
writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
1460
|
+
env[name] = path;
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (typeof value !== 'string')
|
|
1464
|
+
throw new Error(`answered no ${field}`);
|
|
1465
|
+
env[name] = value;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
catch (error) {
|
|
1469
|
+
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
|
|
1470
|
+
for (const name of wanted) {
|
|
1471
|
+
delete env[name];
|
|
1472
|
+
unissued.add(name);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1304
1476
|
function materializeConfig(config, root) {
|
|
1305
1477
|
const services = config.services.map((service) => {
|
|
1306
1478
|
if (service.package === undefined)
|
|
@@ -1367,8 +1539,9 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1367
1539
|
const mounted = lockfileCheck(service, module);
|
|
1368
1540
|
const scenario = service.colocate.scenarioPath;
|
|
1369
1541
|
const binding = managedDatabaseFor(vendorOfModule(module, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
|
|
1370
|
-
|
|
1371
|
-
|
|
1542
|
+
const sides = await sidePortsFor(vendorOfModule(module, service.id), [], allocatedPorts);
|
|
1543
|
+
args.push('--spec', [service.id, module, service.colocate.export, String(port), serviceDataDir, ...(scenario ? [`scenario=${scenario.startsWith('/') ? scenario : resolve(paths.root, scenario)}`] : []), ...(binding !== undefined ? [`database=${binding.url}`] : []), ...sides.map((side) => `side.${side.option}=${side.port}`)].join('|'));
|
|
1544
|
+
allocated.push({ service, port, mounted, sides });
|
|
1372
1545
|
}
|
|
1373
1546
|
const log = join(paths.logs, 'host.log');
|
|
1374
1547
|
// The host is world infrastructure, not an app process — same egress hygiene as controlPlane
|
|
@@ -1376,6 +1549,11 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1376
1549
|
const env = { ...admittedEnv(config.stripEnv), ...(config.env ?? {}), ...worldEnv };
|
|
1377
1550
|
for (const key of CONTROL_PLANE_EGRESS_ENV)
|
|
1378
1551
|
delete env[key];
|
|
1552
|
+
// the twins never read the application's own origin, nor a credential not yet issued
|
|
1553
|
+
for (const [key, value] of Object.entries(env))
|
|
1554
|
+
if (value === APP_URL_ENV)
|
|
1555
|
+
delete env[key];
|
|
1556
|
+
dropUnissued(env);
|
|
1379
1557
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1380
1558
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
1381
1559
|
const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
|
|
@@ -1390,7 +1568,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1390
1568
|
const host = '127.0.0.1';
|
|
1391
1569
|
// Every twin's address is known once its port is allocated: the records and
|
|
1392
1570
|
// the environment they inject exist before the host reports its starts.
|
|
1393
|
-
const services = allocated.map(({ service, port, mounted }) => {
|
|
1571
|
+
const services = allocated.map(({ service, port, mounted, sides }) => {
|
|
1394
1572
|
const url = `http://${host}:${port}`;
|
|
1395
1573
|
return {
|
|
1396
1574
|
id: service.id,
|
|
@@ -1398,6 +1576,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1398
1576
|
command,
|
|
1399
1577
|
port,
|
|
1400
1578
|
url,
|
|
1579
|
+
...sidesRecord(sides),
|
|
1401
1580
|
...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
|
|
1402
1581
|
...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
|
|
1403
1582
|
pid: child.pid ?? 0,
|
|
@@ -1411,7 +1590,12 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1411
1590
|
});
|
|
1412
1591
|
const ready = (async () => {
|
|
1413
1592
|
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1414
|
-
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort
|
|
1593
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try {
|
|
1594
|
+
return statSync(log).size;
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
return 0;
|
|
1598
|
+
} }), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1415
1599
|
if (undeadPids([child.pid]).length)
|
|
1416
1600
|
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1417
1601
|
try {
|
|
@@ -1435,7 +1619,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1435
1619
|
// The host's receipt (this boot, every requested port) is published only
|
|
1436
1620
|
// after every factory bound its own port; a port another process held fails
|
|
1437
1621
|
// its bind and leaves no receipt. The receipt is the host's ownership proof
|
|
1438
|
-
// for each port, so no twin waits out a settle (ADR
|
|
1622
|
+
// for each port, so no twin waits out a settle (ADR 0014). A port that
|
|
1439
1623
|
// answers with another boot's identity is still refused.
|
|
1440
1624
|
const hostSettle = Promise.resolve('alive');
|
|
1441
1625
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
@@ -1463,6 +1647,12 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1463
1647
|
abort?.throwIfAborted();
|
|
1464
1648
|
const root = resolve(options.root ?? process.cwd());
|
|
1465
1649
|
const found = loadWorldConfig(configId, root);
|
|
1650
|
+
// the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
|
|
1651
|
+
const packFactsPaths = packFactsFiles(found.config, root);
|
|
1652
|
+
for (const file of packFactsPaths)
|
|
1653
|
+
overlayPackFactsFile(file);
|
|
1654
|
+
// and this process's injector host rules, which `up`'s inert-injectEnv warning reads, know them too
|
|
1655
|
+
createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
1466
1656
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
1467
1657
|
const name = options.name ?? loaded.config.id;
|
|
1468
1658
|
const mode = options.mode ?? 'local';
|
|
@@ -1510,10 +1700,22 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1510
1700
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1511
1701
|
const instanceFile = join(instance, 'instance.json');
|
|
1512
1702
|
let carriedOrigin;
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const
|
|
1703
|
+
// the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
|
|
1704
|
+
// writes it), so a file any of them last wrote is the World's own, never a custody file
|
|
1705
|
+
const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
1706
|
+
const priorOutputs = [name, ...siblings].flatMap((world) => {
|
|
1707
|
+
const file = world === name ? instanceFile : join(instanceDir(root, world), 'instance.json');
|
|
1708
|
+
if (!existsSync(file))
|
|
1709
|
+
return [];
|
|
1710
|
+
try {
|
|
1711
|
+
const prior = JSON.parse(readFileSync(file, 'utf8'));
|
|
1712
|
+
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
|
|
1713
|
+
}
|
|
1714
|
+
catch {
|
|
1715
|
+
return [];
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
|
|
1517
1719
|
if (loaded.config.resources)
|
|
1518
1720
|
process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
|
|
1519
1721
|
checkedOwner(options.owner);
|
|
@@ -1649,7 +1851,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1649
1851
|
// the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
|
|
1650
1852
|
// on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
|
|
1651
1853
|
VOLTER_WORLD: name,
|
|
1652
|
-
...(
|
|
1854
|
+
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
|
|
1653
1855
|
VOLTER_WORLD_MODE: mode,
|
|
1654
1856
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
1655
1857
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
@@ -1670,6 +1872,8 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1670
1872
|
};
|
|
1671
1873
|
env.NO_PROXY = `127.0.0.1,localhost${env.NO_PROXY || inheritedEnv.NO_PROXY ? `,${env.NO_PROXY ?? inheritedEnv.NO_PROXY}` : ''}`;
|
|
1672
1874
|
env.no_proxy = `127.0.0.1,localhost${env.no_proxy || inheritedEnv.no_proxy ? `,${env.no_proxy ?? inheritedEnv.no_proxy}` : ''}`;
|
|
1875
|
+
const issuing = takeIssued(env);
|
|
1876
|
+
const unissued = new Set();
|
|
1673
1877
|
writeProxyEnv(root, name, env);
|
|
1674
1878
|
const worldInstance = {
|
|
1675
1879
|
name, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
@@ -1678,6 +1882,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1678
1882
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1679
1883
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
1680
1884
|
lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
|
|
1885
|
+
...(packFactsPaths.length ? { packFacts: packFactsPaths } : {}),
|
|
1681
1886
|
...(carriedOrigin ? { origin: carriedOrigin } : {}),
|
|
1682
1887
|
};
|
|
1683
1888
|
saveWorldInstance(worldInstance);
|
|
@@ -1711,7 +1916,8 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1711
1916
|
// The co-located host's twins are known by address once their ports are
|
|
1712
1917
|
// allocated. A twin serves and calls no other service while it starts,
|
|
1713
1918
|
// so a later twin declaration starts beside the host's startup; any other
|
|
1714
|
-
// service waits until every earlier one is ready, as declared (ADR
|
|
1919
|
+
// service waits until every earlier one is ready, as declared (ADR 0015).
|
|
1920
|
+
// A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
|
|
1715
1921
|
let colocatedReady;
|
|
1716
1922
|
if (isolation !== 'process') {
|
|
1717
1923
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
@@ -1720,7 +1926,15 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1720
1926
|
services[started.id] = started;
|
|
1721
1927
|
Object.assign(env, started.env);
|
|
1722
1928
|
}
|
|
1723
|
-
colocatedReady =
|
|
1929
|
+
colocatedReady = async () => {
|
|
1930
|
+
await host.ready;
|
|
1931
|
+
for (const started of host.services) {
|
|
1932
|
+
const declared = colocated.find((s) => s.id === started.id);
|
|
1933
|
+
if (declared)
|
|
1934
|
+
await issueCredentials(declared, started, env, issuing, unissued);
|
|
1935
|
+
}
|
|
1936
|
+
writeProxyEnv(root, name, env);
|
|
1937
|
+
};
|
|
1724
1938
|
recordPidsSoFar();
|
|
1725
1939
|
writeProxyEnv(root, name, env);
|
|
1726
1940
|
}
|
|
@@ -1733,6 +1947,9 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1733
1947
|
const attemptProxy = () => {
|
|
1734
1948
|
if (proxyForServices || (Object.keys(activeVendorMap(env)).length === 0 && env[WORLD_NETWORK_POLICY_ENV] === undefined))
|
|
1735
1949
|
return;
|
|
1950
|
+
// a host with no raw sockets (a browser tab) has nothing for the proxy to intercept; the injector holds the policy
|
|
1951
|
+
if (!hostHasRawEgress())
|
|
1952
|
+
return;
|
|
1736
1953
|
if (!opensslAvailable()) {
|
|
1737
1954
|
if (mode === 'sealed' || loaded.config.network !== undefined) {
|
|
1738
1955
|
throw new Error(`world "${name}" requires its network policy proxy, but openssl is unavailable; refusing to start. Install openssl.`);
|
|
@@ -1754,7 +1971,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1754
1971
|
if (services[service.id])
|
|
1755
1972
|
continue; // already up co-located
|
|
1756
1973
|
if (colocatedReady && (service.type ?? 'twin') !== 'twin') {
|
|
1757
|
-
await colocatedReady;
|
|
1974
|
+
await colocatedReady();
|
|
1758
1975
|
colocatedReady = undefined;
|
|
1759
1976
|
}
|
|
1760
1977
|
attemptProxy();
|
|
@@ -1763,11 +1980,21 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1763
1980
|
services[started.id] = started;
|
|
1764
1981
|
recordPidsSoFar();
|
|
1765
1982
|
Object.assign(env, started.env);
|
|
1983
|
+
await issueCredentials(service, started, env, issuing, unissued);
|
|
1766
1984
|
writeProxyEnv(root, name, env);
|
|
1767
1985
|
}
|
|
1768
1986
|
if (colocatedReady)
|
|
1769
|
-
await colocatedReady;
|
|
1987
|
+
await colocatedReady();
|
|
1770
1988
|
detachBootSignals();
|
|
1989
|
+
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
1990
|
+
if (issuing.size > 0)
|
|
1991
|
+
process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
|
|
1992
|
+
for (const [name] of issuing)
|
|
1993
|
+
unissued.add(name);
|
|
1994
|
+
if (unissued.size > 0) {
|
|
1995
|
+
env[UNISSUED_ENV] = JSON.stringify([...unissued].sort());
|
|
1996
|
+
writeProxyEnv(root, name, env);
|
|
1997
|
+
}
|
|
1771
1998
|
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
1772
1999
|
// only lands in `env` once IT has started, so a check that only runs "before starting the
|
|
1773
2000
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
@@ -2989,7 +3216,17 @@ export function urlWorld(name, options = {}) {
|
|
|
2989
3216
|
if (options.service === undefined) {
|
|
2990
3217
|
return Object.values(status.services)
|
|
2991
3218
|
.filter((service) => service.url)
|
|
2992
|
-
.
|
|
3219
|
+
.flatMap((service) => [{ id: service.id, url: service.url }, ...Object.entries(service.sides ?? {}).map(([side, at]) => ({ id: `${service.id}.${side}`, url: at.url }))]);
|
|
3220
|
+
}
|
|
3221
|
+
// a side port by `<service>.<name>` (the SMTP twin's mailbox: smtp.inspect)
|
|
3222
|
+
const dot = options.service.lastIndexOf('.');
|
|
3223
|
+
if (!status.services[options.service] && dot > 0) {
|
|
3224
|
+
const owner = status.services[options.service.slice(0, dot)];
|
|
3225
|
+
const side = owner?.sides?.[options.service.slice(dot + 1)];
|
|
3226
|
+
if (side)
|
|
3227
|
+
return [{ id: options.service, url: side.url }];
|
|
3228
|
+
if (owner)
|
|
3229
|
+
throw new Error(`Service "${owner.id}" has no side port "${options.service.slice(dot + 1)}"; it has: ${Object.keys(owner.sides ?? {}).join(', ') || 'none'}`);
|
|
2993
3230
|
}
|
|
2994
3231
|
const service = status.services[options.service];
|
|
2995
3232
|
if (!service) {
|
|
@@ -3120,19 +3357,38 @@ function worldAttachedCommandEnv(name, root, verbose = false) {
|
|
|
3120
3357
|
const proxy = ensureWorldProxy(name, root, [admittedEnv(stripEnvOf(status.env)), status.env]);
|
|
3121
3358
|
if (proxy?.url) {
|
|
3122
3359
|
proxyEnv = proxy.env;
|
|
3360
|
+
// a host with no raw sockets (a browser tab) runs no proxy: the injector holds the policy there, as at `up`
|
|
3123
3361
|
}
|
|
3124
|
-
else if (sealed)
|
|
3362
|
+
else if (sealed && hostHasRawEgress())
|
|
3125
3363
|
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
|
|
3126
3364
|
}
|
|
3127
3365
|
catch (error) {
|
|
3128
3366
|
if (error instanceof CaTrustError)
|
|
3129
3367
|
throw error;
|
|
3130
|
-
if (sealed)
|
|
3368
|
+
if (sealed && hostHasRawEgress())
|
|
3131
3369
|
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${error instanceof Error ? error.message : String(error)})`);
|
|
3132
3370
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3133
3371
|
}
|
|
3134
|
-
return { instance: status, env: { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
3135
|
-
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' } };
|
|
3372
|
+
return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
3373
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' }) };
|
|
3374
|
+
}
|
|
3375
|
+
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
3376
|
+
* unset (the application's own env files decide) and the command is told how to record it: never a guess. */
|
|
3377
|
+
export function withAppUrl(name, root, env) {
|
|
3378
|
+
const names = Object.keys(env).filter((key) => env[key] === APP_URL_ENV);
|
|
3379
|
+
if (names.length === 0)
|
|
3380
|
+
return env;
|
|
3381
|
+
const out = { ...env };
|
|
3382
|
+
const recorded = readAppUrl(name, { root });
|
|
3383
|
+
for (const key of names) {
|
|
3384
|
+
if (recorded)
|
|
3385
|
+
out[key] = recorded.url.replace(/\/+$/, '');
|
|
3386
|
+
else
|
|
3387
|
+
delete out[key];
|
|
3388
|
+
}
|
|
3389
|
+
if (!recorded)
|
|
3390
|
+
process.stderr.write(`world "${name}": ${names.join(', ')} name the application's own address, which is not recorded; left unset. Record it with: volter-world app-url ${name} --set <url>\n`);
|
|
3391
|
+
return out;
|
|
3136
3392
|
}
|
|
3137
3393
|
/** Runs the command in the world's env in the foreground and resolves to its exit code. The child is
|
|
3138
3394
|
* awaited, not blocked on: a runtime that runs no synchronous child (the browser engine) still runs
|
|
@@ -3144,7 +3400,7 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
|
|
|
3144
3400
|
return new Promise((resolveExit) => {
|
|
3145
3401
|
const child = spawn(command[0], command.slice(1), { windowsHide: true,
|
|
3146
3402
|
cwd: resolve(options.cwd ?? root),
|
|
3147
|
-
env: attached.env,
|
|
3403
|
+
env: { ...attached.env, ...(options.env ?? {}) },
|
|
3148
3404
|
stdio: 'inherit',
|
|
3149
3405
|
});
|
|
3150
3406
|
const forward = (signal) => { if (child.exitCode === null && child.signalCode === null)
|
|
@@ -3400,7 +3656,7 @@ export function worldShellEnv(name, root = process.cwd()) {
|
|
|
3400
3656
|
return {
|
|
3401
3657
|
shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
|
|
3402
3658
|
cwd: resolve(root),
|
|
3403
|
-
env: { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name },
|
|
3659
|
+
env: withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name }),
|
|
3404
3660
|
};
|
|
3405
3661
|
}
|
|
3406
3662
|
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
@@ -3414,7 +3670,10 @@ export async function shellWorld(name, root = process.cwd()) {
|
|
|
3414
3670
|
const resolvedRoot = resolve(root);
|
|
3415
3671
|
let proxy = null;
|
|
3416
3672
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
3417
|
-
if (
|
|
3673
|
+
if (!hostHasRawEgress()) {
|
|
3674
|
+
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (this host opens no raw sockets; the injector holds the network policy). type 'exit' to leave.\n`);
|
|
3675
|
+
}
|
|
3676
|
+
else if (opensslAvailable()) {
|
|
3418
3677
|
try {
|
|
3419
3678
|
proxy = await startRedirectProxy({ env, trustEnv: [admittedEnv(stripEnvOf(env)), env], tlsDir: tlsDir(resolvedRoot, name) });
|
|
3420
3679
|
Object.assign(env, proxy.proxyEnv());
|
|
@@ -3494,7 +3753,7 @@ function currentProxyState(root, name) {
|
|
|
3494
3753
|
}
|
|
3495
3754
|
function ensureWorldProxyUnlocked(name, root, caller) {
|
|
3496
3755
|
const resolvedRoot = resolve(root);
|
|
3497
|
-
if (!opensslAvailable())
|
|
3756
|
+
if (!hostHasRawEgress() || !opensslAvailable())
|
|
3498
3757
|
return null;
|
|
3499
3758
|
const existing = currentProxyState(resolvedRoot, name);
|
|
3500
3759
|
if (existing && isAlive(existing.pid)) {
|
|
@@ -3506,7 +3765,7 @@ function ensureWorldProxyUnlocked(name, root, caller) {
|
|
|
3506
3765
|
}
|
|
3507
3766
|
function ensureWorldProxyFromEnv(name, root, env, boot) {
|
|
3508
3767
|
const resolvedRoot = resolve(root);
|
|
3509
|
-
if (!opensslAvailable())
|
|
3768
|
+
if (!hostHasRawEgress() || !opensslAvailable())
|
|
3510
3769
|
return null;
|
|
3511
3770
|
writeProxyEnv(resolvedRoot, name, env);
|
|
3512
3771
|
const existing = currentProxyState(resolvedRoot, name);
|
|
@@ -3606,6 +3865,10 @@ function startProxyDaemonOwned(name, root, caCertPath, envFile, boot, caller = p
|
|
|
3606
3865
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
3607
3866
|
}
|
|
3608
3867
|
retire();
|
|
3868
|
+
// the proxy is confirmed gone: ownership goes back to the World's running processes, as on success (it was retained
|
|
3869
|
+
// for cleanup only while the child's fate was unknown), so the World stays running unsealed, as the warning says
|
|
3870
|
+
if (!boot)
|
|
3871
|
+
handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
|
|
3609
3872
|
process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
|
|
3610
3873
|
return { url: '', caCertPath, env: {} };
|
|
3611
3874
|
}
|
|
@@ -3739,16 +4002,17 @@ export function activateScript(name, root = process.cwd()) {
|
|
|
3739
4002
|
env = { ...baseEnv, ...proxy.env };
|
|
3740
4003
|
proxyNote = ` (ambient proxy ${proxy.url})`;
|
|
3741
4004
|
}
|
|
3742
|
-
else if (sealed)
|
|
4005
|
+
else if (sealed && hostHasRawEgress())
|
|
3743
4006
|
throw new Error('attachment proxy is unavailable');
|
|
3744
4007
|
}
|
|
3745
4008
|
catch (error) {
|
|
3746
4009
|
if (error instanceof CaTrustError)
|
|
3747
4010
|
throw error;
|
|
3748
|
-
if (sealed)
|
|
4011
|
+
if (sealed && hostHasRawEgress())
|
|
3749
4012
|
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
|
|
3750
4013
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
3751
4014
|
}
|
|
4015
|
+
env = withAppUrl(name, root, env);
|
|
3752
4016
|
const keys = Object.keys(env).sort();
|
|
3753
4017
|
const lines = [
|
|
3754
4018
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|