@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/src/runtime.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { parallelStartup } from './startup.ts';
|
|
|
4
4
|
import { waitForHostReady } from './host-ready.ts';
|
|
5
5
|
import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
|
|
6
6
|
import { packCli, packEntry } from './catalog.ts';
|
|
7
|
+
import { vendorOfPackModule } from '@volter/world-core/runtime';
|
|
7
8
|
import { spawn, spawnSync } from 'node:child_process';
|
|
8
9
|
import { get as httpGet, type ClientRequest } from 'node:http';
|
|
9
10
|
import { siblingScript } from './sibling.ts';
|
|
@@ -21,15 +22,15 @@ import { packFacts } from './pack-facts.ts';
|
|
|
21
22
|
import { infraConnections, parseInfraDefinition } from './pglite-backing.ts';
|
|
22
23
|
import { describeServiceEnd, readServiceEnd, serviceOutput } from './service-exit.ts';
|
|
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 type { PlanReviewRecord } 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';
|
|
26
26
|
import { loadWorldConfig } from './configs.ts';
|
|
27
27
|
import { createWorldNetworkPolicy, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
28
|
-
import { resolveServicePackage } from './catalog.ts';
|
|
28
|
+
import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
|
|
29
29
|
import { fakeEnvValue } from './fixture-env.ts';
|
|
30
|
-
import { MINT_ENV } from './schema.ts';
|
|
30
|
+
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
|
|
31
|
+
import { readAppUrl } from './app-url.ts';
|
|
31
32
|
import { inertInjectEnvWarnings } from './inject-map.ts';
|
|
32
|
-
import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
|
|
33
|
+
import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
|
|
33
34
|
import { CaTrustError, sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
|
|
34
35
|
import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldRunOutcome, WorldRunRecord, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
|
|
35
36
|
import { checkedOwner, CONSUMER_HEARTBEAT_MS, createConsumer, refreshConsumer, releaseConsumer, removeConsumerRecord, worldConsumers } from './consumers.ts';
|
|
@@ -138,6 +139,7 @@ async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<vo
|
|
|
138
139
|
function worldBootOf(port: number, abort: AbortSignal): Promise<{ boot?: unknown } | null> {
|
|
139
140
|
return new Promise((resolveBoot) => {
|
|
140
141
|
let request: ClientRequest | undefined;
|
|
142
|
+
// 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
|
|
141
143
|
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
142
144
|
let settled = false;
|
|
143
145
|
let responseStarted = false;
|
|
@@ -503,6 +505,8 @@ function shellQuote(value: string): string {
|
|
|
503
505
|
|
|
504
506
|
function envFileContents(env: Record<string, string>): string {
|
|
505
507
|
return `${Object.entries(env)
|
|
508
|
+
// the application's own origin is resolved where a command attaches, never written as the literal marker
|
|
509
|
+
.filter(([, value]) => value !== APP_URL_ENV)
|
|
506
510
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
507
511
|
.map(([key, value]) => `export ${key}=${shellQuote(value)}`)
|
|
508
512
|
.join('\n')}\n`;
|
|
@@ -571,6 +575,25 @@ export function withoutVaultSignIn<T extends Record<string, string | undefined>>
|
|
|
571
575
|
}
|
|
572
576
|
|
|
573
577
|
/** The process's environment as the world admits it: what `stripEnv` names stays outside. */
|
|
578
|
+
/**
|
|
579
|
+
* The network policy a World publishes: its own, under the ceiling of the World it was started inside (docs/contributing/
|
|
580
|
+
* architecture.md, "Worlds within a World"). A World started by a process of a World whose egress is closed or listed
|
|
581
|
+
* (the parent's published policy) reaches at most what the parent reaches: its egress is its own list intersected with
|
|
582
|
+
* the parent's, or the parent's when it declares none. Routing needs no rule: the parent's twin addresses pass through,
|
|
583
|
+
* so a vendor the inner World does not twin reaches the parent's twin of it.
|
|
584
|
+
*/
|
|
585
|
+
function networkOf(name: string, own: { egress: readonly string[] } | undefined, parentEnv: NodeJS.ProcessEnv = process.env): Record<string, string> | undefined {
|
|
586
|
+
const serialized = parentEnv[WORLD_NETWORK_POLICY_ENV];
|
|
587
|
+
let ceiling: readonly string[] | undefined;
|
|
588
|
+
if (serialized !== undefined) {
|
|
589
|
+
// an unreadable parent policy closes the child entirely, as it closes the parent
|
|
590
|
+
try { ceiling = (JSON.parse(serialized) as { egress?: string[] }).egress ?? []; } catch { ceiling = []; }
|
|
591
|
+
}
|
|
592
|
+
if (own === undefined && ceiling === undefined) return undefined;
|
|
593
|
+
const egress = own === undefined ? ceiling! : ceiling === undefined ? own.egress : own.egress.filter((origin) => ceiling!.includes(origin));
|
|
594
|
+
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, { egress: [...egress] })) };
|
|
595
|
+
}
|
|
596
|
+
|
|
574
597
|
export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
575
598
|
const out: Record<string, string> = {};
|
|
576
599
|
const rules = (strip ?? []).map((s): { prefix: string } | { name: string } => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
|
|
@@ -585,7 +608,20 @@ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv
|
|
|
585
608
|
return withoutVaultSignIn(out);
|
|
586
609
|
}
|
|
587
610
|
// The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
|
|
611
|
+
/** 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
|
|
612
|
+
* caller's own value (a real key in their shell) never stands in for the credential the World meant to issue. */
|
|
613
|
+
export const UNISSUED_ENV = 'VOLTER_WORLD_UNISSUED';
|
|
614
|
+
function unissuedOf(instanceEnv: Record<string, string>): string[] {
|
|
615
|
+
try { const names: unknown = JSON.parse(instanceEnv[UNISSUED_ENV] ?? '[]'); return Array.isArray(names) ? names.filter((n): n is string => typeof n === 'string') : []; } catch { return []; }
|
|
616
|
+
}
|
|
617
|
+
|
|
588
618
|
function stripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
|
|
619
|
+
const unissued = unissuedOf(instanceEnv);
|
|
620
|
+
const rules = savedStripEnvOf(instanceEnv);
|
|
621
|
+
return unissued.length === 0 ? rules : [...(rules ?? []), ...unissued];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function savedStripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
|
|
589
625
|
// Freeze the admission policy for the instance; editing/removing its source cannot widen it.
|
|
590
626
|
const saved = instanceEnv.VOLTER_WORLD_STRIP_ENV;
|
|
591
627
|
if (saved !== undefined) {
|
|
@@ -615,7 +651,16 @@ function serviceProcessEnv(
|
|
|
615
651
|
if (service.controlPlane) {
|
|
616
652
|
for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
617
653
|
}
|
|
618
|
-
|
|
654
|
+
dropUnissued(env);
|
|
655
|
+
const out: Record<string, string> = { ...env, ...(service.env ?? {}) };
|
|
656
|
+
// the application's own origin: the World's own application service is reached where it listens (the URL
|
|
657
|
+
// `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
|
|
658
|
+
for (const [key, value] of Object.entries(out)) {
|
|
659
|
+
if (value !== APP_URL_ENV) continue;
|
|
660
|
+
if (service.id === 'app') out[key] = `http://127.0.0.1:${port}`;
|
|
661
|
+
else delete out[key];
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
619
664
|
}
|
|
620
665
|
|
|
621
666
|
async function waitForTcp(port: number, timeoutMs = 15_000, abort?: AbortSignal): Promise<void> {
|
|
@@ -701,8 +746,11 @@ function writeOwnedPids(root: string, name: string, pids: number[], fresh: numbe
|
|
|
701
746
|
function identityPath(root: string, name: string): string {
|
|
702
747
|
return join(instanceDir(root, name), 'pids.identity');
|
|
703
748
|
}
|
|
704
|
-
/** Each running pid's start time
|
|
705
|
-
*
|
|
749
|
+
/** Each running pid's start time; a pid with no process is absent. Printed in one fixed zone and locale, so the same
|
|
750
|
+
* process reads the same whoever asks (`lstart` otherwise follows the caller's TZ and language). One `ps` per pid:
|
|
751
|
+
* given two or more pids, macOS `ps -p` walks the whole process table (measured 2026-09-30 on Darwin 25.4, 944
|
|
752
|
+
* processes: `/usr/bin/time -p ps -o pid=,lstart= -p 1,$$` 0.14–0.35 s, `… -p 1` 0.00 s), which every `status`
|
|
753
|
+
* paid. */
|
|
706
754
|
function processStarts(pids: number[]): Map<number, string> {
|
|
707
755
|
const starts = new Map<number, string>();
|
|
708
756
|
if (!pids.length || process.platform === 'win32') return starts;
|
|
@@ -713,10 +761,11 @@ function processStarts(pids: number[]): Map<number, string> {
|
|
|
713
761
|
}
|
|
714
762
|
return starts;
|
|
715
763
|
}
|
|
716
|
-
const
|
|
717
|
-
for (const
|
|
718
|
-
const
|
|
719
|
-
|
|
764
|
+
const env = { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' };
|
|
765
|
+
for (const pid of pids) {
|
|
766
|
+
const result = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', String(pid)], { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', env });
|
|
767
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec((result.stdout ?? '').trim());
|
|
768
|
+
if (match && Number(match[1]) === pid) starts.set(pid, match[2]!);
|
|
720
769
|
}
|
|
721
770
|
return starts;
|
|
722
771
|
}
|
|
@@ -1129,7 +1178,7 @@ async function startService(
|
|
|
1129
1178
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1130
1179
|
const serviceDataDir = join(paths.data, service.id);
|
|
1131
1180
|
mkdirSync(serviceDataDir, { recursive: true });
|
|
1132
|
-
// R17 — the lockfile holds on the spawn path too: a twin that runs
|
|
1181
|
+
// R17 — the lockfile holds on the spawn path too: a twin that runs a checkout's CLI
|
|
1133
1182
|
// is checked against its pin and its protocol standing before it is spawned.
|
|
1134
1183
|
const spawnedModule = (service.type ?? 'twin') === 'twin' ? spawnedTwinModule(service, paths.root) : undefined;
|
|
1135
1184
|
const mounted: MountedPackage = spawnedModule !== undefined ? lockfileCheck(service, spawnedModule) : {};
|
|
@@ -1139,6 +1188,8 @@ async function startService(
|
|
|
1139
1188
|
const binding = managedDatabaseFor(vendorOfModule(spawnedModule, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
|
|
1140
1189
|
if (binding !== undefined) args.push(binding.arg, binding.url);
|
|
1141
1190
|
}
|
|
1191
|
+
const sides = (service.type ?? 'twin') === 'twin' ? await sidePortsFor(vendorOfModule(spawnedModule, service.id), args, allocatedPorts) : [];
|
|
1192
|
+
for (const side of sides) if (!side.pinned) args.push(side.flag, String(side.port));
|
|
1142
1193
|
if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
|
|
1143
1194
|
if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
1144
1195
|
|
|
@@ -1218,6 +1269,7 @@ async function startService(
|
|
|
1218
1269
|
command: [service.command, ...args],
|
|
1219
1270
|
port,
|
|
1220
1271
|
url,
|
|
1272
|
+
...sidesRecord(sides),
|
|
1221
1273
|
...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
|
|
1222
1274
|
...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
|
|
1223
1275
|
pid: child.pid ?? 0,
|
|
@@ -1246,35 +1298,49 @@ function mountedPackage(modulePath: string, serviceId: string): MountedPackage {
|
|
|
1246
1298
|
}
|
|
1247
1299
|
const up = dirname(dir); if (up === dir) break; dir = up;
|
|
1248
1300
|
}
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
1251
|
-
try { const facts = packFacts()[vendor]; if (facts?.protocol) out.protocol = facts.protocol; } catch { /* no artifact: the standing is unknown, not refused */ }
|
|
1301
|
+
const vendor = vendorOfPackModule(modulePath) ?? serviceId;
|
|
1302
|
+
try { const facts = packFacts()[vendor]; if (facts?.protocol) out.protocol = { ...protocolStanding(facts.protocol.declared), declared: facts.protocol.declared }; } catch { /* no artifact: the standing is unknown, not refused */ }
|
|
1252
1303
|
return out;
|
|
1253
1304
|
}
|
|
1254
1305
|
/** R17 + R16 at boot, on BOTH paths (the colocated host and the spawned process): the mounted
|
|
1255
|
-
* package must satisfy
|
|
1256
|
-
*
|
|
1306
|
+
* package must satisfy its pin and its recorded declaration must match the kernel. Registration and the host
|
|
1307
|
+
* check the loaded descriptor again before a factory runs; compiled facts are only preflight evidence. */
|
|
1257
1308
|
function lockfileCheck(service: WorldServiceConfig, modulePath: string): MountedPackage {
|
|
1258
1309
|
const mounted = mountedPackage(modulePath, service.id);
|
|
1259
1310
|
if (service.version !== undefined) {
|
|
1260
1311
|
if (mounted.version === undefined) throw new Error(`Twin "${service.id}" pins version ${service.version} but no package.json was found above ${modulePath} — nothing to hold the pin against`);
|
|
1261
|
-
if (!versionSatisfies(mounted.version, service.version)) throw new Error(`Twin "${service.id}" pins version ${service.version} and the mounted package is ${mounted.version} (${mounted.packagePath}) —
|
|
1312
|
+
if (!versionSatisfies(mounted.version, service.version)) 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)`);
|
|
1262
1313
|
}
|
|
1263
|
-
if (mounted.protocol?.standing === 'refused') throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION}
|
|
1264
|
-
if (mounted.protocol?.standing === 'deprecated') 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`);
|
|
1314
|
+
if (mounted.protocol?.standing === 'refused') throw new Error(`Twin "${service.id}" targets protocol major ${mounted.protocol.major}; this platform serves ${PROTOCOL_VERSION} only (runtime contract R16)`);
|
|
1265
1315
|
return mounted;
|
|
1266
1316
|
}
|
|
1267
|
-
/** The module a spawned twin runs from: its first arg
|
|
1317
|
+
/** The module a spawned twin runs from: its first arg in a vendor package, from a checkout or an installed
|
|
1268
1318
|
* @volter/twin-<vendor>/ package, resolved. */
|
|
1269
1319
|
function spawnedTwinModule(service: WorldServiceConfig, root: string): string | undefined {
|
|
1270
|
-
const first = (service.args ?? []).find((a) =>
|
|
1320
|
+
const first = (service.args ?? []).find((a) => !a.startsWith('-') && /\.(?:[cm]?js|tsx?)$/.test(a) && vendorOfPackModule(resolve(root, a)) !== undefined);
|
|
1271
1321
|
return first === undefined ? undefined : (first.startsWith('/') ? first : resolve(root, first));
|
|
1272
1322
|
}
|
|
1273
1323
|
|
|
1274
|
-
/**
|
|
1324
|
+
/** A twin's side ports (TwinPack.sidePorts), each allocated before boot unless the service's own args already name it
|
|
1325
|
+
* (a World that pins the port keeps its pin), with the URL the World records for it. */
|
|
1326
|
+
async function sidePortsFor(vendor: string, args: readonly string[], allocatedPorts: Set<number>): Promise<Array<{ name: string; option: string; flag: string; port: number; pinned: boolean; url: string }>> {
|
|
1327
|
+
const declared = packFacts()[vendor]?.sidePorts ?? [];
|
|
1328
|
+
const out: Array<{ name: string; option: string; flag: string; port: number; pinned: boolean; url: string }> = [];
|
|
1329
|
+
for (const side of declared) {
|
|
1330
|
+
const at = args.indexOf(side.flag);
|
|
1331
|
+
const given = at >= 0 ? Number(args[at + 1]) : Number.NaN;
|
|
1332
|
+
const pinned = Number.isInteger(given) && given > 0;
|
|
1333
|
+
const port = pinned ? given : await allocatePort(allocatedPorts);
|
|
1334
|
+
out.push({ name: side.name, option: side.option, flag: side.flag, port, pinned, url: `http://127.0.0.1:${port}${side.path ?? ''}` });
|
|
1335
|
+
}
|
|
1336
|
+
return out;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const sidesRecord = (sides: ReadonlyArray<{ name: string; port: number; url: string }>): { sides?: Record<string, { port: number; url: string }> } =>
|
|
1340
|
+
sides.length ? { sides: Object.fromEntries(sides.map((side) => [side.name, { port: side.port, url: side.url }])) } : {};
|
|
1341
|
+
|
|
1275
1342
|
function vendorOfModule(modulePath: string | undefined, serviceId: string): string {
|
|
1276
|
-
|
|
1277
|
-
return m?.[1] ?? serviceId;
|
|
1343
|
+
return (modulePath === undefined ? undefined : vendorOfPackModule(modulePath)) ?? serviceId;
|
|
1278
1344
|
}
|
|
1279
1345
|
|
|
1280
1346
|
/**
|
|
@@ -1297,12 +1363,86 @@ export function managedDatabaseFor(vendor: string, configPath: string | undefine
|
|
|
1297
1363
|
return url === undefined ? undefined : { arg: declared.arg, url };
|
|
1298
1364
|
}
|
|
1299
1365
|
|
|
1366
|
+
/** The facts files the World's installed packs carry (a released pack's `generated/pack-facts.json`), which the kernel's
|
|
1367
|
+
* pack-facts loader reads through the instance file. */
|
|
1368
|
+
function packFactsFiles(config: WorldConfig, root: string): string[] {
|
|
1369
|
+
const files: string[] = [];
|
|
1370
|
+
for (const service of config.services) {
|
|
1371
|
+
if (service.package === undefined) continue;
|
|
1372
|
+
let dir: string;
|
|
1373
|
+
try { dir = resolveServicePackage(root, service.package); } catch { continue; }
|
|
1374
|
+
const file = join(dir, 'generated', 'pack-facts.json');
|
|
1375
|
+
if (existsSync(file)) files.push(file);
|
|
1376
|
+
}
|
|
1377
|
+
return [...new Set(files)].sort();
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1300
1380
|
/**
|
|
1301
1381
|
* The config as the runtime boots it (docs/concepts/worlds.md#the-config-and-the-running-world): a service
|
|
1302
1382
|
* naming a `package` gets its entry and colocate module resolved from the world root NOW, by
|
|
1303
1383
|
* node's own walk, and a `$mint` env value becomes a freshly minted throwaway credential for this
|
|
1304
1384
|
* boot. Everything downstream sees ordinary command/args/module/env.
|
|
1305
1385
|
*/
|
|
1386
|
+
/** An env name whose value is still `$issue:<service>` is not set yet: no twin, host or application is handed the
|
|
1387
|
+
* marker. */
|
|
1388
|
+
function dropUnissued(env: Record<string, string>): void {
|
|
1389
|
+
for (const [key, value] of Object.entries(env)) if (value.startsWith(ISSUE_ENV_PREFIX)) delete env[key];
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** The names the World's twins issue (`$issue:<service>` in its config), taken out of the boot env until issued. */
|
|
1393
|
+
function takeIssued(env: Record<string, string>): Map<string, string> {
|
|
1394
|
+
const pending = new Map<string, string>();
|
|
1395
|
+
for (const [key, value] of Object.entries(env)) if (value.startsWith(ISSUE_ENV_PREFIX)) pending.set(key, value.slice(ISSUE_ENV_PREFIX.length));
|
|
1396
|
+
dropUnissued(env);
|
|
1397
|
+
return pending;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
|
|
1401
|
+
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1402
|
+
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1403
|
+
|
|
1404
|
+
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1405
|
+
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1406
|
+
* door, leaves the names unset and says so, rather than handing the application a fixture the twin would refuse. */
|
|
1407
|
+
async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>): Promise<void> {
|
|
1408
|
+
const wanted = [...pending].filter(([, id]) => id === service.id).map(([name]) => name);
|
|
1409
|
+
if (wanted.length === 0) return;
|
|
1410
|
+
for (const name of wanted) pending.delete(name);
|
|
1411
|
+
// the pack's door as it stands now (a door the pack fixed since the World was made reaches it), else the saved copy
|
|
1412
|
+
let door = service.issues;
|
|
1413
|
+
try { door = packFacts()[service.id]?.credentialDoor ?? door; } catch { /* no artifact: the saved copy */ }
|
|
1414
|
+
try {
|
|
1415
|
+
if (!door) throw new Error('its service names no credential door');
|
|
1416
|
+
if (!started.url) throw new Error('it has no URL to ask');
|
|
1417
|
+
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) });
|
|
1418
|
+
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
1419
|
+
const answer = (await res.json()) as Record<string, unknown>;
|
|
1420
|
+
for (const name of wanted) {
|
|
1421
|
+
const spec = door.fill[name] ?? '';
|
|
1422
|
+
// `file:<field>`: a credential the application reads from a file (Google's GOOGLE_APPLICATION_CREDENTIALS), written
|
|
1423
|
+
// beside the World's instance file, the name set to its path
|
|
1424
|
+
const field = spec.startsWith('file:') ? spec.slice('file:'.length) : spec;
|
|
1425
|
+
const value = answer[field];
|
|
1426
|
+
if (spec.startsWith('file:')) {
|
|
1427
|
+
if (value === undefined || value === null) throw new Error(`answered no ${field}`);
|
|
1428
|
+
// beside the World's instance and nowhere else: a credential file in the application's checkout could be committed
|
|
1429
|
+
if (!env.VOLTER_WORLD_INSTANCE) throw new Error(`cannot write ${name}'s credential file: the World's instance is unknown`);
|
|
1430
|
+
const dir = join(dirname(env.VOLTER_WORLD_INSTANCE), 'credentials');
|
|
1431
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1432
|
+
const path = join(dir, `${service.id}-${name.toLowerCase()}.json`);
|
|
1433
|
+
writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
1434
|
+
env[name] = path;
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (typeof value !== 'string') throw new Error(`answered no ${field}`);
|
|
1438
|
+
env[name] = value;
|
|
1439
|
+
}
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${error instanceof Error ? error.message : String(error)}); left unset\n`);
|
|
1442
|
+
for (const name of wanted) { delete env[name]; unissued.add(name); }
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1306
1446
|
function materializeConfig(config: WorldConfig, root: string): WorldConfig {
|
|
1307
1447
|
const services = config.services.map((service) => {
|
|
1308
1448
|
if (service.package === undefined) return service;
|
|
@@ -1358,7 +1498,7 @@ async function startColocatedServices(
|
|
|
1358
1498
|
const hostCli = siblingScript(import.meta.url, 'host-cli');
|
|
1359
1499
|
const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
|
|
1360
1500
|
const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
|
|
1361
|
-
const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage }> = [];
|
|
1501
|
+
const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage; sides: Awaited<ReturnType<typeof sidePortsFor>> }> = [];
|
|
1362
1502
|
for (const service of colocated) {
|
|
1363
1503
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1364
1504
|
const serviceDataDir = join(paths.data, service.id);
|
|
@@ -1373,8 +1513,9 @@ async function startColocatedServices(
|
|
|
1373
1513
|
const mounted = lockfileCheck(service, module);
|
|
1374
1514
|
const scenario = service.colocate!.scenarioPath;
|
|
1375
1515
|
const binding = managedDatabaseFor(vendorOfModule(module, service.id), worldEnv.VOLTER_WORLD_CONFIG, config.env ?? {});
|
|
1376
|
-
|
|
1377
|
-
|
|
1516
|
+
const sides = await sidePortsFor(vendorOfModule(module, service.id), [], allocatedPorts);
|
|
1517
|
+
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('|'));
|
|
1518
|
+
allocated.push({ service, port, mounted, sides });
|
|
1378
1519
|
}
|
|
1379
1520
|
|
|
1380
1521
|
const log = join(paths.logs, 'host.log');
|
|
@@ -1382,6 +1523,9 @@ async function startColocatedServices(
|
|
|
1382
1523
|
// services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
|
|
1383
1524
|
const env: Record<string, string> = { ...admittedEnv(config.stripEnv), ...(config.env ?? {}), ...worldEnv };
|
|
1384
1525
|
for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
1526
|
+
// the twins never read the application's own origin, nor a credential not yet issued
|
|
1527
|
+
for (const [key, value] of Object.entries(env)) if (value === APP_URL_ENV) delete env[key];
|
|
1528
|
+
dropUnissued(env);
|
|
1385
1529
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1386
1530
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
1387
1531
|
const { child, ended } = spawnRecorded('host', process.execPath, args, paths.root, env, log);
|
|
@@ -1398,7 +1542,7 @@ async function startColocatedServices(
|
|
|
1398
1542
|
const host = '127.0.0.1';
|
|
1399
1543
|
// Every twin's address is known once its port is allocated: the records and
|
|
1400
1544
|
// the environment they inject exist before the host reports its starts.
|
|
1401
|
-
const services = allocated.map(({ service, port, mounted }) => {
|
|
1545
|
+
const services = allocated.map(({ service, port, mounted, sides }) => {
|
|
1402
1546
|
const url = `http://${host}:${port}`;
|
|
1403
1547
|
return {
|
|
1404
1548
|
id: service.id,
|
|
@@ -1406,6 +1550,7 @@ async function startColocatedServices(
|
|
|
1406
1550
|
command,
|
|
1407
1551
|
port,
|
|
1408
1552
|
url,
|
|
1553
|
+
...sidesRecord(sides),
|
|
1409
1554
|
...(mounted.version !== undefined ? { resolvedVersion: mounted.version } : {}),
|
|
1410
1555
|
...(mounted.protocol !== undefined ? { protocol: mounted.protocol } : {}),
|
|
1411
1556
|
pid: child.pid ?? 0,
|
|
@@ -1419,7 +1564,7 @@ async function startColocatedServices(
|
|
|
1419
1564
|
});
|
|
1420
1565
|
const ready = (async (): Promise<void> => {
|
|
1421
1566
|
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1422
|
-
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1567
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => { try { return statSync(log).size; } catch { return 0; } }), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1423
1568
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1424
1569
|
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1425
1570
|
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
@@ -1435,7 +1580,7 @@ async function startColocatedServices(
|
|
|
1435
1580
|
// The host's receipt (this boot, every requested port) is published only
|
|
1436
1581
|
// after every factory bound its own port; a port another process held fails
|
|
1437
1582
|
// 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
|
|
1583
|
+
// for each port, so no twin waits out a settle (ADR 0014). A port that
|
|
1439
1584
|
// answers with another boot's identity is still refused.
|
|
1440
1585
|
const hostSettle = Promise.resolve('alive' as const);
|
|
1441
1586
|
await parallelStartup(allocated, async ({ service, port }) => {
|
|
@@ -1462,6 +1607,11 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1462
1607
|
abort?.throwIfAborted();
|
|
1463
1608
|
const root = resolve(options.root ?? process.cwd());
|
|
1464
1609
|
const found = loadWorldConfig(configId, root);
|
|
1610
|
+
// the facts of the World's installed packs, over the kernel's, for this process and (through the instance file) its own
|
|
1611
|
+
const packFactsPaths = packFactsFiles(found.config, root);
|
|
1612
|
+
for (const file of packFactsPaths) overlayPackFactsFile(file);
|
|
1613
|
+
// and this process's injector host rules, which `up`'s inert-injectEnv warning reads, know them too
|
|
1614
|
+
createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
1465
1615
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
1466
1616
|
const name = options.name ?? loaded.config.id;
|
|
1467
1617
|
const mode = options.mode ?? 'local';
|
|
@@ -1508,10 +1658,18 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1508
1658
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
1509
1659
|
const instanceFile = join(instance, 'instance.json');
|
|
1510
1660
|
let carriedOrigin: WorldInstance['origin'] | undefined;
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
const
|
|
1661
|
+
// the outputs this root's Worlds published: a branch and its base share the app's env file (whichever is current
|
|
1662
|
+
// writes it), so a file any of them last wrote is the World's own, never a custody file
|
|
1663
|
+
const siblings = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
1664
|
+
const priorOutputs = [name, ...siblings].flatMap((world) => {
|
|
1665
|
+
const file = world === name ? instanceFile : join(instanceDir(root, world), 'instance.json');
|
|
1666
|
+
if (!existsSync(file)) return [];
|
|
1667
|
+
try {
|
|
1668
|
+
const prior = JSON.parse(readFileSync(file, 'utf8')) as WorldInstance;
|
|
1669
|
+
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env) }] : [];
|
|
1670
|
+
} catch { return []; }
|
|
1671
|
+
});
|
|
1672
|
+
const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
|
|
1515
1673
|
|
|
1516
1674
|
if (loaded.config.resources) process.stderr.write(`World "${name}": config resources is deprecated; declarations are not reservations or enforced limits. Configure limits in the execution backend.\n`);
|
|
1517
1675
|
checkedOwner(options.owner);
|
|
@@ -1625,7 +1783,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1625
1783
|
// the world ref the dormant attach entry (`import '@volter/world-core/attach'`) arms the injector
|
|
1626
1784
|
// on — what a Bun process, which ignores NODE_OPTIONS, reads to reach the twins inside `run`
|
|
1627
1785
|
VOLTER_WORLD: name,
|
|
1628
|
-
...(
|
|
1786
|
+
...(networkOf(name, mode === 'sealed' ? { egress: [] } : loaded.config.network) ?? {}),
|
|
1629
1787
|
VOLTER_WORLD_MODE: mode,
|
|
1630
1788
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
1631
1789
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
@@ -1646,6 +1804,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1646
1804
|
};
|
|
1647
1805
|
env.NO_PROXY = `127.0.0.1,localhost${env.NO_PROXY || inheritedEnv.NO_PROXY ? `,${env.NO_PROXY ?? inheritedEnv.NO_PROXY}` : ''}`;
|
|
1648
1806
|
env.no_proxy = `127.0.0.1,localhost${env.no_proxy || inheritedEnv.no_proxy ? `,${env.no_proxy ?? inheritedEnv.no_proxy}` : ''}`;
|
|
1807
|
+
const issuing = takeIssued(env);
|
|
1808
|
+
const unissued = new Set<string>();
|
|
1649
1809
|
writeProxyEnv(root, name, env);
|
|
1650
1810
|
|
|
1651
1811
|
const worldInstance: WorldInstance = {
|
|
@@ -1655,6 +1815,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1655
1815
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
1656
1816
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
1657
1817
|
lifecycle: { owner: options.owner, log: lifecycleLogPath(root, name) },
|
|
1818
|
+
...(packFactsPaths.length ? { packFacts: packFactsPaths } : {}),
|
|
1658
1819
|
...(carriedOrigin ? { origin: carriedOrigin } : {}),
|
|
1659
1820
|
};
|
|
1660
1821
|
saveWorldInstance(worldInstance);
|
|
@@ -1685,8 +1846,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1685
1846
|
// The co-located host's twins are known by address once their ports are
|
|
1686
1847
|
// allocated. A twin serves and calls no other service while it starts,
|
|
1687
1848
|
// so a later twin declaration starts beside the host's startup; any other
|
|
1688
|
-
// service waits until every earlier one is ready, as declared (ADR
|
|
1689
|
-
|
|
1849
|
+
// service waits until every earlier one is ready, as declared (ADR 0015).
|
|
1850
|
+
// A co-located twin's issued credentials are asked of it once the host is ready: it answers no request before.
|
|
1851
|
+
let colocatedReady: (() => Promise<void>) | undefined;
|
|
1690
1852
|
if (isolation !== 'process') {
|
|
1691
1853
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
1692
1854
|
const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
@@ -1694,7 +1856,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1694
1856
|
services[started.id] = started;
|
|
1695
1857
|
Object.assign(env, started.env);
|
|
1696
1858
|
}
|
|
1697
|
-
colocatedReady =
|
|
1859
|
+
colocatedReady = async () => {
|
|
1860
|
+
await host.ready;
|
|
1861
|
+
for (const started of host.services) {
|
|
1862
|
+
const declared = colocated.find((s) => s.id === started.id);
|
|
1863
|
+
if (declared) await issueCredentials(declared, started, env, issuing, unissued);
|
|
1864
|
+
}
|
|
1865
|
+
writeProxyEnv(root, name, env);
|
|
1866
|
+
};
|
|
1698
1867
|
recordPidsSoFar();
|
|
1699
1868
|
writeProxyEnv(root, name, env);
|
|
1700
1869
|
}
|
|
@@ -1706,6 +1875,8 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1706
1875
|
// the twins undetected.
|
|
1707
1876
|
const attemptProxy = (): void => {
|
|
1708
1877
|
if (proxyForServices || (Object.keys(activeVendorMap(env)).length === 0 && env[WORLD_NETWORK_POLICY_ENV] === undefined)) return;
|
|
1878
|
+
// a host with no raw sockets (a browser tab) has nothing for the proxy to intercept; the injector holds the policy
|
|
1879
|
+
if (!hostHasRawEgress()) return;
|
|
1709
1880
|
if (!opensslAvailable()) {
|
|
1710
1881
|
if (mode === 'sealed' || loaded.config.network !== undefined) {
|
|
1711
1882
|
throw new Error(
|
|
@@ -1726,17 +1897,22 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1726
1897
|
};
|
|
1727
1898
|
for (const service of loaded.config.services) {
|
|
1728
1899
|
if (services[service.id]) continue; // already up co-located
|
|
1729
|
-
if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady; colocatedReady = undefined; }
|
|
1900
|
+
if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady(); colocatedReady = undefined; }
|
|
1730
1901
|
attemptProxy();
|
|
1731
1902
|
boot.signal.throwIfAborted();
|
|
1732
1903
|
const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1733
1904
|
services[started.id] = started;
|
|
1734
1905
|
recordPidsSoFar();
|
|
1735
1906
|
Object.assign(env, started.env);
|
|
1907
|
+
await issueCredentials(service, started, env, issuing, unissued);
|
|
1736
1908
|
writeProxyEnv(root, name, env);
|
|
1737
1909
|
}
|
|
1738
|
-
if (colocatedReady) await colocatedReady;
|
|
1910
|
+
if (colocatedReady) await colocatedReady();
|
|
1739
1911
|
detachBootSignals();
|
|
1912
|
+
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
1913
|
+
if (issuing.size > 0) process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
|
|
1914
|
+
for (const [name] of issuing) unissued.add(name);
|
|
1915
|
+
if (unissued.size > 0) { env[UNISSUED_ENV] = JSON.stringify([...unissued].sort()); writeProxyEnv(root, name, env); }
|
|
1740
1916
|
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
1741
1917
|
// only lands in `env` once IT has started, so a check that only runs "before starting the
|
|
1742
1918
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
@@ -2937,7 +3113,15 @@ export function urlWorld(name: string, options: { service?: string; root?: strin
|
|
|
2937
3113
|
if (options.service === undefined) {
|
|
2938
3114
|
return Object.values(status.services)
|
|
2939
3115
|
.filter((service) => service.url)
|
|
2940
|
-
.
|
|
3116
|
+
.flatMap((service) => [{ id: service.id, url: service.url! }, ...Object.entries(service.sides ?? {}).map(([side, at]) => ({ id: `${service.id}.${side}`, url: at.url }))]);
|
|
3117
|
+
}
|
|
3118
|
+
// a side port by `<service>.<name>` (the SMTP twin's mailbox: smtp.inspect)
|
|
3119
|
+
const dot = options.service.lastIndexOf('.');
|
|
3120
|
+
if (!status.services[options.service] && dot > 0) {
|
|
3121
|
+
const owner = status.services[options.service.slice(0, dot)];
|
|
3122
|
+
const side = owner?.sides?.[options.service.slice(dot + 1)];
|
|
3123
|
+
if (side) return [{ id: options.service, url: side.url }];
|
|
3124
|
+
if (owner) throw new Error(`Service "${owner.id}" has no side port "${options.service.slice(dot + 1)}"; it has: ${Object.keys(owner.sides ?? {}).join(', ') || 'none'}`);
|
|
2941
3125
|
}
|
|
2942
3126
|
const service = status.services[options.service];
|
|
2943
3127
|
if (!service) {
|
|
@@ -3052,26 +3236,42 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false): {
|
|
|
3052
3236
|
const proxy = ensureWorldProxy(name, root, [admittedEnv(stripEnvOf(status.env)), status.env]);
|
|
3053
3237
|
if (proxy?.url) {
|
|
3054
3238
|
proxyEnv = proxy.env;
|
|
3055
|
-
|
|
3239
|
+
// a host with no raw sockets (a browser tab) runs no proxy: the injector holds the policy there, as at `up`
|
|
3240
|
+
} else if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy is unavailable; refusing to run the command`);
|
|
3056
3241
|
} catch (error) {
|
|
3057
3242
|
if (error instanceof CaTrustError) throw error;
|
|
3058
|
-
if (sealed) 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)})`);
|
|
3243
|
+
if (sealed && hostHasRawEgress()) 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)})`);
|
|
3059
3244
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3060
3245
|
}
|
|
3061
|
-
return { instance: status, env: { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
3062
|
-
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' } };
|
|
3246
|
+
return { instance: status, env: withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
3247
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' }) };
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
3251
|
+
* unset (the application's own env files decide) and the command is told how to record it: never a guess. */
|
|
3252
|
+
export function withAppUrl<E extends Record<string, string | undefined>>(name: string, root: string, env: E): E {
|
|
3253
|
+
const names = Object.keys(env).filter((key) => env[key] === APP_URL_ENV);
|
|
3254
|
+
if (names.length === 0) return env;
|
|
3255
|
+
const out: Record<string, string | undefined> = { ...env };
|
|
3256
|
+
const recorded = readAppUrl(name, { root });
|
|
3257
|
+
for (const key of names) {
|
|
3258
|
+
if (recorded) out[key] = recorded.url.replace(/\/+$/, '');
|
|
3259
|
+
else delete out[key];
|
|
3260
|
+
}
|
|
3261
|
+
if (!recorded) 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`);
|
|
3262
|
+
return out as E;
|
|
3063
3263
|
}
|
|
3064
3264
|
|
|
3065
3265
|
/** Runs the command in the world's env in the foreground and resolves to its exit code. The child is
|
|
3066
3266
|
* awaited, not blocked on: a runtime that runs no synchronous child (the browser engine) still runs
|
|
3067
3267
|
* this one, and the caller sees no difference — stdio is inherited either way. */
|
|
3068
|
-
export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean } = {}): Promise<number> {
|
|
3268
|
+
export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean; env?: Record<string, string> } = {}): Promise<number> {
|
|
3069
3269
|
if (command.length === 0) throw new Error('Missing command after --');
|
|
3070
3270
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
3071
3271
|
return new Promise((resolveExit) => {
|
|
3072
3272
|
const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
|
|
3073
3273
|
cwd: resolve(options.cwd ?? root),
|
|
3074
|
-
env: attached.env,
|
|
3274
|
+
env: { ...attached.env, ...(options.env ?? {}) },
|
|
3075
3275
|
stdio: 'inherit',
|
|
3076
3276
|
});
|
|
3077
3277
|
const forward = (signal: NodeJS.Signals) => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
|
|
@@ -3275,7 +3475,7 @@ export function worldShellEnv(name: string, root = process.cwd()): { shell: stri
|
|
|
3275
3475
|
return {
|
|
3276
3476
|
shell: status.env.SHELL || admittedEnv(stripEnvOf(status.env)).SHELL || '/bin/bash',
|
|
3277
3477
|
cwd: resolve(root),
|
|
3278
|
-
env: { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string
|
|
3478
|
+
env: withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>),
|
|
3279
3479
|
};
|
|
3280
3480
|
}
|
|
3281
3481
|
|
|
@@ -3290,7 +3490,9 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
3290
3490
|
const resolvedRoot = resolve(root);
|
|
3291
3491
|
let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
|
|
3292
3492
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
3293
|
-
if (
|
|
3493
|
+
if (!hostHasRawEgress()) {
|
|
3494
|
+
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`);
|
|
3495
|
+
} else if (opensslAvailable()) {
|
|
3294
3496
|
try {
|
|
3295
3497
|
proxy = await startRedirectProxy({ env, trustEnv: [admittedEnv(stripEnvOf(env)), env], tlsDir: tlsDir(resolvedRoot, name) });
|
|
3296
3498
|
Object.assign(env, proxy.proxyEnv());
|
|
@@ -3355,7 +3557,7 @@ function currentProxyState(root: string, name: string): ProxyState | null {
|
|
|
3355
3557
|
|
|
3356
3558
|
function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInputs): { url: string; caCertPath: string; env: Record<string, string> } | null {
|
|
3357
3559
|
const resolvedRoot = resolve(root);
|
|
3358
|
-
if (!opensslAvailable()) return null;
|
|
3560
|
+
if (!hostHasRawEgress() || !opensslAvailable()) return null;
|
|
3359
3561
|
const existing = currentProxyState(resolvedRoot, name);
|
|
3360
3562
|
if (existing && isAlive(existing.pid)) {
|
|
3361
3563
|
return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath, caller, existing.guestUrl) };
|
|
@@ -3367,7 +3569,7 @@ function ensureWorldProxyUnlocked(name: string, root: string, caller: CaTrustInp
|
|
|
3367
3569
|
|
|
3368
3570
|
function ensureWorldProxyFromEnv(name: string, root: string, env: Record<string, string>, boot: BootContext): { url: string; caCertPath: string; env: Record<string, string> } | null {
|
|
3369
3571
|
const resolvedRoot = resolve(root);
|
|
3370
|
-
if (!opensslAvailable()) return null;
|
|
3572
|
+
if (!hostHasRawEgress() || !opensslAvailable()) return null;
|
|
3371
3573
|
writeProxyEnv(resolvedRoot, name, env);
|
|
3372
3574
|
const existing = currentProxyState(resolvedRoot, name);
|
|
3373
3575
|
if (existing && isAlive(existing.pid)) {
|
|
@@ -3443,6 +3645,9 @@ function startProxyDaemonOwned(name: string, root: string, caCertPath: string, e
|
|
|
3443
3645
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
3444
3646
|
}
|
|
3445
3647
|
retire();
|
|
3648
|
+
// the proxy is confirmed gone: ownership goes back to the World's running processes, as on success (it was retained
|
|
3649
|
+
// for cleanup only while the child's fate was unknown), so the World stays running unsealed, as the warning says
|
|
3650
|
+
if (!boot) handoffWorldLifecycle(root, name, readPids(join(instanceDir(root, name), 'pids')));
|
|
3446
3651
|
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`);
|
|
3447
3652
|
return { url: '', caCertPath, env: {} };
|
|
3448
3653
|
} catch (error) {
|
|
@@ -3553,12 +3758,13 @@ export function activateScript(name: string, root = process.cwd()): string {
|
|
|
3553
3758
|
if (proxy && proxy.url) {
|
|
3554
3759
|
env = { ...baseEnv, ...proxy.env };
|
|
3555
3760
|
proxyNote = ` (ambient proxy ${proxy.url})`;
|
|
3556
|
-
} else if (sealed) throw new Error('attachment proxy is unavailable');
|
|
3761
|
+
} else if (sealed && hostHasRawEgress()) throw new Error('attachment proxy is unavailable');
|
|
3557
3762
|
} catch (error) {
|
|
3558
3763
|
if (error instanceof CaTrustError) throw error;
|
|
3559
|
-
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
|
|
3764
|
+
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
|
|
3560
3765
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
3561
3766
|
}
|
|
3767
|
+
env = withAppUrl(name, root, env);
|
|
3562
3768
|
const keys = Object.keys(env).sort();
|
|
3563
3769
|
const lines: string[] = [
|
|
3564
3770
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|