@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/src/runtime.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { isScalarCredentialField, withCurrentCredentialDoor } from './credential-door.ts';
|
|
1
2
|
import { errorMessage } from './error-message.ts';
|
|
3
|
+
import { recorderEnvironment, recordedApplicationEnvironment } from './service-environment.ts';
|
|
2
4
|
import { materializeWorldBrowserFiles } from './browser-files.ts';
|
|
3
5
|
import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
|
|
4
6
|
import { offSignal } from './signals.ts';
|
|
5
7
|
import { parallelStartup } from './startup.ts';
|
|
6
|
-
import { type HostReadyTimes, waitForHostReady } from './host-ready.ts';
|
|
8
|
+
import { startedTwins, type HostReadyTimes, waitForHostReady } from './host-ready.ts';
|
|
7
9
|
import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
|
|
8
10
|
import { packCli, packEntry } from './catalog.ts';
|
|
9
11
|
import { vendorOfPackModule } from '@volter/world-core/runtime-state';
|
|
@@ -34,7 +36,7 @@ import { loadWorldConfig } from './configs.ts';
|
|
|
34
36
|
import { createWorldNetworkPolicy, parseWorldNetworkPolicy, normalizeWorldNetwork, intersectWorldNetwork, type WorldNetwork, WORLD_NETWORK_POLICY_ENV } from '@volter/world-core/network-policy';
|
|
35
37
|
import { overlayPackFactsFile, resolveServicePackage } from './catalog.ts';
|
|
36
38
|
import { fakeEnvValue } from './fixture-env.ts';
|
|
37
|
-
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV } from './schema.ts';
|
|
39
|
+
import { APP_URL_ENV, ISSUE_ENV_PREFIX, MINT_ENV, assertRequiredCredentialBindings } from './schema.ts';
|
|
38
40
|
import { readAppUrl } from './app-url.ts';
|
|
39
41
|
import { inertInjectEnvWarnings } from './inject-map.ts';
|
|
40
42
|
import { activeVendorMap, ensureCa, hostHasRawEgress, opensslAvailable, proxyEnvFor, tearDownCa } from './proxy-env.ts';
|
|
@@ -69,13 +71,13 @@ export function worldEventsPath(logs: string): string {
|
|
|
69
71
|
* its output straight to `log` (capped, time-marked), its start and end are recorded there and in the World's events
|
|
70
72
|
* file, and it is handed a directory of its own for the log files it writes itself (VOLTER_WORLD_SERVICE_LOG_DIR).
|
|
71
73
|
* `ended` settles when the recorder exits, right after the service ends and its exit record is written. */
|
|
72
|
-
function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
|
|
74
|
+
function spawnRecorded(serviceId: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, log: string, boot: BootContext, required?: RequiredCredentialClaims): { child: ReturnType<typeof spawn>; ended: Promise<void>; identity: Promise<SpawnIdentity | undefined> } {
|
|
73
75
|
const logDir = ownedPath(dirname(log), `${basename(log).replace(/\.log$/, '')}.d`);
|
|
76
|
+
const recorderEnv = recorderEnvironment(env, serviceId, log, logDir, worldEventsPath(dirname(log)));
|
|
77
|
+
// The recorder adds and removes its own names. Validate the same final child environment before opening a log or spawning.
|
|
78
|
+
if (required !== undefined) assertRequiredCredentials(required, recordedApplicationEnvironment(recorderEnv), `service ${serviceId}'s recorded application environment`);
|
|
74
79
|
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
75
80
|
const out = openPrivateLog(log, 'a'); // the service's output and the recorder's records, appended
|
|
76
|
-
const recorderEnv: NodeJS.ProcessEnv = { ...env, VOLTER_WORLD_SERVICE_ID: serviceId, VOLTER_WORLD_SERVICE_LOG: log,
|
|
77
|
-
VOLTER_WORLD_SERVICE_LOG_DIR: logDir, VOLTER_WORLD_EVENTS: worldEventsPath(dirname(log)) };
|
|
78
|
-
if (recorderEnv.NODE_OPTIONS !== undefined) { recorderEnv.VOLTER_WORLD_SERVICE_NODE_OPTIONS = recorderEnv.NODE_OPTIONS; delete recorderEnv.NODE_OPTIONS; }
|
|
79
81
|
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'service-recorder'), command, ...args], { windowsHide: true,
|
|
80
82
|
cwd,
|
|
81
83
|
env: recorderEnv,
|
|
@@ -614,9 +616,12 @@ function networkOf(name: string, own: WorldNetwork | undefined, recorded: WorldN
|
|
|
614
616
|
return { [WORLD_NETWORK_POLICY_ENV]: JSON.stringify(createWorldNetworkPolicy(name, network)) };
|
|
615
617
|
}
|
|
616
618
|
|
|
617
|
-
/** Strip the publication envelope before combining permissions (the config shape admits only these keys).
|
|
619
|
+
/** Strip the publication envelope before combining permissions (the config shape admits only these keys). Every
|
|
620
|
+
* permission the policy carries stays: a dropped `writes` or `publicReads` would read as the default when combined,
|
|
621
|
+
* and a build World inside a closed one would read the public build origins its parent may not (sealing never widens). */
|
|
618
622
|
function networkPermission(policy: WorldNetwork): WorldNetwork {
|
|
619
|
-
return { egress: policy.egress,
|
|
623
|
+
return { egress: policy.egress, ...(policy.writes !== undefined ? { writes: policy.writes } : {}), phase: policy.phase, passthrough: policy.passthrough,
|
|
624
|
+
...(policy.publicReads !== undefined ? { publicReads: policy.publicReads } : {}), ...(policy.ownLoopback === false ? { ownLoopback: false } : {}) };
|
|
620
625
|
}
|
|
621
626
|
|
|
622
627
|
/** The boot permission, never a fallback to a mutable config. */
|
|
@@ -724,7 +729,13 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
|
|
|
724
729
|
} catch { 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.`); }
|
|
725
730
|
}
|
|
726
731
|
|
|
727
|
-
|
|
732
|
+
/** What Windows itself needs in every process: without these, Winsock and DLL loading fail, the temp directory falls
|
|
733
|
+
* back to C:\Windows\Temp and tools lose their profile folders. They hold no caller credential, so a strip (even
|
|
734
|
+
* `*`) never removes them on win32. Windows names are case-insensitive. */
|
|
735
|
+
const WINDOWS_BASELINE_ENV = new Set(['systemroot', 'windir', 'systemdrive', 'comspec', 'pathext', 'temp', 'tmp', 'userprofile',
|
|
736
|
+
'appdata', 'localappdata', 'programdata', 'programfiles', 'programfiles(x86)', 'programw6432', 'commonprogramfiles',
|
|
737
|
+
'commonprogramfiles(x86)', 'commonprogramw6432', 'homedrive', 'homepath', 'os', 'processor_architecture', 'number_of_processors']);
|
|
738
|
+
export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): Record<string, string> {
|
|
728
739
|
const out: Record<string, string> = {};
|
|
729
740
|
const rules = (strip ?? []).map((s): { prefix: string } | { name: string } => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
|
|
730
741
|
for (const [k, v] of Object.entries(env)) {
|
|
@@ -732,6 +743,7 @@ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv
|
|
|
732
743
|
// Permission is published by the attached instance, never inherited from
|
|
733
744
|
// whichever World happened to launch this operator process.
|
|
734
745
|
if (k === WORLD_NETWORK_POLICY_ENV) continue;
|
|
746
|
+
if (platform === 'win32' && WINDOWS_BASELINE_ENV.has(k.toLowerCase())) { out[k] = v; continue; }
|
|
735
747
|
if (rules.some((r) => ('prefix' in r ? k.startsWith(r.prefix) : k === r.name))) continue;
|
|
736
748
|
out[k] = v;
|
|
737
749
|
}
|
|
@@ -769,6 +781,7 @@ function serviceProcessEnv(
|
|
|
769
781
|
service: WorldServiceConfig,
|
|
770
782
|
worldEnv: Record<string, string>,
|
|
771
783
|
port: number,
|
|
784
|
+
twin = service.type === 'twin',
|
|
772
785
|
): Record<string, string> {
|
|
773
786
|
const env: Record<string, string> = {
|
|
774
787
|
...admittedEnv(config.stripEnv),
|
|
@@ -782,6 +795,12 @@ function serviceProcessEnv(
|
|
|
782
795
|
for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
783
796
|
}
|
|
784
797
|
dropUnissued(env);
|
|
798
|
+
// NODE_ENV is the application's mode: a twin is the World's own server, run as built (React's production server build
|
|
799
|
+
// crashes Bun 1.3.11 at load, so Twenty's NODE_ENV=production stopped every twin with screens); a service's own env
|
|
800
|
+
// may still set it
|
|
801
|
+
// a twin is a service that runs a pack: declared `type: 'twin'`, or untyped and spawning a pack's module (never an
|
|
802
|
+
// untyped command of the application's)
|
|
803
|
+
if (twin) delete env.NODE_ENV;
|
|
785
804
|
const out: Record<string, string> = { ...env, ...(service.env ?? {}) };
|
|
786
805
|
// the application's own origin: the World's own application service is reached where it listens (the URL
|
|
787
806
|
// `volter-world app-url` answers for it); any other service never reads it, and a command resolves it at attach
|
|
@@ -1273,6 +1292,7 @@ async function startExternalService(
|
|
|
1273
1292
|
service: WorldServiceConfig,
|
|
1274
1293
|
paths: { root: string; logs: string },
|
|
1275
1294
|
worldEnv: Record<string, string>,
|
|
1295
|
+
required: RequiredCredentialClaims,
|
|
1276
1296
|
stripEnv?: string[],
|
|
1277
1297
|
boot?: BootContext,
|
|
1278
1298
|
): Promise<WorldServiceInstance> {
|
|
@@ -1281,6 +1301,7 @@ async function startExternalService(
|
|
|
1281
1301
|
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1282
1302
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1283
1303
|
const env = externalServiceEnv(service, paths, worldEnv, stripEnv);
|
|
1304
|
+
if (!ownInfrastructureService(service)) assertRequiredCredentials(required, env, `external service ${service.id}`);
|
|
1284
1305
|
|
|
1285
1306
|
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
1286
1307
|
if (!external.up[0] || !commandExists(external.up[0])) {
|
|
@@ -1411,8 +1432,11 @@ async function startService(
|
|
|
1411
1432
|
paths: { root: string; instance: string; logs: string; data: string },
|
|
1412
1433
|
allocatedPorts: Set<number>,
|
|
1413
1434
|
boot: BootContext,
|
|
1435
|
+
required: RequiredCredentialClaims,
|
|
1414
1436
|
): Promise<WorldServiceInstance> {
|
|
1415
|
-
if (service.type
|
|
1437
|
+
if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
|
|
1438
|
+
assertRequiredCredentials(required, worldEnv, `service ${service.id}`);
|
|
1439
|
+
if (service.type === 'external') return startExternalService(service, paths, worldEnv, required, config.stripEnv, boot);
|
|
1416
1440
|
if (!service.command) throw new Error(`Service "${service.id}" must define command`);
|
|
1417
1441
|
const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
|
|
1418
1442
|
const serviceDataDir = ownedPath(paths.data, service.id);
|
|
@@ -1433,7 +1457,7 @@ async function startService(
|
|
|
1433
1457
|
if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
1434
1458
|
|
|
1435
1459
|
const log = ownedPath(paths.logs, `${service.id}.log`);
|
|
1436
|
-
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
1460
|
+
const env = serviceProcessEnv(config, service, worldEnv, port, service.type === 'twin' || (service.type === undefined && spawnedModule !== undefined));
|
|
1437
1461
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
1438
1462
|
|
|
1439
1463
|
// Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
|
|
@@ -1447,7 +1471,9 @@ async function startService(
|
|
|
1447
1471
|
// The service runs under the service recorder, which outlives it to write its end into its own log: `up`
|
|
1448
1472
|
// returns once the World is ready, so nothing else is left to see a service stop.
|
|
1449
1473
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1450
|
-
|
|
1474
|
+
if ((service.type ?? 'twin') !== 'twin' && !ownInfrastructureService(service))
|
|
1475
|
+
assertRequiredCredentials(required, env, `service ${service.id}'s effective environment`);
|
|
1476
|
+
const { child, ended, identity } = spawnRecorded(service.id, service.command, args, cwd, env, log, boot, (service.type ?? 'twin') === 'twin' ? undefined : required);
|
|
1451
1477
|
child.once('error', error => boot.fail(error));
|
|
1452
1478
|
child.unref();
|
|
1453
1479
|
await trackBootingChild(child, boot, identity);
|
|
@@ -1497,7 +1523,8 @@ async function startService(
|
|
|
1497
1523
|
|
|
1498
1524
|
// Optional readiness probe beyond TCP (httpUrl/command): a process can bind its port
|
|
1499
1525
|
// before it is actually serving, so this makes `up` wait until the service truly responds.
|
|
1500
|
-
|
|
1526
|
+
// `ready: { timeoutMs }` with neither is TCP readiness with its own budget: the bind wait above has used it.
|
|
1527
|
+
if (service.ready && service.ready !== 'tcp' && (service.ready.command !== undefined || service.ready.httpUrl !== undefined)) {
|
|
1501
1528
|
const readiness = await untilReadyOrEnded((abort) => awaitReadiness(resolveReadyProbe(service.ready as Exclude<typeof service.ready, 'tcp' | undefined>, { host, port, url }), cwd, service.id, env, abort), ended, boot, 'the service', log).catch(async (error) => {
|
|
1502
1529
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1503
1530
|
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
@@ -1645,6 +1672,87 @@ function takeIssued(env: Record<string, string>): Map<string, string> {
|
|
|
1645
1672
|
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1646
1673
|
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1647
1674
|
|
|
1675
|
+
/** Each required name belongs to this boot and gains a value only from its owner's scalar door response. */
|
|
1676
|
+
type RequiredCredentialClaims = Map<string, { service: string; value: string | undefined }>;
|
|
1677
|
+
|
|
1678
|
+
function requiredCredentialClaims(config: WorldConfig): RequiredCredentialClaims {
|
|
1679
|
+
const required: RequiredCredentialClaims = new Map();
|
|
1680
|
+
for (const service of config.services) for (const name of service.issues?.required ?? [])
|
|
1681
|
+
required.set(name, { service: service.id, value: undefined });
|
|
1682
|
+
return required;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/** Bind names and owners to the actual values retained in instance.env; the hash is not vendor authentication. */
|
|
1686
|
+
function requiredCredentialValuesDigest(required: RequiredCredentialClaims): string {
|
|
1687
|
+
const rows = [...required.keys()].sort().map(name => {
|
|
1688
|
+
const claim = required.get(name)!;
|
|
1689
|
+
if (claim.value === undefined) throw new Error(`${name}: no issued value exists to retain`);
|
|
1690
|
+
return [name, claim.service, claim.value];
|
|
1691
|
+
});
|
|
1692
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** New boots retain their required owners even if source config later changes; legacy optional records gain no proof. */
|
|
1696
|
+
function recordedRequiredCredentialClaims(instance: WorldInstance): RequiredCredentialClaims {
|
|
1697
|
+
const required: RequiredCredentialClaims = new Map();
|
|
1698
|
+
if (!Object.hasOwn(instance, 'requiredCredentials')) throw new Error('World boot has no retained credential requirement declaration; finish teardown with its owning runtime and boot with this runtime before attachment');
|
|
1699
|
+
const raw: unknown = instance.requiredCredentials;
|
|
1700
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('World required credential record is invalid');
|
|
1701
|
+
const record = raw as Record<string, unknown>;
|
|
1702
|
+
if (Object.keys(record).some(key => !['version', 'owners', 'valuesSha256'].includes(key))
|
|
1703
|
+
|| !Object.hasOwn(record, 'version') || record.version !== 1 || !Object.hasOwn(record, 'owners') || record.owners === null
|
|
1704
|
+
|| typeof record.owners !== 'object' || Array.isArray(record.owners))
|
|
1705
|
+
throw new Error('World required credential record has an unsupported version or shape');
|
|
1706
|
+
if (!instance.env || typeof instance.env !== 'object' || Array.isArray(instance.env)
|
|
1707
|
+
|| !instance.services || typeof instance.services !== 'object' || Array.isArray(instance.services))
|
|
1708
|
+
throw new Error('World required credential record lacks its retained environment or services');
|
|
1709
|
+
for (const [name, owner] of Object.entries(record.owners as Record<string, unknown>)) {
|
|
1710
|
+
if (!isEnvironmentName(name) || typeof owner !== 'string' || !owner || !Object.hasOwn(instance.services, owner))
|
|
1711
|
+
throw new Error(`World required credential owner is invalid for ${name}`);
|
|
1712
|
+
const service = instance.services[owner];
|
|
1713
|
+
if (!service || service.id !== owner || service.type !== 'twin') throw new Error(`World required credential ${name} names no twin owner`);
|
|
1714
|
+
const value = instance.env[name];
|
|
1715
|
+
if (!Object.hasOwn(instance.env, name) || typeof value !== 'string'
|
|
1716
|
+
|| value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV)
|
|
1717
|
+
throw new Error(`World required credential ${name} has no retained scalar value`);
|
|
1718
|
+
required.set(name, { service: owner, value });
|
|
1719
|
+
}
|
|
1720
|
+
if (!Object.hasOwn(record, 'valuesSha256')) {
|
|
1721
|
+
if (required.size) throw new Error('World required credential issuance has not completed; refusing attachment');
|
|
1722
|
+
return required;
|
|
1723
|
+
}
|
|
1724
|
+
if (typeof record.valuesSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(record.valuesSha256)
|
|
1725
|
+
|| record.valuesSha256 !== requiredCredentialValuesDigest(required))
|
|
1726
|
+
throw new Error('World required credential names, owners or retained values changed; refusing attachment');
|
|
1727
|
+
return required;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** Capture the validated boot's values before an owned executor applies its own environment layers. */
|
|
1731
|
+
export function worldRequiredCredentialGate(instance: WorldInstance): (env: NodeJS.ProcessEnv, boundary: string) => void {
|
|
1732
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1733
|
+
return (env, boundary) => assertRequiredCredentials(required, env, boundary);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** These transports do not carry required ownership/value authority; refuse instead of exporting an optional-only rendering. */
|
|
1737
|
+
export function refuseRequiredCredentialTransport(instance: WorldInstance, transport: string, code?: 'WORLD_MACHINE_CAPABILITY_GAP'): void {
|
|
1738
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1739
|
+
if (required.size) {
|
|
1740
|
+
const error = new Error(`World ${instance.name}: ${transport} does not carry required credential bindings (${[...required.keys()].join(', ')}); use local env attachment`);
|
|
1741
|
+
throw code ? Object.assign(error, { code }) : error;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** Check the actual response value, then the environment this boundary will use; never print credential values. */
|
|
1746
|
+
function assertRequiredCredentials(required: RequiredCredentialClaims, env: NodeJS.ProcessEnv, boundary: string): void {
|
|
1747
|
+
const failures: Error[] = [];
|
|
1748
|
+
for (const [name, claim] of required) {
|
|
1749
|
+
if (claim.value === undefined) failures.push(new Error(`${name}: ${claim.service} has not issued its required scalar credential`));
|
|
1750
|
+
else if (!Object.hasOwn(env, name) || env[name] !== claim.value)
|
|
1751
|
+
failures.push(new Error(`${name}: its issued value is missing or overridden at ${boundary}`));
|
|
1752
|
+
}
|
|
1753
|
+
if (failures.length) throw new AggregateError(failures, `World required credential gate refused at ${boundary}: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1648
1756
|
/** Where one credential ask's time went (S2, the twins' credentials): performance.now() at each step, read by the caller
|
|
1649
1757
|
* against its own origin. `door` is the pack's door read, `sent` the request handed to fetch, `headers` its answer's
|
|
1650
1758
|
* status, `answered` its body parsed, `filled` every name set or said unset. Door→sent and answered→filled include synchronous placeholder-pull
|
|
@@ -1653,22 +1761,33 @@ type CredentialAskTimes = { twin?: string; names?: number; door?: number; sent?:
|
|
|
1653
1761
|
|
|
1654
1762
|
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1655
1763
|
* asked for one and each pending name of that service is set to it. A twin that answers no credential, or has no
|
|
1656
|
-
* door, leaves
|
|
1657
|
-
async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>, controlRoot: string, times?: CredentialAskTimes): Promise<void> {
|
|
1764
|
+
* door, leaves optional names unset and says so. Required scalar names fail the boot with their original reasons. */
|
|
1765
|
+
async function issueCredentials(service: WorldServiceConfig, started: WorldServiceInstance, env: Record<string, string>, pending: Map<string, string>, unissued: Set<string>, controlRoot: string, claims: RequiredCredentialClaims, fresh: boolean, times?: CredentialAskTimes): Promise<void> {
|
|
1658
1766
|
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1659
1767
|
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1660
1768
|
if (times) { times.names = wanted.length; }
|
|
1769
|
+
const required = new Set(service.issues?.required ?? []);
|
|
1770
|
+
const failures: Error[] = [];
|
|
1771
|
+
for (const name of required) {
|
|
1772
|
+
if (!wanted.includes(name) || direct.has(name) || claims.get(name)?.service !== service.id)
|
|
1773
|
+
failures.push(new Error(`${name}: its exact $issue:${service.id} scalar request is absent at issuance`));
|
|
1774
|
+
}
|
|
1775
|
+
if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential requests refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1661
1776
|
if (wanted.length === 0) return;
|
|
1662
1777
|
for (const name of wanted) pending.delete(name);
|
|
1663
|
-
//
|
|
1664
|
-
|
|
1665
|
-
|
|
1778
|
+
// Revalidate aliases at the actual issuance boundary. A missing current pack door cannot revive a saved issuer.
|
|
1779
|
+
const door = (service.type ?? 'twin') === 'twin'
|
|
1780
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor).issues : service.issues;
|
|
1666
1781
|
if (times) times.door = performance.now();
|
|
1667
1782
|
try {
|
|
1668
1783
|
if (!door) throw new Error('its service names no credential door');
|
|
1669
1784
|
if (!started.url) throw new Error('it has no URL to ask');
|
|
1670
|
-
// A boot's identity is existing account state, not an application write to deploy
|
|
1671
|
-
|
|
1785
|
+
// A boot's identity is existing account state, not an application write to deploy: asked inside the twin's
|
|
1786
|
+
// placeholder-pull window, as the seed is (architecture, the descriptor's credentialDoor), on a fresh World's first
|
|
1787
|
+
// issuance only: a resumed World's door answers what it issued before, and its branch keeps its own writes; a twin
|
|
1788
|
+
// with a root is not seeded, and its door's writes stay its own
|
|
1789
|
+
const seeds = fresh && !(service as { root?: unknown }).root;
|
|
1790
|
+
const enclosingSeed = !seeds || placeholderPullActive(service.id, controlRoot);
|
|
1672
1791
|
if (!enclosingSeed) beginPlaceholderPull(service.id, controlRoot);
|
|
1673
1792
|
let answer: Record<string, unknown>;
|
|
1674
1793
|
try {
|
|
@@ -1769,7 +1888,11 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1769
1888
|
continue;
|
|
1770
1889
|
}
|
|
1771
1890
|
const field = spec;
|
|
1891
|
+
if (required.has(name) && (!isScalarCredentialField(field) || answer === null || typeof answer !== 'object'
|
|
1892
|
+
|| Array.isArray(answer) || !Object.hasOwn(answer, field))) throw new Error(`answered no own scalar ${field}`);
|
|
1772
1893
|
const value = answer[field];
|
|
1894
|
+
if (required.has(name) && typeof value === 'string' && (value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV))
|
|
1895
|
+
throw new Error(`answered an interpreted environment marker for ${field}`);
|
|
1773
1896
|
if (typeof value !== 'string') throw new Error(`answered no ${field}`);
|
|
1774
1897
|
if (direct.has(name)) {
|
|
1775
1898
|
const issued = new URL(value);
|
|
@@ -1779,9 +1902,19 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1779
1902
|
issued.host = endpoint.host;
|
|
1780
1903
|
env[name] = issued.toString();
|
|
1781
1904
|
} else env[name] = value;
|
|
1905
|
+
if (required.has(name)) {
|
|
1906
|
+
const claim = claims.get(name);
|
|
1907
|
+
if (!claim || claim.service !== service.id) throw new Error('its required credential owner changed');
|
|
1908
|
+
claim.value = value;
|
|
1909
|
+
}
|
|
1782
1910
|
} catch (error) {
|
|
1783
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer
|
|
1911
|
+
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`);
|
|
1784
1912
|
delete env[name]; unissued.add(name);
|
|
1913
|
+
if (required.has(name)) {
|
|
1914
|
+
const claim = claims.get(name);
|
|
1915
|
+
if (claim) claim.value = undefined;
|
|
1916
|
+
failures.push(new Error(`${name}: ${service.id} did not issue its required credential (${errorMessage(error)})`, { cause: error }));
|
|
1917
|
+
}
|
|
1785
1918
|
}
|
|
1786
1919
|
}
|
|
1787
1920
|
if (started.url) {
|
|
@@ -1793,8 +1926,14 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1793
1926
|
} catch (error) {
|
|
1794
1927
|
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
|
|
1795
1928
|
for (const name of wanted) { delete env[name]; unissued.add(name); }
|
|
1929
|
+
for (const name of required) {
|
|
1930
|
+
const claim = claims.get(name);
|
|
1931
|
+
if (claim) claim.value = undefined;
|
|
1932
|
+
failures.push(new Error(`${name}: ${service.id}'s required credential ask failed (${errorMessage(error)})`, { cause: error }));
|
|
1933
|
+
}
|
|
1796
1934
|
}
|
|
1797
1935
|
if (times) times.filled = performance.now();
|
|
1936
|
+
if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1798
1937
|
}
|
|
1799
1938
|
|
|
1800
1939
|
function materializeConfig(config: WorldConfig, root: string): WorldConfig {
|
|
@@ -1906,6 +2045,8 @@ async function startColocatedServices(
|
|
|
1906
2045
|
// the twins never read the application's own origin, nor a credential not yet issued
|
|
1907
2046
|
for (const [key, value] of Object.entries(env)) if (value === APP_URL_ENV) delete env[key];
|
|
1908
2047
|
dropUnissued(env);
|
|
2048
|
+
// the colocated twins' host runs the World's own servers: the application's NODE_ENV is not theirs (serviceProcessEnv)
|
|
2049
|
+
delete env.NODE_ENV;
|
|
1909
2050
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1910
2051
|
const spawnedAt = Date.now();
|
|
1911
2052
|
// Under the same exit recorder as a process service, so the host's end (and every twin it holds) is in host.log.
|
|
@@ -1946,7 +2087,7 @@ async function startColocatedServices(
|
|
|
1946
2087
|
});
|
|
1947
2088
|
const ready = (async (): Promise<void> => {
|
|
1948
2089
|
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1949
|
-
const hostTimes = await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort, () =>
|
|
2090
|
+
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) => {
|
|
1950
2091
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1951
2092
|
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1952
2093
|
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
@@ -2051,6 +2192,11 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2051
2192
|
// instead of loading and rebuilding a second facts/host table on every empty up.
|
|
2052
2193
|
if (packFactsPaths.length) createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
2053
2194
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
2195
|
+
// Before any output claim or lifecycle write, every saved alias fits the actual current pack.
|
|
2196
|
+
loaded.config.services = loaded.config.services.map(service => (service.type ?? 'twin') === 'twin'
|
|
2197
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor) : service);
|
|
2198
|
+
assertRequiredCredentialBindings(loaded.config, found.path);
|
|
2199
|
+
const required = requiredCredentialClaims(loaded.config);
|
|
2054
2200
|
const name = options.name ?? loaded.config.id;
|
|
2055
2201
|
const mode = options.mode ?? 'local';
|
|
2056
2202
|
assertSafeWorldName(name);
|
|
@@ -2155,7 +2301,6 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2155
2301
|
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
2156
2302
|
if (existsSync(instanceFile)) {
|
|
2157
2303
|
let prior: WorldInstance | undefined;
|
|
2158
|
-
if (options.keepState) prior = readWorldBootRecordLocked(name, root);
|
|
2159
2304
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
2160
2305
|
const receiptPath = join(instance, 'stopped.json');
|
|
2161
2306
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
@@ -2163,6 +2308,13 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2163
2308
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
2164
2309
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
2165
2310
|
}
|
|
2311
|
+
// a resume reads the record its last boot completed; a boot that failed and was rolled back (the receipt above)
|
|
2312
|
+
// completed none, and the resume writes a complete one over the kept state (architecture, the boot record)
|
|
2313
|
+
let priorIncomplete = false;
|
|
2314
|
+
if (options.keepState) {
|
|
2315
|
+
try { prior = readWorldBootRecordLocked(name, root); }
|
|
2316
|
+
catch { priorIncomplete = true; }
|
|
2317
|
+
}
|
|
2166
2318
|
if (!prior) { try { prior = JSON.parse(priorRaw) as WorldInstance; } catch { /* fresh boot needs no retained shape */ } }
|
|
2167
2319
|
if (prior) {
|
|
2168
2320
|
// A readable ceiling remains useful even when unrelated metadata is incomplete.
|
|
@@ -2175,7 +2327,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2175
2327
|
}
|
|
2176
2328
|
const ceiling = recordedWorldNetwork(permissionRecord);
|
|
2177
2329
|
if (ceiling !== undefined) carriedNetwork = carriedNetwork === undefined ? ceiling : intersectWorldNetwork(carriedNetwork, ceiling);
|
|
2178
|
-
} catch { if (options.keepState) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
|
|
2330
|
+
} catch { if (options.keepState && !priorIncomplete) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
|
|
2179
2331
|
}
|
|
2180
2332
|
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
2181
2333
|
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
@@ -2277,7 +2429,10 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2277
2429
|
const unissued = new Set<string>();
|
|
2278
2430
|
writeProxyEnv(root, name, env);
|
|
2279
2431
|
|
|
2432
|
+
const requiredCredentials: NonNullable<WorldInstance['requiredCredentials']> = { version: 1,
|
|
2433
|
+
owners: Object.fromEntries([...required].map(([name, claim]) => [name, claim.service])) };
|
|
2280
2434
|
const worldInstance: WorldInstance = {
|
|
2435
|
+
requiredCredentials,
|
|
2281
2436
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2282
2437
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2283
2438
|
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
@@ -2352,14 +2507,15 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2352
2507
|
// own sent and status says that twin's door.
|
|
2353
2508
|
const origin = performance.now();
|
|
2354
2509
|
const steps: Array<{ id: string; queued: number; times: CredentialAskTimes }> = [];
|
|
2355
|
-
|
|
2510
|
+
// A descriptor refusal still settles every owned ask before rollback can retire its custody.
|
|
2511
|
+
const asks = await Promise.allSettled(host.services.map(async (started) => {
|
|
2356
2512
|
const declared = colocated.find((s) => s.id === started.id);
|
|
2357
2513
|
if (!declared) return undefined;
|
|
2358
2514
|
const began = performance.now();
|
|
2359
2515
|
const times: CredentialAskTimes = { ...(declared.package ? { twin: declared.package } : {}) };
|
|
2360
2516
|
steps.push({ id: started.id, queued: began, times });
|
|
2361
2517
|
const own: Record<string, string> = { ...before };
|
|
2362
|
-
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), times);
|
|
2518
|
+
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), required, !options.keepState, times);
|
|
2363
2519
|
return { id: started.id, ms: Math.round(performance.now() - began), own };
|
|
2364
2520
|
}));
|
|
2365
2521
|
const at = (time: number | undefined): string => time === undefined ? '-' : `+${Math.round(time - origin)}`;
|
|
@@ -2368,13 +2524,17 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2368
2524
|
.sort((left, right) => (right.times.filled ?? 0) - (left.times.filled ?? 0))
|
|
2369
2525
|
.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('; ')}`);
|
|
2370
2526
|
const asked: Array<{ id: string; ms: number }> = [];
|
|
2371
|
-
|
|
2527
|
+
const failures: unknown[] = [];
|
|
2528
|
+
for (const result of asks) {
|
|
2529
|
+
if (result.status === 'rejected') { failures.push(result.reason); continue; }
|
|
2530
|
+
const ask = result.value;
|
|
2372
2531
|
if (!ask) continue;
|
|
2373
2532
|
for (const [key, value] of Object.entries(ask.own)) if (before[key] !== value) env[key] = value;
|
|
2374
2533
|
for (const key of Object.keys(before)) if (!Object.hasOwn(ask.own, key)) delete env[key];
|
|
2375
2534
|
asked.push({ id: ask.id, ms: ask.ms });
|
|
2376
2535
|
}
|
|
2377
2536
|
if (asked.length) 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`);
|
|
2537
|
+
if (failures.length) throw new AggregateError(failures, `World credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
2378
2538
|
writeProxyEnv(root, name, env);
|
|
2379
2539
|
if (colocated.length) boot.progress('co-located twins: credentials issued, proxy environment written');
|
|
2380
2540
|
};
|
|
@@ -2387,7 +2547,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2387
2547
|
for (const service of loaded.config.services) {
|
|
2388
2548
|
if (services[service.id] || !ownInfrastructureService(service)) continue;
|
|
2389
2549
|
boot.progress(`${service.id}: starting beside the host (the runtime's own infrastructure)`);
|
|
2390
|
-
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot);
|
|
2550
|
+
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot, required);
|
|
2391
2551
|
starting.catch((error: unknown) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
|
|
2392
2552
|
early.set(service.id, starting);
|
|
2393
2553
|
}
|
|
@@ -2429,18 +2589,20 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2429
2589
|
attemptProxy();
|
|
2430
2590
|
boot.signal.throwIfAborted();
|
|
2431
2591
|
if (!starting) boot.progress(`${service.id}: starting (${service.type ?? 'twin'})`);
|
|
2432
|
-
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot));
|
|
2592
|
+
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot, required));
|
|
2433
2593
|
// An early start published its teardown when it began; its record moves to its declared position.
|
|
2434
2594
|
if (starting) delete services[service.id];
|
|
2435
2595
|
recordService(started);
|
|
2436
2596
|
recordPidsSoFar();
|
|
2437
2597
|
Object.assign(env, started.env);
|
|
2438
|
-
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id));
|
|
2598
|
+
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id), required, !options.keepState);
|
|
2439
2599
|
writeProxyEnv(root, name, env);
|
|
2440
2600
|
boot.progress(`${service.id}: started, its environment merged`);
|
|
2441
2601
|
}
|
|
2442
2602
|
if (colocatedReady) await colocatedReady();
|
|
2443
|
-
|
|
2603
|
+
// An abort records its reason while the final owned credential asks settle. No later service may remain
|
|
2604
|
+
// to observe it, so completion itself must reject it before publishing a successful environment.
|
|
2605
|
+
boot.signal.throwIfAborted();
|
|
2444
2606
|
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
2445
2607
|
if (issuing.size > 0) process.stderr.write(`volter-world: nothing in this World issues ${[...issuing].map(([n, id]) => `${n} ($issue:${id})`).join(', ')}; left unset\n`);
|
|
2446
2608
|
for (const [name] of issuing) unissued.add(name);
|
|
@@ -2450,6 +2612,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2450
2612
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
2451
2613
|
// config test below exercises exactly this.
|
|
2452
2614
|
attemptProxy();
|
|
2615
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2453
2616
|
// Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
|
|
2454
2617
|
// lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
|
|
2455
2618
|
// Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
|
|
@@ -2458,6 +2621,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2458
2621
|
process.stderr.write(`${warning}\n`);
|
|
2459
2622
|
}
|
|
2460
2623
|
|
|
2624
|
+
// This can await the browser materializer's import. Keep boot cancellation attached through that work,
|
|
2625
|
+
// and do not persist completed credential claims or publish the environment until it has finished.
|
|
2626
|
+
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2627
|
+
boot.signal.throwIfAborted();
|
|
2628
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2629
|
+
requiredCredentials.valuesSha256 = requiredCredentialValuesDigest(required);
|
|
2461
2630
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2462
2631
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2463
2632
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
@@ -2468,11 +2637,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2468
2637
|
delete worldInstance.previousEnvOutput;
|
|
2469
2638
|
saveWorldInstance(worldInstance);
|
|
2470
2639
|
boot.progress(`environment written: ${envFile}`);
|
|
2471
|
-
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2472
2640
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
2473
2641
|
saveWorldInstance(worldInstance);
|
|
2474
2642
|
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
2643
|
+
boot.signal.throwIfAborted();
|
|
2475
2644
|
handoffWorldLifecycle(root, name, servicePids);
|
|
2645
|
+
detachBootSignals();
|
|
2476
2646
|
boot.progress(`returning: the World is up; ${lag.account()}`);
|
|
2477
2647
|
if (mode === 'share') {
|
|
2478
2648
|
try {
|
|
@@ -2692,6 +2862,24 @@ function killHeldSurvivorsSync(child: ReturnType<typeof spawn>, graceMs: number)
|
|
|
2692
2862
|
* purge leaves that lock alone; its owner's finally releases it, so waiting claimants never
|
|
2693
2863
|
* enter through a replacement lock while the original is still held.
|
|
2694
2864
|
*/
|
|
2865
|
+
/** A purged World's env output outside its folder (architecture, "World environment exports are outputs"): removed with
|
|
2866
|
+
* the record that proved it the World's own, when it still holds exactly the World's exports as a single-link regular
|
|
2867
|
+
* file and no other World of the root records that path; anything else stays. */
|
|
2868
|
+
function purgeEnvOutput(root: string, name: string, purged: string, instance: WorldInstance): void {
|
|
2869
|
+
const path = typeof instance.envFile === 'string' ? resolve(instance.envFile) : undefined;
|
|
2870
|
+
if (!path || !relative(purged, path).startsWith('..')) return;
|
|
2871
|
+
const others = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
2872
|
+
const shared = others.some((world) => {
|
|
2873
|
+
try { return resolve((JSON.parse(readFileSync(join(instanceDir(root, world), 'instance.json'), 'utf8')) as WorldInstance).envFile) === path; } catch { return false; }
|
|
2874
|
+
});
|
|
2875
|
+
if (shared) return;
|
|
2876
|
+
try {
|
|
2877
|
+
const named = lstatSync(path);
|
|
2878
|
+
if (!named.isFile() || named.nlink !== 1 || !readFileSync(path).equals(Buffer.from(envFileContents(instance.env)))) return;
|
|
2879
|
+
rmSync(path);
|
|
2880
|
+
} catch { /* no output, or one the World cannot read: it stays */ }
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2695
2883
|
function purgeInstanceDir(root: string, name: string): string {
|
|
2696
2884
|
assertSafeWorldName(name);
|
|
2697
2885
|
const base = worldBaseDir(root);
|
|
@@ -2922,6 +3110,7 @@ async function downWorldClaimed(
|
|
|
2922
3110
|
if (!options.purge) return result;
|
|
2923
3111
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
2924
3112
|
if (purged && instance) clearBrowserFiles!(instance.dirs.data, resolvedRoot);
|
|
3113
|
+
if (purged && instance) purgeEnvOutput(resolvedRoot, name, purged, instance);
|
|
2925
3114
|
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
2926
3115
|
}));
|
|
2927
3116
|
}
|
|
@@ -3597,6 +3786,14 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
3597
3786
|
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3598
3787
|
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3599
3788
|
: 'declared boot services have instance records' });
|
|
3789
|
+
// a vendor-backed root whose vendor rotated its refresh token while the seal failed (world-core head.ts
|
|
3790
|
+
// unsealedMarkerOf): it renews only inside the process that held the new one, so a person must seal it again
|
|
3791
|
+
const credentialDir = join(root, stateDirName(), 'credentials');
|
|
3792
|
+
for (const file of existsSync(credentialDir) ? readdirSync(credentialDir).filter((f) => f.endsWith('.unsealed.json')).sort() : []) {
|
|
3793
|
+
let said = '';
|
|
3794
|
+
try { const r = JSON.parse(readFileSync(join(credentialDir, file), 'utf8')) as { field?: string; at?: string; cause?: string }; said = `: ${r.field ?? 'a field'} rotated at ${r.at ?? '?'} was not sealed (${r.cause ?? 'no cause recorded'})`; } catch { /* the marker's presence is the finding */ }
|
|
3795
|
+
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` });
|
|
3796
|
+
}
|
|
3600
3797
|
|
|
3601
3798
|
if (instance.lifecycle) {
|
|
3602
3799
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
|
@@ -3888,8 +4085,9 @@ export function listWorlds(root = process.cwd()): Array<{ name: string; running:
|
|
|
3888
4085
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
3889
4086
|
}
|
|
3890
4087
|
|
|
3891
|
-
function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv } {
|
|
4088
|
+
function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv; required: RequiredCredentialClaims } {
|
|
3892
4089
|
const status = instance ?? statusWorld(name, root);
|
|
4090
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
3893
4091
|
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed' || status.env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
3894
4092
|
const baseEnv = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
3895
4093
|
let proxyEnv: Record<string, string> = {};
|
|
@@ -3904,8 +4102,10 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false, in
|
|
|
3904
4102
|
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
3905
4103
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3906
4104
|
}
|
|
3907
|
-
|
|
3908
|
-
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' })
|
|
4105
|
+
const env = withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
4106
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' });
|
|
4107
|
+
assertRequiredCredentials(required, env, 'command attachment');
|
|
4108
|
+
return { instance: status, env, required };
|
|
3909
4109
|
}
|
|
3910
4110
|
|
|
3911
4111
|
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
@@ -3929,10 +4129,12 @@ export function withAppUrl<E extends Record<string, string | undefined>>(name: s
|
|
|
3929
4129
|
export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean; env?: Record<string, string> } = {}): Promise<number> {
|
|
3930
4130
|
if (command.length === 0) throw new Error('Missing command after --');
|
|
3931
4131
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
4132
|
+
const env = { ...attached.env, ...(options.env ?? {}) };
|
|
4133
|
+
assertRequiredCredentials(attached.required, env, 'SDK command environment');
|
|
3932
4134
|
return new Promise((resolveExit) => {
|
|
3933
4135
|
const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
|
|
3934
4136
|
cwd: resolve(options.cwd ?? root),
|
|
3935
|
-
env
|
|
4137
|
+
env,
|
|
3936
4138
|
stdio: 'inherit',
|
|
3937
4139
|
});
|
|
3938
4140
|
const forward = (signal: NodeJS.Signals) => {
|
|
@@ -3998,6 +4200,8 @@ export async function attachWorld(name: string, command: string[], root = proces
|
|
|
3998
4200
|
// Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
|
|
3999
4201
|
if (!current.running) 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)})`);
|
|
4000
4202
|
if (current.createdAt !== attached.instance.createdAt) 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`);
|
|
4203
|
+
// A supplied instance is a snapshot, not authority to erase the current boot's required bindings.
|
|
4204
|
+
assertRequiredCredentials(recordedRequiredCredentialClaims(current), attached.env, 'registered command attachment');
|
|
4001
4205
|
// Attaching to a World whose services died is allowed (a command may not need them), but never silent.
|
|
4002
4206
|
for (const [id, state] of Object.entries(current.serviceStates)) {
|
|
4003
4207
|
if (state === 'running' || state === 'external') continue;
|
|
@@ -4139,13 +4343,21 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
4139
4343
|
* TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
|
|
4140
4344
|
* since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
|
|
4141
4345
|
export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
|
|
4346
|
+
const { shell, cwd, env } = worldShellContext(name, root);
|
|
4347
|
+
return { shell, cwd, env };
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
/** Keep the boot's required proof with the shell environment until its proxy transformations have finished. */
|
|
4351
|
+
function worldShellContext(name: string, root: string): { shell: string; cwd: string; env: Record<string, string>; required: RequiredCredentialClaims } {
|
|
4142
4352
|
const status = statusWorld(name, root);
|
|
4143
4353
|
if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4354
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4355
|
+
const env = withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>);
|
|
4356
|
+
assertRequiredCredentials(required, env, 'shell environment');
|
|
4357
|
+
// Windows has no /bin/bash: its shell is the one ComSpec names (cmd.exe), kept by the Windows baseline (admittedEnv).
|
|
4358
|
+
const inherited = admittedEnv(stripEnvOf(status.env));
|
|
4359
|
+
const fallback = process.platform === 'win32' ? (inherited.ComSpec ?? inherited.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe') : '/bin/bash';
|
|
4360
|
+
return { shell: status.env.SHELL || inherited.SHELL || fallback, cwd: resolve(root), env, required };
|
|
4149
4361
|
}
|
|
4150
4362
|
|
|
4151
4363
|
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
@@ -4155,7 +4367,7 @@ export function worldShellEnv(name: string, root = process.cwd()): { shell: stri
|
|
|
4155
4367
|
* land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
|
|
4156
4368
|
* trusted ONLY via this subshell's env — never installed system-wide. See docs/guides/route-a-cli-through-the-world.md. */
|
|
4157
4369
|
export async function shellWorld(name: string, root = process.cwd()): Promise<number> {
|
|
4158
|
-
const { shell, cwd, env } =
|
|
4370
|
+
const { shell, cwd, env, required } = worldShellContext(name, root);
|
|
4159
4371
|
const resolvedRoot = resolve(root);
|
|
4160
4372
|
let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
|
|
4161
4373
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
@@ -4176,13 +4388,25 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
4176
4388
|
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
|
|
4177
4389
|
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`);
|
|
4178
4390
|
}
|
|
4391
|
+
let failed = false;
|
|
4392
|
+
let failure: unknown;
|
|
4179
4393
|
try {
|
|
4394
|
+
assertRequiredCredentials(required, env, 'interactive shell');
|
|
4180
4395
|
const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
|
|
4181
|
-
|
|
4396
|
+
if (result.error) throw result.error;
|
|
4397
|
+
if (result.signal) throw new Error(`World shell ended by ${result.signal}`);
|
|
4398
|
+
if (result.status === null) throw new Error('World shell returned no exit status');
|
|
4399
|
+
return result.status;
|
|
4400
|
+
} catch (error) {
|
|
4401
|
+
failed = true; failure = error;
|
|
4402
|
+
throw error;
|
|
4182
4403
|
} finally {
|
|
4183
4404
|
if (proxy) {
|
|
4184
|
-
await proxy.close();
|
|
4185
|
-
|
|
4405
|
+
try { await proxy.close(); tearDownCa(tlsDir(resolvedRoot, name)); }
|
|
4406
|
+
catch (cleanup) {
|
|
4407
|
+
if (failed) throw new AggregateError([failure, cleanup], `World shell failed (${errorMessage(failure)}); proxy cleanup also failed (${errorMessage(cleanup)})`, { cause: failure });
|
|
4408
|
+
throw cleanup;
|
|
4409
|
+
}
|
|
4186
4410
|
}
|
|
4187
4411
|
}
|
|
4188
4412
|
}
|
|
@@ -4446,6 +4670,7 @@ function clearWorldProxyFiles(root: string, name: string): void {
|
|
|
4446
4670
|
*/
|
|
4447
4671
|
export function activateScript(name: string, root = process.cwd()): string {
|
|
4448
4672
|
const status = statusWorld(name, root);
|
|
4673
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4449
4674
|
if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4450
4675
|
const baseEnv = status.env ?? {};
|
|
4451
4676
|
// Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
|
|
@@ -4465,6 +4690,8 @@ export function activateScript(name: string, root = process.cwd()): string {
|
|
|
4465
4690
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
4466
4691
|
}
|
|
4467
4692
|
env = withAppUrl(name, root, env);
|
|
4693
|
+
// Exports follow the bookkeeping assignments; the final prompt assignment is the one later value change.
|
|
4694
|
+
assertRequiredCredentials(required, { ...env, PS1: `(world:${name}) ${env.PS1 ?? ''}` }, 'shell activation including its prompt');
|
|
4468
4695
|
const keys = Object.keys(env).sort();
|
|
4469
4696
|
const lines: string[] = [
|
|
4470
4697
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|