@volter/world-runtime 3.0.166 → 3.0.168
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 +6 -1
- package/dist/src/index.js +7 -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 +311 -59
- 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 +8 -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 +272 -45
- 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);
|
|
@@ -1501,7 +1527,8 @@ async function startService(config, service, worldEnv, paths, allocatedPorts, bo
|
|
|
1501
1527
|
boot.progress(`${service.id}: port ${port} is its own`);
|
|
1502
1528
|
// Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
|
|
1503
1529
|
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
1504
|
-
|
|
1530
|
+
// `ready: { timeoutMs }` with neither is TCP readiness with its own budget: the bind wait above has used it.
|
|
1531
|
+
if (service.ready && service.ready !== 'tcp' && (service.ready.command !== undefined || service.ready.httpUrl !== undefined)) {
|
|
1505
1532
|
const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1506
1533
|
if (undeadPids([child.pid]).length)
|
|
1507
1534
|
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
@@ -1674,25 +1701,110 @@ function takeIssued(env) {
|
|
|
1674
1701
|
/** How long a twin's credential door has to answer before its names are left unset (a door answers from the twin's
|
|
1675
1702
|
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1676
1703
|
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1704
|
+
function requiredCredentialClaims(config) {
|
|
1705
|
+
const required = new Map();
|
|
1706
|
+
for (const service of config.services)
|
|
1707
|
+
for (const name of service.issues?.required ?? [])
|
|
1708
|
+
required.set(name, { service: service.id, value: undefined });
|
|
1709
|
+
return required;
|
|
1710
|
+
}
|
|
1711
|
+
/** Bind names and owners to the actual values retained in instance.env; the hash is not vendor authentication. */
|
|
1712
|
+
function requiredCredentialValuesDigest(required) {
|
|
1713
|
+
const rows = [...required.keys()].sort().map(name => {
|
|
1714
|
+
const claim = required.get(name);
|
|
1715
|
+
if (claim.value === undefined)
|
|
1716
|
+
throw new Error(`${name}: no issued value exists to retain`);
|
|
1717
|
+
return [name, claim.service, claim.value];
|
|
1718
|
+
});
|
|
1719
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
1720
|
+
}
|
|
1721
|
+
/** New boots retain their required owners even if source config later changes; legacy optional records gain no proof. */
|
|
1722
|
+
function recordedRequiredCredentialClaims(instance) {
|
|
1723
|
+
const required = new Map();
|
|
1724
|
+
if (!Object.hasOwn(instance, 'requiredCredentials'))
|
|
1725
|
+
throw new Error('World boot has no retained credential requirement declaration; finish teardown with its owning runtime and boot with this runtime before attachment');
|
|
1726
|
+
const raw = instance.requiredCredentials;
|
|
1727
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1728
|
+
throw new Error('World required credential record is invalid');
|
|
1729
|
+
const record = raw;
|
|
1730
|
+
if (Object.keys(record).some(key => !['version', 'owners', 'valuesSha256'].includes(key))
|
|
1731
|
+
|| !Object.hasOwn(record, 'version') || record.version !== 1 || !Object.hasOwn(record, 'owners') || record.owners === null
|
|
1732
|
+
|| typeof record.owners !== 'object' || Array.isArray(record.owners))
|
|
1733
|
+
throw new Error('World required credential record has an unsupported version or shape');
|
|
1734
|
+
if (!instance.env || typeof instance.env !== 'object' || Array.isArray(instance.env)
|
|
1735
|
+
|| !instance.services || typeof instance.services !== 'object' || Array.isArray(instance.services))
|
|
1736
|
+
throw new Error('World required credential record lacks its retained environment or services');
|
|
1737
|
+
for (const [name, owner] of Object.entries(record.owners)) {
|
|
1738
|
+
if (!isEnvironmentName(name) || typeof owner !== 'string' || !owner || !Object.hasOwn(instance.services, owner))
|
|
1739
|
+
throw new Error(`World required credential owner is invalid for ${name}`);
|
|
1740
|
+
const service = instance.services[owner];
|
|
1741
|
+
if (!service || service.id !== owner || service.type !== 'twin')
|
|
1742
|
+
throw new Error(`World required credential ${name} names no twin owner`);
|
|
1743
|
+
const value = instance.env[name];
|
|
1744
|
+
if (!Object.hasOwn(instance.env, name) || typeof value !== 'string'
|
|
1745
|
+
|| value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV)
|
|
1746
|
+
throw new Error(`World required credential ${name} has no retained scalar value`);
|
|
1747
|
+
required.set(name, { service: owner, value });
|
|
1748
|
+
}
|
|
1749
|
+
if (!Object.hasOwn(record, 'valuesSha256')) {
|
|
1750
|
+
if (required.size)
|
|
1751
|
+
throw new Error('World required credential issuance has not completed; refusing attachment');
|
|
1752
|
+
return required;
|
|
1753
|
+
}
|
|
1754
|
+
if (typeof record.valuesSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(record.valuesSha256)
|
|
1755
|
+
|| record.valuesSha256 !== requiredCredentialValuesDigest(required))
|
|
1756
|
+
throw new Error('World required credential names, owners or retained values changed; refusing attachment');
|
|
1757
|
+
return required;
|
|
1758
|
+
}
|
|
1759
|
+
/** Capture the validated boot's values before an owned executor applies its own environment layers. */
|
|
1760
|
+
export function worldRequiredCredentialGate(instance) {
|
|
1761
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1762
|
+
return (env, boundary) => assertRequiredCredentials(required, env, boundary);
|
|
1763
|
+
}
|
|
1764
|
+
/** These transports do not carry required ownership/value authority; refuse instead of exporting an optional-only rendering. */
|
|
1765
|
+
export function refuseRequiredCredentialTransport(instance, transport, code) {
|
|
1766
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1767
|
+
if (required.size) {
|
|
1768
|
+
const error = new Error(`World ${instance.name}: ${transport} does not carry required credential bindings (${[...required.keys()].join(', ')}); use local env attachment`);
|
|
1769
|
+
throw code ? Object.assign(error, { code }) : error;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
/** Check the actual response value, then the environment this boundary will use; never print credential values. */
|
|
1773
|
+
function assertRequiredCredentials(required, env, boundary) {
|
|
1774
|
+
const failures = [];
|
|
1775
|
+
for (const [name, claim] of required) {
|
|
1776
|
+
if (claim.value === undefined)
|
|
1777
|
+
failures.push(new Error(`${name}: ${claim.service} has not issued its required scalar credential`));
|
|
1778
|
+
else if (!Object.hasOwn(env, name) || env[name] !== claim.value)
|
|
1779
|
+
failures.push(new Error(`${name}: its issued value is missing or overridden at ${boundary}`));
|
|
1780
|
+
}
|
|
1781
|
+
if (failures.length)
|
|
1782
|
+
throw new AggregateError(failures, `World required credential gate refused at ${boundary}: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1783
|
+
}
|
|
1677
1784
|
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1678
1785
|
* 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) {
|
|
1786
|
+
* door, leaves optional names unset and says so. Required scalar names fail the boot with their original reasons. */
|
|
1787
|
+
async function issueCredentials(service, started, env, pending, unissued, controlRoot, claims, fresh, times) {
|
|
1681
1788
|
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1682
1789
|
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1683
1790
|
if (times) {
|
|
1684
1791
|
times.names = wanted.length;
|
|
1685
1792
|
}
|
|
1793
|
+
const required = new Set(service.issues?.required ?? []);
|
|
1794
|
+
const failures = [];
|
|
1795
|
+
for (const name of required) {
|
|
1796
|
+
if (!wanted.includes(name) || direct.has(name) || claims.get(name)?.service !== service.id)
|
|
1797
|
+
failures.push(new Error(`${name}: its exact $issue:${service.id} scalar request is absent at issuance`));
|
|
1798
|
+
}
|
|
1799
|
+
if (failures.length)
|
|
1800
|
+
throw new AggregateError(failures, `World service ${service.id}: required credential requests refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1686
1801
|
if (wanted.length === 0)
|
|
1687
1802
|
return;
|
|
1688
1803
|
for (const name of wanted)
|
|
1689
1804
|
pending.delete(name);
|
|
1690
|
-
//
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
door = packFacts()[service.id]?.credentialDoor ?? door;
|
|
1694
|
-
}
|
|
1695
|
-
catch { /* no artifact: the saved copy */ }
|
|
1805
|
+
// Revalidate aliases at the actual issuance boundary. A missing current pack door cannot revive a saved issuer.
|
|
1806
|
+
const door = (service.type ?? 'twin') === 'twin'
|
|
1807
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor).issues : service.issues;
|
|
1696
1808
|
if (times)
|
|
1697
1809
|
times.door = performance.now();
|
|
1698
1810
|
try {
|
|
@@ -1700,8 +1812,12 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1700
1812
|
throw new Error('its service names no credential door');
|
|
1701
1813
|
if (!started.url)
|
|
1702
1814
|
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
|
-
|
|
1815
|
+
// A boot's identity is existing account state, not an application write to deploy: asked inside the twin's
|
|
1816
|
+
// placeholder-pull window, as the seed is (architecture, the descriptor's credentialDoor), on a fresh World's first
|
|
1817
|
+
// issuance only: a resumed World's door answers what it issued before, and its branch keeps its own writes; a twin
|
|
1818
|
+
// with a root is not seeded, and its door's writes stay its own
|
|
1819
|
+
const seeds = fresh && !service.root;
|
|
1820
|
+
const enclosingSeed = !seeds || placeholderPullActive(service.id, controlRoot);
|
|
1705
1821
|
if (!enclosingSeed)
|
|
1706
1822
|
beginPlaceholderPull(service.id, controlRoot);
|
|
1707
1823
|
let answer;
|
|
@@ -1828,7 +1944,12 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1828
1944
|
continue;
|
|
1829
1945
|
}
|
|
1830
1946
|
const field = spec;
|
|
1947
|
+
if (required.has(name) && (!isScalarCredentialField(field) || answer === null || typeof answer !== 'object'
|
|
1948
|
+
|| Array.isArray(answer) || !Object.hasOwn(answer, field)))
|
|
1949
|
+
throw new Error(`answered no own scalar ${field}`);
|
|
1831
1950
|
const value = answer[field];
|
|
1951
|
+
if (required.has(name) && typeof value === 'string' && (value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV))
|
|
1952
|
+
throw new Error(`answered an interpreted environment marker for ${field}`);
|
|
1832
1953
|
if (typeof value !== 'string')
|
|
1833
1954
|
throw new Error(`answered no ${field}`);
|
|
1834
1955
|
if (direct.has(name)) {
|
|
@@ -1842,11 +1963,23 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1842
1963
|
}
|
|
1843
1964
|
else
|
|
1844
1965
|
env[name] = value;
|
|
1966
|
+
if (required.has(name)) {
|
|
1967
|
+
const claim = claims.get(name);
|
|
1968
|
+
if (!claim || claim.service !== service.id)
|
|
1969
|
+
throw new Error('its required credential owner changed');
|
|
1970
|
+
claim.value = value;
|
|
1971
|
+
}
|
|
1845
1972
|
}
|
|
1846
1973
|
catch (error) {
|
|
1847
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer
|
|
1974
|
+
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
1975
|
delete env[name];
|
|
1849
1976
|
unissued.add(name);
|
|
1977
|
+
if (required.has(name)) {
|
|
1978
|
+
const claim = claims.get(name);
|
|
1979
|
+
if (claim)
|
|
1980
|
+
claim.value = undefined;
|
|
1981
|
+
failures.push(new Error(`${name}: ${service.id} did not issue its required credential (${errorMessage(error)})`, { cause: error }));
|
|
1982
|
+
}
|
|
1850
1983
|
}
|
|
1851
1984
|
}
|
|
1852
1985
|
if (started.url) {
|
|
@@ -1862,9 +1995,17 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1862
1995
|
delete env[name];
|
|
1863
1996
|
unissued.add(name);
|
|
1864
1997
|
}
|
|
1998
|
+
for (const name of required) {
|
|
1999
|
+
const claim = claims.get(name);
|
|
2000
|
+
if (claim)
|
|
2001
|
+
claim.value = undefined;
|
|
2002
|
+
failures.push(new Error(`${name}: ${service.id}'s required credential ask failed (${errorMessage(error)})`, { cause: error }));
|
|
2003
|
+
}
|
|
1865
2004
|
}
|
|
1866
2005
|
if (times)
|
|
1867
2006
|
times.filled = performance.now();
|
|
2007
|
+
if (failures.length)
|
|
2008
|
+
throw new AggregateError(failures, `World service ${service.id}: required credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1868
2009
|
}
|
|
1869
2010
|
function materializeConfig(config, root) {
|
|
1870
2011
|
const services = config.services.map((service) => {
|
|
@@ -1976,6 +2117,8 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1976
2117
|
if (value === APP_URL_ENV)
|
|
1977
2118
|
delete env[key];
|
|
1978
2119
|
dropUnissued(env);
|
|
2120
|
+
// the colocated twins' host runs the World's own servers: the application's NODE_ENV is not theirs (serviceProcessEnv)
|
|
2121
|
+
delete env.NODE_ENV;
|
|
1979
2122
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1980
2123
|
const spawnedAt = Date.now();
|
|
1981
2124
|
// 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 +2157,7 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
2014
2157
|
});
|
|
2015
2158
|
const ready = (async () => {
|
|
2016
2159
|
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) => {
|
|
2160
|
+
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
2161
|
if (undeadPids([child.pid]).length)
|
|
2024
2162
|
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
2025
2163
|
try {
|
|
@@ -2144,6 +2282,11 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2144
2282
|
if (packFactsPaths.length)
|
|
2145
2283
|
createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
2146
2284
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
2285
|
+
// Before any output claim or lifecycle write, every saved alias fits the actual current pack.
|
|
2286
|
+
loaded.config.services = loaded.config.services.map(service => (service.type ?? 'twin') === 'twin'
|
|
2287
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor) : service);
|
|
2288
|
+
assertRequiredCredentialBindings(loaded.config, found.path);
|
|
2289
|
+
const required = requiredCredentialClaims(loaded.config);
|
|
2147
2290
|
const name = options.name ?? loaded.config.id;
|
|
2148
2291
|
const mode = options.mode ?? 'local';
|
|
2149
2292
|
assertSafeWorldName(name);
|
|
@@ -2252,8 +2395,6 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2252
2395
|
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
2253
2396
|
if (existsSync(instanceFile)) {
|
|
2254
2397
|
let prior;
|
|
2255
|
-
if (options.keepState)
|
|
2256
|
-
prior = readWorldBootRecordLocked(name, root);
|
|
2257
2398
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
2258
2399
|
const receiptPath = join(instance, 'stopped.json');
|
|
2259
2400
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
@@ -2261,6 +2402,17 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2261
2402
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
2262
2403
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
2263
2404
|
}
|
|
2405
|
+
// a resume reads the record its last boot completed; a boot that failed and was rolled back (the receipt above)
|
|
2406
|
+
// completed none, and the resume writes a complete one over the kept state (architecture, the boot record)
|
|
2407
|
+
let priorIncomplete = false;
|
|
2408
|
+
if (options.keepState) {
|
|
2409
|
+
try {
|
|
2410
|
+
prior = readWorldBootRecordLocked(name, root);
|
|
2411
|
+
}
|
|
2412
|
+
catch {
|
|
2413
|
+
priorIncomplete = true;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2264
2416
|
if (!prior) {
|
|
2265
2417
|
try {
|
|
2266
2418
|
prior = JSON.parse(priorRaw);
|
|
@@ -2284,7 +2436,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2284
2436
|
carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
|
|
2285
2437
|
}
|
|
2286
2438
|
catch {
|
|
2287
|
-
if (options.keepState)
|
|
2439
|
+
if (options.keepState && !priorIncomplete)
|
|
2288
2440
|
throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`);
|
|
2289
2441
|
}
|
|
2290
2442
|
}
|
|
@@ -2409,7 +2561,10 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2409
2561
|
const issuing = takeIssued(env);
|
|
2410
2562
|
const unissued = new Set();
|
|
2411
2563
|
writeProxyEnv(root, name, env);
|
|
2564
|
+
const requiredCredentials = { version: 1,
|
|
2565
|
+
owners: Object.fromEntries([...required].map(([name, claim]) => [name, claim.service])) };
|
|
2412
2566
|
const worldInstance = {
|
|
2567
|
+
requiredCredentials,
|
|
2413
2568
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2414
2569
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2415
2570
|
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
@@ -2492,7 +2647,8 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2492
2647
|
// own sent and status says that twin's door.
|
|
2493
2648
|
const origin = performance.now();
|
|
2494
2649
|
const steps = [];
|
|
2495
|
-
|
|
2650
|
+
// A descriptor refusal still settles every owned ask before rollback can retire its custody.
|
|
2651
|
+
const asks = await Promise.allSettled(host.services.map(async (started) => {
|
|
2496
2652
|
const declared = colocated.find((s) => s.id === started.id);
|
|
2497
2653
|
if (!declared)
|
|
2498
2654
|
return undefined;
|
|
@@ -2500,7 +2656,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2500
2656
|
const times = { ...(declared.package ? { twin: declared.package } : {}) };
|
|
2501
2657
|
steps.push({ id: started.id, queued: began, times });
|
|
2502
2658
|
const own = { ...before };
|
|
2503
|
-
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), times);
|
|
2659
|
+
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), required, !options.keepState, times);
|
|
2504
2660
|
return { id: started.id, ms: Math.round(performance.now() - began), own };
|
|
2505
2661
|
}));
|
|
2506
2662
|
const at = (time) => time === undefined ? '-' : `+${Math.round(time - origin)}`;
|
|
@@ -2510,7 +2666,13 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2510
2666
|
.sort((left, right) => (right.times.filled ?? 0) - (left.times.filled ?? 0))
|
|
2511
2667
|
.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
2668
|
const asked = [];
|
|
2513
|
-
|
|
2669
|
+
const failures = [];
|
|
2670
|
+
for (const result of asks) {
|
|
2671
|
+
if (result.status === 'rejected') {
|
|
2672
|
+
failures.push(result.reason);
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
const ask = result.value;
|
|
2514
2676
|
if (!ask)
|
|
2515
2677
|
continue;
|
|
2516
2678
|
for (const [key, value] of Object.entries(ask.own))
|
|
@@ -2523,6 +2685,8 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2523
2685
|
}
|
|
2524
2686
|
if (asked.length)
|
|
2525
2687
|
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`);
|
|
2688
|
+
if (failures.length)
|
|
2689
|
+
throw new AggregateError(failures, `World credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
2526
2690
|
writeProxyEnv(root, name, env);
|
|
2527
2691
|
if (colocated.length)
|
|
2528
2692
|
boot.progress('co-located twins: credentials issued, proxy environment written');
|
|
@@ -2537,7 +2701,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2537
2701
|
if (services[service.id] || !ownInfrastructureService(service))
|
|
2538
2702
|
continue;
|
|
2539
2703
|
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);
|
|
2704
|
+
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot, required);
|
|
2541
2705
|
starting.catch((error) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
|
|
2542
2706
|
early.set(service.id, starting);
|
|
2543
2707
|
}
|
|
@@ -2586,20 +2750,22 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2586
2750
|
boot.signal.throwIfAborted();
|
|
2587
2751
|
if (!starting)
|
|
2588
2752
|
boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
|
|
2589
|
-
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
|
|
2753
|
+
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot, required));
|
|
2590
2754
|
// An early start published its teardown when it began; its record moves to its declared position.
|
|
2591
2755
|
if (starting)
|
|
2592
2756
|
delete services[service.id];
|
|
2593
2757
|
recordService(started);
|
|
2594
2758
|
recordPidsSoFar();
|
|
2595
2759
|
Object.assign(env, started.env);
|
|
2596
|
-
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id));
|
|
2760
|
+
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id), required, !options.keepState);
|
|
2597
2761
|
writeProxyEnv(root, name, env);
|
|
2598
2762
|
boot.progress(`${service.id}: started, its environment merged`);
|
|
2599
2763
|
}
|
|
2600
2764
|
if (colocatedReady)
|
|
2601
2765
|
await colocatedReady();
|
|
2602
|
-
|
|
2766
|
+
// An abort records its reason while the final owned credential asks settle. No later service may remain
|
|
2767
|
+
// to observe it, so completion itself must reject it before publishing a successful environment.
|
|
2768
|
+
boot.signal.throwIfAborted();
|
|
2603
2769
|
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
2604
2770
|
if (issuing.size > 0)
|
|
2605
2771
|
process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
|
|
@@ -2614,6 +2780,7 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2614
2780
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
2615
2781
|
// config test below exercises exactly this.
|
|
2616
2782
|
attemptProxy();
|
|
2783
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2617
2784
|
// Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
|
|
2618
2785
|
// lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
|
|
2619
2786
|
// Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
|
|
@@ -2621,6 +2788,12 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2621
2788
|
for (const warning of inertInjectEnvWarnings(loaded.config.services)) {
|
|
2622
2789
|
process.stderr.write(`${warning}\n`);
|
|
2623
2790
|
}
|
|
2791
|
+
// This can await the browser materializer's import. Keep boot cancellation attached through that work,
|
|
2792
|
+
// and do not persist completed credential claims or publish the environment until it has finished.
|
|
2793
|
+
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2794
|
+
boot.signal.throwIfAborted();
|
|
2795
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2796
|
+
requiredCredentials.valuesSha256 = requiredCredentialValuesDigest(required);
|
|
2624
2797
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2625
2798
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2626
2799
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
@@ -2631,11 +2804,12 @@ async function upWorldRecorded(configId, options, abort, upStarted, lag) {
|
|
|
2631
2804
|
delete worldInstance.previousEnvOutput;
|
|
2632
2805
|
saveWorldInstance(worldInstance);
|
|
2633
2806
|
boot.progress(`environment written: ${envFile}`);
|
|
2634
|
-
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2635
2807
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
2636
2808
|
saveWorldInstance(worldInstance);
|
|
2637
2809
|
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
2810
|
+
boot.signal.throwIfAborted();
|
|
2638
2811
|
handoffWorldLifecycle(root, name, servicePids);
|
|
2812
|
+
detachBootSignals();
|
|
2639
2813
|
boot.progress(`returning: the World is up; ${lag.account()}`);
|
|
2640
2814
|
if (mode === 'share') {
|
|
2641
2815
|
try {
|
|
@@ -2895,6 +3069,32 @@ function killHeldSurvivorsSync(child, graceMs) {
|
|
|
2895
3069
|
* purge leaves that lock alone; its owner's finally releases it, so waiting claimants never
|
|
2896
3070
|
* enter through a replacement lock while the original is still held.
|
|
2897
3071
|
*/
|
|
3072
|
+
/** A purged World's env output outside its folder (architecture, "World environment exports are outputs"): removed with
|
|
3073
|
+
* the record that proved it the World's own, when it still holds exactly the World's exports as a single-link regular
|
|
3074
|
+
* file and no other World of the root records that path; anything else stays. */
|
|
3075
|
+
function purgeEnvOutput(root, name, purged, instance) {
|
|
3076
|
+
const path = typeof instance.envFile === 'string' ? resolve(instance.envFile) : undefined;
|
|
3077
|
+
if (!path || !relative(purged, path).startsWith('..'))
|
|
3078
|
+
return;
|
|
3079
|
+
const others = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
3080
|
+
const shared = others.some((world) => {
|
|
3081
|
+
try {
|
|
3082
|
+
return resolve(JSON.parse(readFileSync(join(instanceDir(root, world), 'instance.json'), 'utf8')).envFile) === path;
|
|
3083
|
+
}
|
|
3084
|
+
catch {
|
|
3085
|
+
return false;
|
|
3086
|
+
}
|
|
3087
|
+
});
|
|
3088
|
+
if (shared)
|
|
3089
|
+
return;
|
|
3090
|
+
try {
|
|
3091
|
+
const named = lstatSync(path);
|
|
3092
|
+
if (!named.isFile() || named.nlink !== 1 || !readFileSync(path).equals(Buffer.from(envFileContents(instance.env))))
|
|
3093
|
+
return;
|
|
3094
|
+
rmSync(path);
|
|
3095
|
+
}
|
|
3096
|
+
catch { /* no output, or one the World cannot read: it stays */ }
|
|
3097
|
+
}
|
|
2898
3098
|
function purgeInstanceDir(root, name) {
|
|
2899
3099
|
assertSafeWorldName(name);
|
|
2900
3100
|
const base = worldBaseDir(root);
|
|
@@ -3175,6 +3375,8 @@ async function downWorldClaimed(name, root, options, externalErrors = []) {
|
|
|
3175
3375
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
3176
3376
|
if (purged && instance)
|
|
3177
3377
|
clearBrowserFiles(instance.dirs.data, resolvedRoot);
|
|
3378
|
+
if (purged && instance)
|
|
3379
|
+
purgeEnvOutput(resolvedRoot, name, purged, instance);
|
|
3178
3380
|
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
3179
3381
|
}));
|
|
3180
3382
|
}
|
|
@@ -3857,6 +4059,18 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3857
4059
|
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3858
4060
|
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3859
4061
|
: 'declared boot services have instance records' });
|
|
4062
|
+
// a vendor-backed root whose vendor rotated its refresh token while the seal failed (world-core head.ts
|
|
4063
|
+
// unsealedMarkerOf): it renews only inside the process that held the new one, so a person must seal it again
|
|
4064
|
+
const credentialDir = join(root, stateDirName(), 'credentials');
|
|
4065
|
+
for (const file of existsSync(credentialDir) ? readdirSync(credentialDir).filter((f) => f.endsWith('.unsealed.json')).sort() : []) {
|
|
4066
|
+
let said = '';
|
|
4067
|
+
try {
|
|
4068
|
+
const r = JSON.parse(readFileSync(join(credentialDir, file), 'utf8'));
|
|
4069
|
+
said = `: ${r.field ?? 'a field'} rotated at ${r.at ?? '?'} was not sealed (${r.cause ?? 'no cause recorded'})`;
|
|
4070
|
+
}
|
|
4071
|
+
catch { /* the marker's presence is the finding */ }
|
|
4072
|
+
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` });
|
|
4073
|
+
}
|
|
3860
4074
|
if (instance.lifecycle) {
|
|
3861
4075
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
|
3862
4076
|
message: `lifecycle ownership recorded; no capacity reserved; log=${instance.lifecycle.log}` });
|
|
@@ -4163,6 +4377,7 @@ export function listWorlds(root = process.cwd()) {
|
|
|
4163
4377
|
}
|
|
4164
4378
|
function worldAttachedCommandEnv(name, root, verbose = false, instance) {
|
|
4165
4379
|
const status = instance ?? statusWorld(name, root);
|
|
4380
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4166
4381
|
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed' || status.env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
4167
4382
|
const baseEnv = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
4168
4383
|
let proxyEnv = {};
|
|
@@ -4182,8 +4397,10 @@ function worldAttachedCommandEnv(name, root, verbose = false, instance) {
|
|
|
4182
4397
|
throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
4183
4398
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
4184
4399
|
}
|
|
4185
|
-
|
|
4186
|
-
|
|
4400
|
+
const env = withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
4401
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' });
|
|
4402
|
+
assertRequiredCredentials(required, env, 'command attachment');
|
|
4403
|
+
return { instance: status, env, required };
|
|
4187
4404
|
}
|
|
4188
4405
|
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
4189
4406
|
* unset (the application's own env files decide) and the command is told how to record it: never a guess. */
|
|
@@ -4210,10 +4427,12 @@ export function runWithWorldEnv(name, command, root = process.cwd(), options = {
|
|
|
4210
4427
|
if (command.length === 0)
|
|
4211
4428
|
throw new Error('Missing command after --');
|
|
4212
4429
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
4430
|
+
const env = { ...attached.env, ...(options.env ?? {}) };
|
|
4431
|
+
assertRequiredCredentials(attached.required, env, 'SDK command environment');
|
|
4213
4432
|
return new Promise((resolveExit) => {
|
|
4214
4433
|
const child = spawn(command[0], command.slice(1), { windowsHide: true,
|
|
4215
4434
|
cwd: resolve(options.cwd ?? root),
|
|
4216
|
-
env
|
|
4435
|
+
env,
|
|
4217
4436
|
stdio: 'inherit',
|
|
4218
4437
|
});
|
|
4219
4438
|
const forward = (signal) => {
|
|
@@ -4290,6 +4509,8 @@ export async function attachWorld(name, command, root = process.cwd(), options =
|
|
|
4290
4509
|
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
4510
|
if (current.createdAt !== attached.instance.createdAt)
|
|
4292
4511
|
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`);
|
|
4512
|
+
// A supplied instance is a snapshot, not authority to erase the current boot's required bindings.
|
|
4513
|
+
assertRequiredCredentials(recordedRequiredCredentialClaims(current), attached.env, 'registered command attachment');
|
|
4293
4514
|
// Attaching to a World whose services died is allowed (a command may not need them), but never silent.
|
|
4294
4515
|
for (const [id, state] of Object.entries(current.serviceStates)) {
|
|
4295
4516
|
if (state === 'running' || state === 'external')
|
|
@@ -4472,14 +4693,21 @@ async function runWithWorldEnvLogged(name, command, root, options = {}) {
|
|
|
4472
4693
|
* TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
|
|
4473
4694
|
* since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
|
|
4474
4695
|
export function worldShellEnv(name, root = process.cwd()) {
|
|
4696
|
+
const { shell, cwd, env } = worldShellContext(name, root);
|
|
4697
|
+
return { shell, cwd, env };
|
|
4698
|
+
}
|
|
4699
|
+
/** Keep the boot's required proof with the shell environment until its proxy transformations have finished. */
|
|
4700
|
+
function worldShellContext(name, root) {
|
|
4475
4701
|
const status = statusWorld(name, root);
|
|
4476
4702
|
if (!status.running)
|
|
4477
4703
|
throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4704
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4705
|
+
const env = withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name });
|
|
4706
|
+
assertRequiredCredentials(required, env, 'shell environment');
|
|
4707
|
+
// Windows has no /bin/bash: its shell is the one ComSpec names (cmd.exe), kept by the Windows baseline (admittedEnv).
|
|
4708
|
+
const inherited = admittedEnv(stripEnvOf(status.env));
|
|
4709
|
+
const fallback = process.platform === 'win32' ? (inherited.ComSpec ?? inherited.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe') : '/bin/bash';
|
|
4710
|
+
return { shell: status.env.SHELL || inherited.SHELL || fallback, cwd: resolve(root), env, required };
|
|
4483
4711
|
}
|
|
4484
4712
|
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
4485
4713
|
* (which wires your CURRENT shell + sets the `(world:<name>)` prompt marker). The subshell gets the
|
|
@@ -4488,7 +4716,7 @@ export function worldShellEnv(name, root = process.cwd()) {
|
|
|
4488
4716
|
* land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
|
|
4489
4717
|
* trusted ONLY via this subshell's env — never installed system-wide. See docs/guides/route-a-cli-through-the-world.md. */
|
|
4490
4718
|
export async function shellWorld(name, root = process.cwd()) {
|
|
4491
|
-
const { shell, cwd, env } =
|
|
4719
|
+
const { shell, cwd, env, required } = worldShellContext(name, root);
|
|
4492
4720
|
const resolvedRoot = resolve(root);
|
|
4493
4721
|
let proxy = null;
|
|
4494
4722
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
@@ -4515,14 +4743,35 @@ export async function shellWorld(name, root = process.cwd()) {
|
|
|
4515
4743
|
throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
|
|
4516
4744
|
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
4745
|
}
|
|
4746
|
+
let failed = false;
|
|
4747
|
+
let failure;
|
|
4518
4748
|
try {
|
|
4749
|
+
assertRequiredCredentials(required, env, 'interactive shell');
|
|
4519
4750
|
const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
|
|
4520
|
-
|
|
4751
|
+
if (result.error)
|
|
4752
|
+
throw result.error;
|
|
4753
|
+
if (result.signal)
|
|
4754
|
+
throw new Error(`World shell ended by ${result.signal}`);
|
|
4755
|
+
if (result.status === null)
|
|
4756
|
+
throw new Error('World shell returned no exit status');
|
|
4757
|
+
return result.status;
|
|
4758
|
+
}
|
|
4759
|
+
catch (error) {
|
|
4760
|
+
failed = true;
|
|
4761
|
+
failure = error;
|
|
4762
|
+
throw error;
|
|
4521
4763
|
}
|
|
4522
4764
|
finally {
|
|
4523
4765
|
if (proxy) {
|
|
4524
|
-
|
|
4525
|
-
|
|
4766
|
+
try {
|
|
4767
|
+
await proxy.close();
|
|
4768
|
+
tearDownCa(tlsDir(resolvedRoot, name));
|
|
4769
|
+
}
|
|
4770
|
+
catch (cleanup) {
|
|
4771
|
+
if (failed)
|
|
4772
|
+
throw new AggregateError([failure, cleanup], `World shell failed (${errorMessage(failure)}); proxy cleanup also failed (${errorMessage(cleanup)})`, { cause: failure });
|
|
4773
|
+
throw cleanup;
|
|
4774
|
+
}
|
|
4526
4775
|
}
|
|
4527
4776
|
}
|
|
4528
4777
|
}
|
|
@@ -4844,6 +5093,7 @@ function clearWorldProxyFiles(root, name) {
|
|
|
4844
5093
|
*/
|
|
4845
5094
|
export function activateScript(name, root = process.cwd()) {
|
|
4846
5095
|
const status = statusWorld(name, root);
|
|
5096
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4847
5097
|
if (!status.running)
|
|
4848
5098
|
throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4849
5099
|
const baseEnv = status.env ?? {};
|
|
@@ -4869,6 +5119,8 @@ export function activateScript(name, root = process.cwd()) {
|
|
|
4869
5119
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
4870
5120
|
}
|
|
4871
5121
|
env = withAppUrl(name, root, env);
|
|
5122
|
+
// Exports follow the bookkeeping assignments; the final prompt assignment is the one later value change.
|
|
5123
|
+
assertRequiredCredentials(required, { ...env, PS1: `(world:${name}) ${env.PS1 ?? ''}` }, 'shell activation including its prompt');
|
|
4872
5124
|
const keys = Object.keys(env).sort();
|
|
4873
5125
|
const lines = [
|
|
4874
5126
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|