@volter/world-runtime 3.0.165 → 3.0.167
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 +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +4 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +309 -58
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +5 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +270 -44
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- package/src/service-recorder.ts +9 -9
package/dist/src/runtime.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { isScalarCredentialField, withCurrentCredentialDoor } from "./credential-door.js";
|
|
1
2
|
import { errorMessage } from "./error-message.js";
|
|
3
|
+
import { recorderEnvironment, recordedApplicationEnvironment } from "./service-environment.js";
|
|
2
4
|
import { materializeWorldBrowserFiles } from "./browser-files.js";
|
|
3
5
|
import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
|
|
4
6
|
import { offSignal } from "./signals.js";
|
|
5
7
|
import { parallelStartup } from "./startup.js";
|
|
6
|
-
import { waitForHostReady } from "./host-ready.js";
|
|
8
|
+
import { startedTwins, waitForHostReady } from "./host-ready.js";
|
|
7
9
|
import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
|
|
8
10
|
import { packCli, packEntry } from "./catalog.js";
|
|
9
11
|
import { vendorOfPackModule } from '@volter/world-core/runtime-state';
|
|
@@ -32,7 +34,7 @@ import { loadWorldConfig } from "./configs.js";
|
|
|
32
34
|
import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
33
35
|
import { overlayPackFactsFile, resolveServicePackage } from "./catalog.js";
|
|
34
36
|
import { fakeEnvValue } from "./fixture-env.js";
|
|
35
|
-
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from "./schema.js";
|
|
37
|
+
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV, assertRequiredCredentialBindings } from "./schema.js";
|
|
36
38
|
import { readAppUrl } from "./app-url.js";
|
|
37
39
|
import { inertInjectEnvWarnings } from "./inject-map.js";
|
|
38
40
|
import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, tearDownCa } from "./proxy-env.js";
|
|
@@ -64,16 +66,14 @@ export function worldEventsPath(logs) {
|
|
|
64
66
|
* its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
|
|
65
67
|
* file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
|
|
66
68
|
* `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
|
|
67
|
-
function spawnRecorded(serviceId, command, args, cwd, env, log, boot) {
|
|
69
|
+
function spawnRecorded(serviceId, command, args, cwd, env, log, boot, required) {
|
|
68
70
|
const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
|
|
71
|
+
const recorderEnv = recorderEnvironment(env, serviceId, log, logDir, worldEventsPath(dirname(log)));
|
|
72
|
+
// The recorder adds and removes its own names. Validate the same final child environment before opening a log or spawning.
|
|
73
|
+
if (required !== undefined)
|
|
74
|
+
assertRequiredCredentials(required, recordedApplicationEnvironment(recorderEnv), `service ${serviceId}'s recorded application environment`);
|
|
69
75
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
70
76
|
const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
|
|
71
|
-
const recorderEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
|
|
72
|
-
VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: worldEventsPath(dirname(log)) };
|
|
73
|
-
if (recorderEnv.NODE_OPTIONS !== undefined) {
|
|
74
|
-
recorderEnv.VOLTER_WORLD_SERVICE_NODE_OPTIONS = recorderEnv.NODE_OPTIONS;
|
|
75
|
-
delete recorderEnv.NODE_OPTIONS;
|
|
76
|
-
}
|
|
77
77
|
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'service-recorder'), command, ...args], { windowsHide: true,
|
|
78
78
|
cwd,
|
|
79
79
|
env: recorderEnv,
|
|
@@ -563,9 +563,12 @@ function networkOf(name, own, recorded, parentEnv = process.env) {
|
|
|
563
563
|
return undefined;
|
|
564
564
|
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
|
|
565
565
|
}
|
|
566
|
-
/** Strip the publication envelope before combining permissions (the config shape admits only these keys).
|
|
566
|
+
/** Strip the publication envelope before combining permissions (the config shape admits only these keys). Every
|
|
567
|
+
* permission the policy carries stays: a dropped `writes` or `publicReads` would read as the default when combined,
|
|
568
|
+
* and a build World inside a closed one would read the public build origins its parent may not (sealing never widens). */
|
|
567
569
|
function networkPermission(policy) {
|
|
568
|
-
return { egress: policy.egress,
|
|
570
|
+
return { egress: policy.egress, ...(policy.writes !== undefined ? { writes: policy.writes } : {}), phase: policy.phase, passthrough: policy.passthrough,
|
|
571
|
+
...(policy.publicReads !== undefined ? { publicReads: policy.publicReads } : {}), ...(policy.ownLoopback === false ? { ownLoopback: false } : {}) };
|
|
569
572
|
}
|
|
570
573
|
/** The boot permission, never a fallback to a mutable config. */
|
|
571
574
|
export function recordedWorldNetwork(instance) {
|
|
@@ -701,7 +704,13 @@ function readWorldBootRecordLocked(name, root) {
|
|
|
701
704
|
throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`);
|
|
702
705
|
}
|
|
703
706
|
}
|
|
704
|
-
|
|
707
|
+
/** What Windows itself needs in every process: without these, Winsock and DLL loading fail, the temp directory falls
|
|
708
|
+
* back to C:\Windows\Temp and tools lose their profile folders. They hold no caller credential, so a strip (even
|
|
709
|
+
* `*`) never removes them on win32. Windows names are case-insensitive. */
|
|
710
|
+
const WINDOWS_BASELINE_ENV = new Set(['systemroot', 'windir', 'systemdrive', 'comspec', 'pathext', 'temp', 'tmp', 'userprofile',
|
|
711
|
+
'appdata', 'localappdata', 'programdata', 'programfiles', 'programfiles(x86)', 'programw6432', 'commonprogramfiles',
|
|
712
|
+
'commonprogramfiles(x86)', 'commonprogramw6432', 'homedrive', 'homepath', 'os', 'processor_architecture', 'number_of_processors']);
|
|
713
|
+
export function admittedEnv(strip, env = process.env, platform = process.platform) {
|
|
705
714
|
const out = {};
|
|
706
715
|
const rules = (strip ?? []).map((s) => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
|
|
707
716
|
for (const [k, v] of Object.entries(env)) {
|
|
@@ -711,6 +720,10 @@ export function admittedEnv(strip, env = process.env) {
|
|
|
711
720
|
// whichever World happened to launch this operator process.
|
|
712
721
|
if (k === WORLD_NETWORK_POLICY_ENV)
|
|
713
722
|
continue;
|
|
723
|
+
if (platform === 'win32' && WINDOWS_BASELINE_ENV.has(k.toLowerCase())) {
|
|
724
|
+
out[k] = v;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
714
727
|
if (rules.some((r) => ('prefix' in r ? k.startsWith(r.prefix) : k === r.name)))
|
|
715
728
|
continue;
|
|
716
729
|
out[k] = v;
|
|
@@ -758,7 +771,7 @@ function savedStripEnvOf(instanceEnv) {
|
|
|
758
771
|
return ['*'];
|
|
759
772
|
}
|
|
760
773
|
}
|
|
761
|
-
function serviceProcessEnv(config, service, worldEnv, port) {
|
|
774
|
+
function serviceProcessEnv(config, service, worldEnv, port, twin = service.type === 'twin') {
|
|
762
775
|
const env = {
|
|
763
776
|
...admittedEnv(config.stripEnv),
|
|
764
777
|
...(config.env ?? {}),
|
|
@@ -772,6 +785,13 @@ function serviceProcessEnv(config, service, worldEnv, port) {
|
|
|
772
785
|
delete env[key];
|
|
773
786
|
}
|
|
774
787
|
dropUnissued(env);
|
|
788
|
+
// NODE_ENV is the application's mode: a twin is the World's own server, run as built (React's production server build
|
|
789
|
+
// crashes Bun 1.3.11 at load, so Twenty's NODE_ENV=production stopped every twin with screens); a service's own env
|
|
790
|
+
// may still set it
|
|
791
|
+
// a twin is a service that runs a pack: declared `type: 'twin'`, or untyped and spawning a pack's module (never an
|
|
792
|
+
// untyped command of the application's)
|
|
793
|
+
if (twin)
|
|
794
|
+
delete env.NODE_ENV;
|
|
775
795
|
const out = { ...env, ...(service.env ?? {}) };
|
|
776
796
|
// the application's own origin: the World's own application service is reached where it listens (the URL
|
|
777
797
|
// `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
|
|
@@ -1288,13 +1308,15 @@ export function externalServiceEnv(service, paths, worldEnv, stripEnv) {
|
|
|
1288
1308
|
delete env[key];
|
|
1289
1309
|
return env;
|
|
1290
1310
|
}
|
|
1291
|
-
async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
1311
|
+
async function startExternalService(service, paths, worldEnv, required, stripEnv, boot) {
|
|
1292
1312
|
const external = service.external;
|
|
1293
1313
|
if (!external)
|
|
1294
1314
|
throw new Error(`External service "${service.id}" is missing its external config`);
|
|
1295
1315
|
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1296
1316
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1297
1317
|
const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
|
|
1318
|
+
if (!ownInfrastructureService(service))
|
|
1319
|
+
assertRequiredCredentials(required, env, `external service ${service.id}`);
|
|
1298
1320
|
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
1299
1321
|
if (!external.up[0] || !commandExists(external.up[0])) {
|
|
1300
1322
|
throw new Error(`External service "${service.id}": \`${external.up[0] ?? ''}\` not found on PATH (the up command's tool is not installed)`);
|
|
@@ -1412,9 +1434,11 @@ async function trackBootingChild(child, boot, birth) {
|
|
|
1412
1434
|
boot.identities[child.pid] = identity.start;
|
|
1413
1435
|
boot.recordPids();
|
|
1414
1436
|
}
|
|
1415
|
-
async function startService(config, service, worldEnv, paths, allocatedPorts, boot) {
|
|
1437
|
+
async function startService(config, service, worldEnv, paths, allocatedPorts, boot, required) {
|
|
1438
|
+
if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
|
|
1439
|
+
assertRequiredCredentials(required, worldEnv, `service ${service.id}`);
|
|
1416
1440
|
if (service.type === 'external')
|
|
1417
|
-
return startExternalService(service, paths, worldEnv, config.stripEnv, boot);
|
|
1441
|
+
return startExternalService(service, paths, worldEnv, required, config.stripEnv, boot);
|
|
1418
1442
|
if (!service.command)
|
|
1419
1443
|
throw new Error(`Service "${service.id}" must define command`);
|
|
1420
1444
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
@@ -1439,7 +1463,7 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1439
1463
|
if (service.rootArg !== false)
|
|
1440
1464
|
args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
1441
1465
|
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1442
|
-
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
1466
|
+
const env = serviceProcessEnv(config, service, worldEnv, port, service.type === 'twin' || (service.type === undefined && spawnedModule !== undefined));
|
|
1443
1467
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1444
1468
|
// Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
|
|
1445
1469
|
// (e.g. a sandbox) WITHOUT replacing the world's injector. Paths resolve to absolute against the
|
|
@@ -1452,7 +1476,9 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1452
1476
|
// The service runs under the service recorder, which outlives it to write its end into its own log: `up`
|
|
1453
1477
|
// returns once the World is ready, so nothing else is left to see a service stop.
|
|
1454
1478
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1455
|
-
|
|
1479
|
+
if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
|
|
1480
|
+
assertRequiredCredentials(required, env, `service ${service.id}'s effective environment`);
|
|
1481
|
+
const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot, (service.type ?? 'twin') === 'twin' ? undefined : required);
|
|
1456
1482
|
child.once('error', error => boot.fail(error));
|
|
1457
1483
|
child.unref();
|
|
1458
1484
|
await trackBootingChild(child, boot, identity);
|
|
@@ -1674,25 +1700,110 @@ function takeIssued(env) {
|
|
|
1674
1700
|
/** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
|
|
1675
1701
|
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1676
1702
|
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1703
|
+
function requiredCredentialClaims(config) {
|
|
1704
|
+
const required = new Map();
|
|
1705
|
+
for (const service of config.services)
|
|
1706
|
+
for (const name of service.issues?.required ?? [])
|
|
1707
|
+
required.set(name, { service: service.id, value: undefined });
|
|
1708
|
+
return required;
|
|
1709
|
+
}
|
|
1710
|
+
/** Bind names and owners to the actual values retained in instance.env; the hash is not vendor authentication. */
|
|
1711
|
+
function requiredCredentialValuesDigest(required) {
|
|
1712
|
+
const rows = [...required.keys()].sort().map(name => {
|
|
1713
|
+
const claim = required.get(name);
|
|
1714
|
+
if (claim.value === undefined)
|
|
1715
|
+
throw new Error(`${name}: no issued value exists to retain`);
|
|
1716
|
+
return [name, claim.service, claim.value];
|
|
1717
|
+
});
|
|
1718
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
1719
|
+
}
|
|
1720
|
+
/** New boots retain their required owners even if source config later changes; legacy optional records gain no proof. */
|
|
1721
|
+
function recordedRequiredCredentialClaims(instance) {
|
|
1722
|
+
const required = new Map();
|
|
1723
|
+
if (!Object.hasOwn(instance, 'requiredCredentials'))
|
|
1724
|
+
throw new Error('World boot has no retained credential requirement declaration; finish teardown with its owning runtime and boot with this runtime before attachment');
|
|
1725
|
+
const raw = instance.requiredCredentials;
|
|
1726
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1727
|
+
throw new Error('World required credential record is invalid');
|
|
1728
|
+
const record = raw;
|
|
1729
|
+
if (Object.keys(record).some(key => !['version', 'owners', 'valuesSha256'].includes(key))
|
|
1730
|
+
|| !Object.hasOwn(record, 'version') || record.version !== 1 || !Object.hasOwn(record, 'owners') || record.owners === null
|
|
1731
|
+
|| typeof record.owners !== 'object' || Array.isArray(record.owners))
|
|
1732
|
+
throw new Error('World required credential record has an unsupported version or shape');
|
|
1733
|
+
if (!instance.env || typeof instance.env !== 'object' || Array.isArray(instance.env)
|
|
1734
|
+
|| !instance.services || typeof instance.services !== 'object' || Array.isArray(instance.services))
|
|
1735
|
+
throw new Error('World required credential record lacks its retained environment or services');
|
|
1736
|
+
for (const [name, owner] of Object.entries(record.owners)) {
|
|
1737
|
+
if (!isEnvironmentName(name) || typeof owner !== 'string' || !owner || !Object.hasOwn(instance.services, owner))
|
|
1738
|
+
throw new Error(`World required credential owner is invalid for ${name}`);
|
|
1739
|
+
const service = instance.services[owner];
|
|
1740
|
+
if (!service || service.id !== owner || service.type !== 'twin')
|
|
1741
|
+
throw new Error(`World required credential ${name} names no twin owner`);
|
|
1742
|
+
const value = instance.env[name];
|
|
1743
|
+
if (!Object.hasOwn(instance.env, name) || typeof value !== 'string'
|
|
1744
|
+
|| value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV)
|
|
1745
|
+
throw new Error(`World required credential ${name} has no retained scalar value`);
|
|
1746
|
+
required.set(name, { service: owner, value });
|
|
1747
|
+
}
|
|
1748
|
+
if (!Object.hasOwn(record, 'valuesSha256')) {
|
|
1749
|
+
if (required.size)
|
|
1750
|
+
throw new Error('World required credential issuance has not completed; refusing attachment');
|
|
1751
|
+
return required;
|
|
1752
|
+
}
|
|
1753
|
+
if (typeof record.valuesSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(record.valuesSha256)
|
|
1754
|
+
|| record.valuesSha256 !== requiredCredentialValuesDigest(required))
|
|
1755
|
+
throw new Error('World required credential names, owners or retained values changed; refusing attachment');
|
|
1756
|
+
return required;
|
|
1757
|
+
}
|
|
1758
|
+
/** Capture the validated boot's values before an owned executor applies its own environment layers. */
|
|
1759
|
+
export function worldRequiredCredentialGate(instance) {
|
|
1760
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1761
|
+
return (env, boundary) => assertRequiredCredentials(required, env, boundary);
|
|
1762
|
+
}
|
|
1763
|
+
/** These transports do not carry required ownership/value authority; refuse instead of exporting an optional-only rendering. */
|
|
1764
|
+
export function refuseRequiredCredentialTransport(instance, transport, code) {
|
|
1765
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1766
|
+
if (required.size) {
|
|
1767
|
+
const error = new Error(`World ${instance.name}: ${transport} does not carry required credential bindings (${[...required.keys()].join(', ')}); use local env attachment`);
|
|
1768
|
+
throw code ? Object.assign(error, { code }) : error;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
/** Check the actual response value, then the environment this boundary will use; never print credential values. */
|
|
1772
|
+
function assertRequiredCredentials(required, env, boundary) {
|
|
1773
|
+
const failures = [];
|
|
1774
|
+
for (const [name, claim] of required) {
|
|
1775
|
+
if (claim.value === undefined)
|
|
1776
|
+
failures.push(new Error(`${name}: ${claim.service} has not issued its required scalar credential`));
|
|
1777
|
+
else if (!Object.hasOwn(env, name) || env[name] !== claim.value)
|
|
1778
|
+
failures.push(new Error(`${name}: its issued value is missing or overridden at ${boundary}`));
|
|
1779
|
+
}
|
|
1780
|
+
if (failures.length)
|
|
1781
|
+
throw new AggregateError(failures, `World required credential gate refused at ${boundary}: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1782
|
+
}
|
|
1677
1783
|
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1678
1784
|
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1679
|
-
* door, leaves
|
|
1680
|
-
async function issueCredentials(service, started, env, pending, unissued, controlRoot, times) {
|
|
1785
|
+
* door, leaves optional names unset and says so. Required scalar names fail the boot with their original reasons. */
|
|
1786
|
+
async function issueCredentials(service, started, env, pending, unissued, controlRoot, claims, fresh, times) {
|
|
1681
1787
|
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1682
1788
|
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1683
1789
|
if (times) {
|
|
1684
1790
|
times.names = wanted.length;
|
|
1685
1791
|
}
|
|
1792
|
+
const required = new Set(service.issues?.required ?? []);
|
|
1793
|
+
const failures = [];
|
|
1794
|
+
for (const name of required) {
|
|
1795
|
+
if (!wanted.includes(name) || direct.has(name) || claims.get(name)?.service !== service.id)
|
|
1796
|
+
failures.push(new Error(`${name}: its exact $issue:${service.id} scalar request is absent at issuance`));
|
|
1797
|
+
}
|
|
1798
|
+
if (failures.length)
|
|
1799
|
+
throw new AggregateError(failures, `World service ${service.id}: required credential requests refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1686
1800
|
if (wanted.length === 0)
|
|
1687
1801
|
return;
|
|
1688
1802
|
for (const name of wanted)
|
|
1689
1803
|
pending.delete(name);
|
|
1690
|
-
//
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
door = packFacts()[service.id]?.credentialDoor ?? door;
|
|
1694
|
-
}
|
|
1695
|
-
catch { /* no artifact: the saved copy */ }
|
|
1804
|
+
// Revalidate aliases at the actual issuance boundary. A missing current pack door cannot revive a saved issuer.
|
|
1805
|
+
const door = (service.type ?? 'twin') === 'twin'
|
|
1806
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor).issues : service.issues;
|
|
1696
1807
|
if (times)
|
|
1697
1808
|
times.door = performance.now();
|
|
1698
1809
|
try {
|
|
@@ -1700,8 +1811,12 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1700
1811
|
throw new Error('its service names no credential door');
|
|
1701
1812
|
if (!started.url)
|
|
1702
1813
|
throw new Error('it has no URL to ask');
|
|
1703
|
-
// A boot's identity is existing account state, not an application write to deploy
|
|
1704
|
-
|
|
1814
|
+
// A boot's identity is existing account state, not an application write to deploy: asked inside the twin's
|
|
1815
|
+
// placeholder-pull window, as the seed is (architecture, the descriptor's credentialDoor), on a fresh World's first
|
|
1816
|
+
// issuance only: a resumed World's door answers what it issued before, and its branch keeps its own writes; a twin
|
|
1817
|
+
// with a root is not seeded, and its door's writes stay its own
|
|
1818
|
+
const seeds = fresh && !service.root;
|
|
1819
|
+
const enclosingSeed = !seeds || placeholderPullActive(service.id, controlRoot);
|
|
1705
1820
|
if (!enclosingSeed)
|
|
1706
1821
|
beginPlaceholderPull(service.id, controlRoot);
|
|
1707
1822
|
let answer;
|
|
@@ -1828,7 +1943,12 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1828
1943
|
continue;
|
|
1829
1944
|
}
|
|
1830
1945
|
const field = spec;
|
|
1946
|
+
if (required.has(name) && (!isScalarCredentialField(field) || answer === null || typeof answer !== 'object'
|
|
1947
|
+
|| Array.isArray(answer) || !Object.hasOwn(answer, field)))
|
|
1948
|
+
throw new Error(`answered no own scalar ${field}`);
|
|
1831
1949
|
const value = answer[field];
|
|
1950
|
+
if (required.has(name) && typeof value === 'string' && (value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV))
|
|
1951
|
+
throw new Error(`answered an interpreted environment marker for ${field}`);
|
|
1832
1952
|
if (typeof value !== 'string')
|
|
1833
1953
|
throw new Error(`answered no ${field}`);
|
|
1834
1954
|
if (direct.has(name)) {
|
|
@@ -1842,11 +1962,23 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1842
1962
|
}
|
|
1843
1963
|
else
|
|
1844
1964
|
env[name] = value;
|
|
1965
|
+
if (required.has(name)) {
|
|
1966
|
+
const claim = claims.get(name);
|
|
1967
|
+
if (!claim || claim.service !== service.id)
|
|
1968
|
+
throw new Error('its required credential owner changed');
|
|
1969
|
+
claim.value = value;
|
|
1970
|
+
}
|
|
1845
1971
|
}
|
|
1846
1972
|
catch (error) {
|
|
1847
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer
|
|
1973
|
+
process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer?.codex_unavailable === 'string' ? `; the twin says: ${answer.codex_unavailable.slice(0, 300)}` : ''}); left unset\n`);
|
|
1848
1974
|
delete env[name];
|
|
1849
1975
|
unissued.add(name);
|
|
1976
|
+
if (required.has(name)) {
|
|
1977
|
+
const claim = claims.get(name);
|
|
1978
|
+
if (claim)
|
|
1979
|
+
claim.value = undefined;
|
|
1980
|
+
failures.push(new Error(`${name}: ${service.id} did not issue its required credential (${errorMessage(error)})`, { cause: error }));
|
|
1981
|
+
}
|
|
1850
1982
|
}
|
|
1851
1983
|
}
|
|
1852
1984
|
if (started.url) {
|
|
@@ -1862,9 +1994,17 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1862
1994
|
delete env[name];
|
|
1863
1995
|
unissued.add(name);
|
|
1864
1996
|
}
|
|
1997
|
+
for (const name of required) {
|
|
1998
|
+
const claim = claims.get(name);
|
|
1999
|
+
if (claim)
|
|
2000
|
+
claim.value = undefined;
|
|
2001
|
+
failures.push(new Error(`${name}: ${service.id}'s required credential ask failed (${errorMessage(error)})`, { cause: error }));
|
|
2002
|
+
}
|
|
1865
2003
|
}
|
|
1866
2004
|
if (times)
|
|
1867
2005
|
times.filled = performance.now();
|
|
2006
|
+
if (failures.length)
|
|
2007
|
+
throw new AggregateError(failures, `World service ${service.id}: required credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1868
2008
|
}
|
|
1869
2009
|
function materializeConfig(config, root) {
|
|
1870
2010
|
const services = config.services.map((service) => {
|
|
@@ -1976,6 +2116,8 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1976
2116
|
if (value === APP_URL_ENV)
|
|
1977
2117
|
delete env[key];
|
|
1978
2118
|
dropUnissued(env);
|
|
2119
|
+
// the colocated twins' host runs the World's own servers: the application's NODE_ENV is not theirs (serviceProcessEnv)
|
|
2120
|
+
delete env.NODE_ENV;
|
|
1979
2121
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1980
2122
|
const spawnedAt = Date.now();
|
|
1981
2123
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
@@ -2014,12 +2156,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
2014
2156
|
});
|
|
2015
2157
|
const ready = (async () => {
|
|
2016
2158
|
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
2017
|
-
const hostTimes = await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () =>
|
|
2018
|
-
return statSync(log).size;
|
|
2019
|
-
}
|
|
2020
|
-
catch {
|
|
2021
|
-
return 0;
|
|
2022
|
-
} }), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
2159
|
+
const hostTimes = await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () => startedTwins(log)), ended, boot, 'the co-located host', log).catch(async (error) => {
|
|
2023
2160
|
if (undeadPids([child.pid]).length)
|
|
2024
2161
|
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
2025
2162
|
try {
|
|
@@ -2144,6 +2281,11 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2144
2281
|
if (packFactsPaths.length)
|
|
2145
2282
|
createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
2146
2283
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
2284
|
+
// Before any output claim or lifecycle write, every saved alias fits the actual current pack.
|
|
2285
|
+
loaded.config.services = loaded.config.services.map(service => (service.type ?? 'twin') === 'twin'
|
|
2286
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor) : service);
|
|
2287
|
+
assertRequiredCredentialBindings(loaded.config, found.path);
|
|
2288
|
+
const required = requiredCredentialClaims(loaded.config);
|
|
2147
2289
|
const name = options.name ?? loaded.config.id;
|
|
2148
2290
|
const mode = options.mode ?? 'local';
|
|
2149
2291
|
assertSafeWorldName(name);
|
|
@@ -2252,8 +2394,6 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2252
2394
|
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
2253
2395
|
if (existsSync(instanceFile)) {
|
|
2254
2396
|
let prior;
|
|
2255
|
-
if (options.keepState)
|
|
2256
|
-
prior = readWorldBootRecordLocked(name, root);
|
|
2257
2397
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
2258
2398
|
const receiptPath = join(instance, 'stopped.json');
|
|
2259
2399
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
@@ -2261,6 +2401,17 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2261
2401
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
2262
2402
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
2263
2403
|
}
|
|
2404
|
+
// a resume reads the record its last boot completed; a boot that failed and was rolled back (the receipt above)
|
|
2405
|
+
// completed none, and the resume writes a complete one over the kept state (architecture, the boot record)
|
|
2406
|
+
let priorIncomplete = false;
|
|
2407
|
+
if (options.keepState) {
|
|
2408
|
+
try {
|
|
2409
|
+
prior = readWorldBootRecordLocked(name, root);
|
|
2410
|
+
}
|
|
2411
|
+
catch {
|
|
2412
|
+
priorIncomplete = true;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2264
2415
|
if (!prior) {
|
|
2265
2416
|
try {
|
|
2266
2417
|
prior = JSON.parse(priorRaw);
|
|
@@ -2284,7 +2435,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2284
2435
|
carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
|
|
2285
2436
|
}
|
|
2286
2437
|
catch {
|
|
2287
|
-
if (options.keepState)
|
|
2438
|
+
if (options.keepState && !priorIncomplete)
|
|
2288
2439
|
throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`);
|
|
2289
2440
|
}
|
|
2290
2441
|
}
|
|
@@ -2409,7 +2560,10 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2409
2560
|
const issuing = takeIssued(env);
|
|
2410
2561
|
const unissued = new Set();
|
|
2411
2562
|
writeProxyEnv(root, name, env);
|
|
2563
|
+
const requiredCredentials = { version: 1,
|
|
2564
|
+
owners: Object.fromEntries([...required].map(([name, claim]) => [name, claim.service])) };
|
|
2412
2565
|
const worldInstance = {
|
|
2566
|
+
requiredCredentials,
|
|
2413
2567
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2414
2568
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2415
2569
|
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
@@ -2492,7 +2646,8 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2492
2646
|
// own sent and status says that twin's door.
|
|
2493
2647
|
const origin = performance.now();
|
|
2494
2648
|
const steps = [];
|
|
2495
|
-
|
|
2649
|
+
// A descriptor refusal still settles every owned ask before rollback can retire its custody.
|
|
2650
|
+
const asks = await Promise.allSettled(host.services.map(async (started) => {
|
|
2496
2651
|
const declared = colocated.find((s) => s.id === started.id);
|
|
2497
2652
|
if (!declared)
|
|
2498
2653
|
return undefined;
|
|
@@ -2500,7 +2655,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2500
2655
|
const times = { ...(declared.package ? { twin: declared.package } : {}) };
|
|
2501
2656
|
steps.push({ id: started.id, queued: began, times });
|
|
2502
2657
|
const own = { ...before };
|
|
2503
|
-
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), times);
|
|
2658
|
+
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), required, !options.keepState, times);
|
|
2504
2659
|
return { id: started.id, ms: Math.round(performance.now() - began), own };
|
|
2505
2660
|
}));
|
|
2506
2661
|
const at = (time) => time === undefined ? '-' : `+${Math.round(time - origin)}`;
|
|
@@ -2510,7 +2665,13 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2510
2665
|
.sort((left, right) => (right.times.filled ?? 0) - (left.times.filled ?? 0))
|
|
2511
2666
|
.map(({ id, queued, times }) => `${id}${times.twin ? ` (${times.twin})` : ''} ${at(queued)}/${at(times.door)}/${at(times.sent)}/${at(times.headers)}/${at(times.answered)}/${at(times.filled)}`).join('; ')}`);
|
|
2512
2667
|
const asked = [];
|
|
2513
|
-
|
|
2668
|
+
const failures = [];
|
|
2669
|
+
for (const result of asks) {
|
|
2670
|
+
if (result.status === 'rejected') {
|
|
2671
|
+
failures.push(result.reason);
|
|
2672
|
+
continue;
|
|
2673
|
+
}
|
|
2674
|
+
const ask = result.value;
|
|
2514
2675
|
if (!ask)
|
|
2515
2676
|
continue;
|
|
2516
2677
|
for (const [key, value] of Object.entries(ask.own))
|
|
@@ -2523,6 +2684,8 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2523
2684
|
}
|
|
2524
2685
|
if (asked.length)
|
|
2525
2686
|
boot.progress(`co-located twins: each asked for its credentials at once, slowest first: ${[...asked].sort((left, right) => right.ms - left.ms).slice(0, 6).map((each) => `${each.id} ${each.ms} ms`).join(', ')}; ${asked.length} asked, ${Math.max(...asked.map((each) => each.ms))} ms the longest`);
|
|
2687
|
+
if (failures.length)
|
|
2688
|
+
throw new AggregateError(failures, `World credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
2526
2689
|
writeProxyEnv(root, name, env);
|
|
2527
2690
|
if (colocated.length)
|
|
2528
2691
|
boot.progress('co-located twins: credentials issued, proxy environment written');
|
|
@@ -2537,7 +2700,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2537
2700
|
if (services[service.id] || !ownInfrastructureService(service))
|
|
2538
2701
|
continue;
|
|
2539
2702
|
boot.progress(`${service.id}: starting beside the host (the runtime's own infrastructure)`);
|
|
2540
|
-
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot);
|
|
2703
|
+
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot, required);
|
|
2541
2704
|
starting.catch((error) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
|
|
2542
2705
|
early.set(service.id, starting);
|
|
2543
2706
|
}
|
|
@@ -2586,20 +2749,22 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2586
2749
|
boot.signal.throwIfAborted();
|
|
2587
2750
|
if (!starting)
|
|
2588
2751
|
boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
|
|
2589
|
-
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
|
|
2752
|
+
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot, required));
|
|
2590
2753
|
// An early start published its teardown when it began; its record moves to its declared position.
|
|
2591
2754
|
if (starting)
|
|
2592
2755
|
delete services[service.id];
|
|
2593
2756
|
recordService(started);
|
|
2594
2757
|
recordPidsSoFar();
|
|
2595
2758
|
Object.assign(env, started.env);
|
|
2596
|
-
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id));
|
|
2759
|
+
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id), required, !options.keepState);
|
|
2597
2760
|
writeProxyEnv(root, name, env);
|
|
2598
2761
|
boot.progress(`${service.id}: started, its environment merged`);
|
|
2599
2762
|
}
|
|
2600
2763
|
if (colocatedReady)
|
|
2601
2764
|
await colocatedReady();
|
|
2602
|
-
|
|
2765
|
+
// An abort records its reason while the final owned credential asks settle. No later service may remain
|
|
2766
|
+
// to observe it, so completion itself must reject it before publishing a successful environment.
|
|
2767
|
+
boot.signal.throwIfAborted();
|
|
2603
2768
|
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
2604
2769
|
if (issuing.size > 0)
|
|
2605
2770
|
process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
|
|
@@ -2614,6 +2779,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2614
2779
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
2615
2780
|
// config test below exercises exactly this.
|
|
2616
2781
|
attemptProxy();
|
|
2782
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2617
2783
|
// Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
|
|
2618
2784
|
// lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
|
|
2619
2785
|
// Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
|
|
@@ -2621,6 +2787,12 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2621
2787
|
for (const warning of inertInjectEnvWarnings(loaded.config.services)) {
|
|
2622
2788
|
process.stderr.write(`${warning}\n`);
|
|
2623
2789
|
}
|
|
2790
|
+
// This can await the browser materializer's import. Keep boot cancellation attached through that work,
|
|
2791
|
+
// and do not persist completed credential claims or publish the environment until it has finished.
|
|
2792
|
+
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2793
|
+
boot.signal.throwIfAborted();
|
|
2794
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2795
|
+
requiredCredentials.valuesSha256 = requiredCredentialValuesDigest(required);
|
|
2624
2796
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2625
2797
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2626
2798
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
@@ -2631,11 +2803,12 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2631
2803
|
delete worldInstance.previousEnvOutput;
|
|
2632
2804
|
saveWorldInstance(worldInstance);
|
|
2633
2805
|
boot.progress(`environment written: ${envFile}`);
|
|
2634
|
-
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2635
2806
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
2636
2807
|
saveWorldInstance(worldInstance);
|
|
2637
2808
|
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
2809
|
+
boot.signal.throwIfAborted();
|
|
2638
2810
|
handoffWorldLifecycle(root, name, servicePids);
|
|
2811
|
+
detachBootSignals();
|
|
2639
2812
|
boot.progress(`returning: the World is up; ${lag.account()}`);
|
|
2640
2813
|
if (mode === 'share') {
|
|
2641
2814
|
try {
|
|
@@ -2895,6 +3068,32 @@ function killHeldSurvivorsSync(child, graceMs) {
|
|
|
2895
3068
|
* purge leaves that lock alone; its owner's finally releases it, so waiting claimants never
|
|
2896
3069
|
* enter through a replacement lock while the original is still held.
|
|
2897
3070
|
*/
|
|
3071
|
+
/** A purged World's env output outside its folder (architecture, "World environment exports are outputs"): removed with
|
|
3072
|
+
* the record that proved it the World's own, when it still holds exactly the World's exports as a single-link regular
|
|
3073
|
+
* file and no other World of the root records that path; anything else stays. */
|
|
3074
|
+
function purgeEnvOutput(root, name, purged, instance) {
|
|
3075
|
+
const path = typeof instance.envFile === 'string' ? resolve(instance.envFile) : undefined;
|
|
3076
|
+
if (!path || !relative(purged, path).startsWith('..'))
|
|
3077
|
+
return;
|
|
3078
|
+
const others = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
3079
|
+
const shared = others.some((world) => {
|
|
3080
|
+
try {
|
|
3081
|
+
return resolve(JSON.parse(readFileSync(join(instanceDir(root, world), 'instance.json'), 'utf8')).envFile) === path;
|
|
3082
|
+
}
|
|
3083
|
+
catch {
|
|
3084
|
+
return false;
|
|
3085
|
+
}
|
|
3086
|
+
});
|
|
3087
|
+
if (shared)
|
|
3088
|
+
return;
|
|
3089
|
+
try {
|
|
3090
|
+
const named = lstatSync(path);
|
|
3091
|
+
if (!named.isFile() || named.nlink !== 1 || !readFileSync(path).equals(Buffer.from(envFileContents(instance.env))))
|
|
3092
|
+
return;
|
|
3093
|
+
rmSync(path);
|
|
3094
|
+
}
|
|
3095
|
+
catch { /* no output, or one the World cannot read: it stays */ }
|
|
3096
|
+
}
|
|
2898
3097
|
function purgeInstanceDir(root, name) {
|
|
2899
3098
|
assertSafeWorldName(name);
|
|
2900
3099
|
const base = worldBaseDir(root);
|
|
@@ -3175,6 +3374,8 @@ async function downWorldClaimed(name, root, options, externalErrors = []) {
|
|
|
3175
3374
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
3176
3375
|
if (purged && instance)
|
|
3177
3376
|
clearBrowserFiles(instance.dirs.data, resolvedRoot);
|
|
3377
|
+
if (purged && instance)
|
|
3378
|
+
purgeEnvOutput(resolvedRoot, name, purged, instance);
|
|
3178
3379
|
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
3179
3380
|
}));
|
|
3180
3381
|
}
|
|
@@ -3857,6 +4058,18 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3857
4058
|
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3858
4059
|
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3859
4060
|
: 'declared boot services have instance records' });
|
|
4061
|
+
// a vendor-backed root whose vendor rotated its refresh token while the seal failed (world-core head.ts
|
|
4062
|
+
// unsealedMarkerOf): it renews only inside the process that held the new one, so a person must seal it again
|
|
4063
|
+
const credentialDir = join(root, stateDirName(), 'credentials');
|
|
4064
|
+
for (const file of existsSync(credentialDir) ? readdirSync(credentialDir).filter((f) => f.endsWith('.unsealed.json')).sort() : []) {
|
|
4065
|
+
let said = '';
|
|
4066
|
+
try {
|
|
4067
|
+
const r = JSON.parse(readFileSync(join(credentialDir, file), 'utf8'));
|
|
4068
|
+
said = `: ${r.field ?? 'a field'} rotated at ${r.at ?? '?'} was not sealed (${r.cause ?? 'no cause recorded'})`;
|
|
4069
|
+
}
|
|
4070
|
+
catch { /* the marker's presence is the finding */ }
|
|
4071
|
+
checks.push({ id: `root-${file.replace(/\.unsealed\.json$/, '')}`, ok: false, message: `the root's credential lost a rotation${said}; seal it again before its process ends` });
|
|
4072
|
+
}
|
|
3860
4073
|
if (instance.lifecycle) {
|
|
3861
4074
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
|
3862
4075
|
message: `lifecycle ownership recorded; no capacity reserved; log=${instance.lifecycle.log}` });
|
|
@@ -4163,6 +4376,7 @@ export function listWorlds(root = process.cwd()) {
|
|
|
4163
4376
|
}
|
|
4164
4377
|
function worldAttachedCommandEnv(name, root, verbose = false, instance) {
|
|
4165
4378
|
const status = instance ?? statusWorld(name, root);
|
|
4379
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4166
4380
|
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed' || status.env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
4167
4381
|
const baseEnv = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
4168
4382
|
let proxyEnv = {};
|
|
@@ -4182,8 +4396,10 @@ function worldAttachedCommandEnv(name, root, verbose = false, instance) {
|
|
|
4182
4396
|
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
4183
4397
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
4184
4398
|
}
|
|
4185
|
-
|
|
4186
|
-
|
|
4399
|
+
const env = withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
4400
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' });
|
|
4401
|
+
assertRequiredCredentials(required, env, 'command attachment');
|
|
4402
|
+
return { instance: status, env, required };
|
|
4187
4403
|
}
|
|
4188
4404
|
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
4189
4405
|
* unset (the application's own env files decide) and the command is told how to record it: never a guess. */
|
|
@@ -4210,10 +4426,12 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
|
|
|
4210
4426
|
if (command.length === 0)
|
|
4211
4427
|
throw new Error('Missing command after --');
|
|
4212
4428
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
4429
|
+
const env = { ...attached.env, ...(options.env ?? {}) };
|
|
4430
|
+
assertRequiredCredentials(attached.required, env, 'SDK command environment');
|
|
4213
4431
|
return new Promise((resolveExit) => {
|
|
4214
4432
|
const child = spawn(command[0], command.slice(1), { windowsHide: true,
|
|
4215
4433
|
cwd: resolve(options.cwd ?? root),
|
|
4216
|
-
env
|
|
4434
|
+
env,
|
|
4217
4435
|
stdio: 'inherit',
|
|
4218
4436
|
});
|
|
4219
4437
|
const forward = (signal) => {
|
|
@@ -4290,6 +4508,8 @@ export async function attachWorld(name, command, root = process.cwd(), options =
|
|
|
4290
4508
|
throw new Error(`World "${name}" is not running while attaching: none of its recorded processes is live and its own (recorded pids ${readPids(current.pidsFile).join(', ') || 'none'}; live and its own: none); ${describeWorldLifecycle(resolvedRoot, name, current.createdAt)})`);
|
|
4291
4509
|
if (current.createdAt !== attached.instance.createdAt)
|
|
4292
4510
|
throw new Error(`World "${name}" changed while attaching: its instance was created ${attached.instance.createdAt} when the command's environment was read and ${current.createdAt} now`);
|
|
4511
|
+
// A supplied instance is a snapshot, not authority to erase the current boot's required bindings.
|
|
4512
|
+
assertRequiredCredentials(recordedRequiredCredentialClaims(current), attached.env, 'registered command attachment');
|
|
4293
4513
|
// Attaching to a World whose services died is allowed (a command may not need them), but never silent.
|
|
4294
4514
|
for (const [id, state] of Object.entries(current.serviceStates)) {
|
|
4295
4515
|
if (state === 'running' || state === 'external')
|
|
@@ -4472,14 +4692,21 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
4472
4692
|
* TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
|
|
4473
4693
|
* since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
|
|
4474
4694
|
export function worldShellEnv(name, root = process.cwd()) {
|
|
4695
|
+
const { shell, cwd, env } = worldShellContext(name, root);
|
|
4696
|
+
return { shell, cwd, env };
|
|
4697
|
+
}
|
|
4698
|
+
/** Keep the boot's required proof with the shell environment until its proxy transformations have finished. */
|
|
4699
|
+
function worldShellContext(name, root) {
|
|
4475
4700
|
const status = statusWorld(name, root);
|
|
4476
4701
|
if (!status.running)
|
|
4477
4702
|
throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4703
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4704
|
+
const env = withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name });
|
|
4705
|
+
assertRequiredCredentials(required, env, 'shell environment');
|
|
4706
|
+
// Windows has no /bin/bash: its shell is the one ComSpec names (cmd.exe), kept by the Windows baseline (admittedEnv).
|
|
4707
|
+
const inherited = admittedEnv(stripEnvOf(status.env));
|
|
4708
|
+
const fallback = process.platform === 'win32' ? (inherited.ComSpec ?? inherited.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe') : '/bin/bash';
|
|
4709
|
+
return { shell: status.env.SHELL || inherited.SHELL || fallback, cwd: resolve(root), env, required };
|
|
4483
4710
|
}
|
|
4484
4711
|
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
4485
4712
|
* (which wires your CURRENT shell + sets the `(world:<name>)` prompt marker). The subshell gets the
|
|
@@ -4488,7 +4715,7 @@ export function worldShellEnv(name, root = process.cwd()) {
|
|
|
4488
4715
|
* land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
|
|
4489
4716
|
* trusted ONLY via this subshell's env — never installed system-wide. See docs/guides/route-a-cli-through-the-world.md. */
|
|
4490
4717
|
export async function shellWorld(name, root = process.cwd()) {
|
|
4491
|
-
const { shell, cwd, env } =
|
|
4718
|
+
const { shell, cwd, env, required } = worldShellContext(name, root);
|
|
4492
4719
|
const resolvedRoot = resolve(root);
|
|
4493
4720
|
let proxy = null;
|
|
4494
4721
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
@@ -4515,14 +4742,35 @@ export async function shellWorld(name, root = process.cwd()) {
|
|
|
4515
4742
|
throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
|
|
4516
4743
|
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (env-only; install openssl for ambient https redirect). type 'exit' to leave.\n`);
|
|
4517
4744
|
}
|
|
4745
|
+
let failed = false;
|
|
4746
|
+
let failure;
|
|
4518
4747
|
try {
|
|
4748
|
+
assertRequiredCredentials(required, env, 'interactive shell');
|
|
4519
4749
|
const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
|
|
4520
|
-
|
|
4750
|
+
if (result.error)
|
|
4751
|
+
throw result.error;
|
|
4752
|
+
if (result.signal)
|
|
4753
|
+
throw new Error(`World shell ended by ${result.signal}`);
|
|
4754
|
+
if (result.status === null)
|
|
4755
|
+
throw new Error('World shell returned no exit status');
|
|
4756
|
+
return result.status;
|
|
4757
|
+
}
|
|
4758
|
+
catch (error) {
|
|
4759
|
+
failed = true;
|
|
4760
|
+
failure = error;
|
|
4761
|
+
throw error;
|
|
4521
4762
|
}
|
|
4522
4763
|
finally {
|
|
4523
4764
|
if (proxy) {
|
|
4524
|
-
|
|
4525
|
-
|
|
4765
|
+
try {
|
|
4766
|
+
await proxy.close();
|
|
4767
|
+
tearDownCa(tlsDir(resolvedRoot, name));
|
|
4768
|
+
}
|
|
4769
|
+
catch (cleanup) {
|
|
4770
|
+
if (failed)
|
|
4771
|
+
throw new AggregateError([failure, cleanup], `World shell failed (${errorMessage(failure)}); proxy cleanup also failed (${errorMessage(cleanup)})`, { cause: failure });
|
|
4772
|
+
throw cleanup;
|
|
4773
|
+
}
|
|
4526
4774
|
}
|
|
4527
4775
|
}
|
|
4528
4776
|
}
|
|
@@ -4844,6 +5092,7 @@ function clearWorldProxyFiles(root, name) {
|
|
|
4844
5092
|
*/
|
|
4845
5093
|
export function activateScript(name, root = process.cwd()) {
|
|
4846
5094
|
const status = statusWorld(name, root);
|
|
5095
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4847
5096
|
if (!status.running)
|
|
4848
5097
|
throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4849
5098
|
const baseEnv = status.env ?? {};
|
|
@@ -4869,6 +5118,8 @@ export function activateScript(name, root = process.cwd()) {
|
|
|
4869
5118
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
4870
5119
|
}
|
|
4871
5120
|
env = withAppUrl(name, root, env);
|
|
5121
|
+
// Exports follow the bookkeeping assignments; the final prompt assignment is the one later value change.
|
|
5122
|
+
assertRequiredCredentials(required, { ...env, PS1: `(world:${name}) ${env.PS1 ?? ''}` }, 'shell activation including its prompt');
|
|
4872
5123
|
const keys = Object.keys(env).sort();
|
|
4873
5124
|
const lines = [
|
|
4874
5125
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|