@volter/world-runtime 3.0.165 → 3.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/known-external-services.json +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +4 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +309 -58
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +5 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +270 -44
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- package/src/service-recorder.ts +9 -9
package/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);
|
|
@@ -1645,6 +1671,87 @@ function takeIssued(env: Record<string, string>): Map<string, string> {
|
|
|
1645
1671
|
* own store, in milliseconds; this bounds a hung twin, not a slow one). */
|
|
1646
1672
|
const ISSUE_TIMEOUT_MS = 15_000;
|
|
1647
1673
|
|
|
1674
|
+
/** Each required name belongs to this boot and gains a value only from its owner's scalar door response. */
|
|
1675
|
+
type RequiredCredentialClaims = Map<string, { service: string; value: string | undefined }>;
|
|
1676
|
+
|
|
1677
|
+
function requiredCredentialClaims(config: WorldConfig): RequiredCredentialClaims {
|
|
1678
|
+
const required: RequiredCredentialClaims = new Map();
|
|
1679
|
+
for (const service of config.services) for (const name of service.issues?.required ?? [])
|
|
1680
|
+
required.set(name, { service: service.id, value: undefined });
|
|
1681
|
+
return required;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** Bind names and owners to the actual values retained in instance.env; the hash is not vendor authentication. */
|
|
1685
|
+
function requiredCredentialValuesDigest(required: RequiredCredentialClaims): string {
|
|
1686
|
+
const rows = [...required.keys()].sort().map(name => {
|
|
1687
|
+
const claim = required.get(name)!;
|
|
1688
|
+
if (claim.value === undefined) throw new Error(`${name}: no issued value exists to retain`);
|
|
1689
|
+
return [name, claim.service, claim.value];
|
|
1690
|
+
});
|
|
1691
|
+
return createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** New boots retain their required owners even if source config later changes; legacy optional records gain no proof. */
|
|
1695
|
+
function recordedRequiredCredentialClaims(instance: WorldInstance): RequiredCredentialClaims {
|
|
1696
|
+
const required: RequiredCredentialClaims = new Map();
|
|
1697
|
+
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');
|
|
1698
|
+
const raw: unknown = instance.requiredCredentials;
|
|
1699
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('World required credential record is invalid');
|
|
1700
|
+
const record = raw as Record<string, unknown>;
|
|
1701
|
+
if (Object.keys(record).some(key => !['version', 'owners', 'valuesSha256'].includes(key))
|
|
1702
|
+
|| !Object.hasOwn(record, 'version') || record.version !== 1 || !Object.hasOwn(record, 'owners') || record.owners === null
|
|
1703
|
+
|| typeof record.owners !== 'object' || Array.isArray(record.owners))
|
|
1704
|
+
throw new Error('World required credential record has an unsupported version or shape');
|
|
1705
|
+
if (!instance.env || typeof instance.env !== 'object' || Array.isArray(instance.env)
|
|
1706
|
+
|| !instance.services || typeof instance.services !== 'object' || Array.isArray(instance.services))
|
|
1707
|
+
throw new Error('World required credential record lacks its retained environment or services');
|
|
1708
|
+
for (const [name, owner] of Object.entries(record.owners as Record<string, unknown>)) {
|
|
1709
|
+
if (!isEnvironmentName(name) || typeof owner !== 'string' || !owner || !Object.hasOwn(instance.services, owner))
|
|
1710
|
+
throw new Error(`World required credential owner is invalid for ${name}`);
|
|
1711
|
+
const service = instance.services[owner];
|
|
1712
|
+
if (!service || service.id !== owner || service.type !== 'twin') throw new Error(`World required credential ${name} names no twin owner`);
|
|
1713
|
+
const value = instance.env[name];
|
|
1714
|
+
if (!Object.hasOwn(instance.env, name) || typeof value !== 'string'
|
|
1715
|
+
|| value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV)
|
|
1716
|
+
throw new Error(`World required credential ${name} has no retained scalar value`);
|
|
1717
|
+
required.set(name, { service: owner, value });
|
|
1718
|
+
}
|
|
1719
|
+
if (!Object.hasOwn(record, 'valuesSha256')) {
|
|
1720
|
+
if (required.size) throw new Error('World required credential issuance has not completed; refusing attachment');
|
|
1721
|
+
return required;
|
|
1722
|
+
}
|
|
1723
|
+
if (typeof record.valuesSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(record.valuesSha256)
|
|
1724
|
+
|| record.valuesSha256 !== requiredCredentialValuesDigest(required))
|
|
1725
|
+
throw new Error('World required credential names, owners or retained values changed; refusing attachment');
|
|
1726
|
+
return required;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** Capture the validated boot's values before an owned executor applies its own environment layers. */
|
|
1730
|
+
export function worldRequiredCredentialGate(instance: WorldInstance): (env: NodeJS.ProcessEnv, boundary: string) => void {
|
|
1731
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1732
|
+
return (env, boundary) => assertRequiredCredentials(required, env, boundary);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** These transports do not carry required ownership/value authority; refuse instead of exporting an optional-only rendering. */
|
|
1736
|
+
export function refuseRequiredCredentialTransport(instance: WorldInstance, transport: string, code?: 'WORLD_MACHINE_CAPABILITY_GAP'): void {
|
|
1737
|
+
const required = recordedRequiredCredentialClaims(instance);
|
|
1738
|
+
if (required.size) {
|
|
1739
|
+
const error = new Error(`World ${instance.name}: ${transport} does not carry required credential bindings (${[...required.keys()].join(', ')}); use local env attachment`);
|
|
1740
|
+
throw code ? Object.assign(error, { code }) : error;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/** Check the actual response value, then the environment this boundary will use; never print credential values. */
|
|
1745
|
+
function assertRequiredCredentials(required: RequiredCredentialClaims, env: NodeJS.ProcessEnv, boundary: string): void {
|
|
1746
|
+
const failures: Error[] = [];
|
|
1747
|
+
for (const [name, claim] of required) {
|
|
1748
|
+
if (claim.value === undefined) failures.push(new Error(`${name}: ${claim.service} has not issued its required scalar credential`));
|
|
1749
|
+
else if (!Object.hasOwn(env, name) || env[name] !== claim.value)
|
|
1750
|
+
failures.push(new Error(`${name}: its issued value is missing or overridden at ${boundary}`));
|
|
1751
|
+
}
|
|
1752
|
+
if (failures.length) throw new AggregateError(failures, `World required credential gate refused at ${boundary}: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1648
1755
|
/** Where one credential ask's time went (S2, the twins' credentials): performance.now() at each step, read by the caller
|
|
1649
1756
|
* against its own origin. `door` is the pack's door read, `sent` the request handed to fetch, `headers` its answer's
|
|
1650
1757
|
* status, `answered` its body parsed, `filled` every name set or said unset. Door→sent and answered→filled include synchronous placeholder-pull
|
|
@@ -1653,22 +1760,33 @@ type CredentialAskTimes = { twin?: string; names?: number; door?: number; sent?:
|
|
|
1653
1760
|
|
|
1654
1761
|
/** A twin's issued credential (its `issues`, copied from its pack's credentialDoor by init): once it is up, the door is
|
|
1655
1762
|
* 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> {
|
|
1763
|
+
* door, leaves optional names unset and says so. Required scalar names fail the boot with their original reasons. */
|
|
1764
|
+
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
1765
|
const direct = new Set([...pending].filter(([, id]) => id === `${service.id}@direct`).map(([name]) => name));
|
|
1659
1766
|
const wanted = [...pending].filter(([name, id]) => id === service.id || direct.has(name)).map(([name]) => name);
|
|
1660
1767
|
if (times) { times.names = wanted.length; }
|
|
1768
|
+
const required = new Set(service.issues?.required ?? []);
|
|
1769
|
+
const failures: Error[] = [];
|
|
1770
|
+
for (const name of required) {
|
|
1771
|
+
if (!wanted.includes(name) || direct.has(name) || claims.get(name)?.service !== service.id)
|
|
1772
|
+
failures.push(new Error(`${name}: its exact $issue:${service.id} scalar request is absent at issuance`));
|
|
1773
|
+
}
|
|
1774
|
+
if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential requests refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1661
1775
|
if (wanted.length === 0) return;
|
|
1662
1776
|
for (const name of wanted) pending.delete(name);
|
|
1663
|
-
//
|
|
1664
|
-
|
|
1665
|
-
|
|
1777
|
+
// Revalidate aliases at the actual issuance boundary. A missing current pack door cannot revive a saved issuer.
|
|
1778
|
+
const door = (service.type ?? 'twin') === 'twin'
|
|
1779
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor).issues : service.issues;
|
|
1666
1780
|
if (times) times.door = performance.now();
|
|
1667
1781
|
try {
|
|
1668
1782
|
if (!door) throw new Error('its service names no credential door');
|
|
1669
1783
|
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
|
-
|
|
1784
|
+
// A boot's identity is existing account state, not an application write to deploy: asked inside the twin's
|
|
1785
|
+
// placeholder-pull window, as the seed is (architecture, the descriptor's credentialDoor), on a fresh World's first
|
|
1786
|
+
// issuance only: a resumed World's door answers what it issued before, and its branch keeps its own writes; a twin
|
|
1787
|
+
// with a root is not seeded, and its door's writes stay its own
|
|
1788
|
+
const seeds = fresh && !(service as { root?: unknown }).root;
|
|
1789
|
+
const enclosingSeed = !seeds || placeholderPullActive(service.id, controlRoot);
|
|
1672
1790
|
if (!enclosingSeed) beginPlaceholderPull(service.id, controlRoot);
|
|
1673
1791
|
let answer: Record<string, unknown>;
|
|
1674
1792
|
try {
|
|
@@ -1769,7 +1887,11 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1769
1887
|
continue;
|
|
1770
1888
|
}
|
|
1771
1889
|
const field = spec;
|
|
1890
|
+
if (required.has(name) && (!isScalarCredentialField(field) || answer === null || typeof answer !== 'object'
|
|
1891
|
+
|| Array.isArray(answer) || !Object.hasOwn(answer, field))) throw new Error(`answered no own scalar ${field}`);
|
|
1772
1892
|
const value = answer[field];
|
|
1893
|
+
if (required.has(name) && typeof value === 'string' && (value.startsWith(ISSUE_ENV_PREFIX) || value === APP_URL_ENV))
|
|
1894
|
+
throw new Error(`answered an interpreted environment marker for ${field}`);
|
|
1773
1895
|
if (typeof value !== 'string') throw new Error(`answered no ${field}`);
|
|
1774
1896
|
if (direct.has(name)) {
|
|
1775
1897
|
const issued = new URL(value);
|
|
@@ -1779,9 +1901,19 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1779
1901
|
issued.host = endpoint.host;
|
|
1780
1902
|
env[name] = issued.toString();
|
|
1781
1903
|
} else env[name] = value;
|
|
1904
|
+
if (required.has(name)) {
|
|
1905
|
+
const claim = claims.get(name);
|
|
1906
|
+
if (!claim || claim.service !== service.id) throw new Error('its required credential owner changed');
|
|
1907
|
+
claim.value = value;
|
|
1908
|
+
}
|
|
1782
1909
|
} catch (error) {
|
|
1783
|
-
process.stderr.write(`volter-world: ${service.id} did not issue ${name} (${errorMessage(error)}${typeof answer
|
|
1910
|
+
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
1911
|
delete env[name]; unissued.add(name);
|
|
1912
|
+
if (required.has(name)) {
|
|
1913
|
+
const claim = claims.get(name);
|
|
1914
|
+
if (claim) claim.value = undefined;
|
|
1915
|
+
failures.push(new Error(`${name}: ${service.id} did not issue its required credential (${errorMessage(error)})`, { cause: error }));
|
|
1916
|
+
}
|
|
1785
1917
|
}
|
|
1786
1918
|
}
|
|
1787
1919
|
if (started.url) {
|
|
@@ -1793,8 +1925,14 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1793
1925
|
} catch (error) {
|
|
1794
1926
|
process.stderr.write(`volter-world: ${service.id} did not issue ${wanted.join(', ')} (${errorMessage(error)}); left unset\n`);
|
|
1795
1927
|
for (const name of wanted) { delete env[name]; unissued.add(name); }
|
|
1928
|
+
for (const name of required) {
|
|
1929
|
+
const claim = claims.get(name);
|
|
1930
|
+
if (claim) claim.value = undefined;
|
|
1931
|
+
failures.push(new Error(`${name}: ${service.id}'s required credential ask failed (${errorMessage(error)})`, { cause: error }));
|
|
1932
|
+
}
|
|
1796
1933
|
}
|
|
1797
1934
|
if (times) times.filled = performance.now();
|
|
1935
|
+
if (failures.length) throw new AggregateError(failures, `World service ${service.id}: required credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
1798
1936
|
}
|
|
1799
1937
|
|
|
1800
1938
|
function materializeConfig(config: WorldConfig, root: string): WorldConfig {
|
|
@@ -1906,6 +2044,8 @@ async function startColocatedServices(
|
|
|
1906
2044
|
// the twins never read the application's own origin, nor a credential not yet issued
|
|
1907
2045
|
for (const [key, value] of Object.entries(env)) if (value === APP_URL_ENV) delete env[key];
|
|
1908
2046
|
dropUnissued(env);
|
|
2047
|
+
// the colocated twins' host runs the World's own servers: the application's NODE_ENV is not theirs (serviceProcessEnv)
|
|
2048
|
+
delete env.NODE_ENV;
|
|
1909
2049
|
env.VOLTER_WORLD_BOOT_ID = boot.bootId;
|
|
1910
2050
|
const spawnedAt = Date.now();
|
|
1911
2051
|
// 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 +2086,7 @@ async function startColocatedServices(
|
|
|
1946
2086
|
});
|
|
1947
2087
|
const ready = (async (): Promise<void> => {
|
|
1948
2088
|
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, () =>
|
|
2089
|
+
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
2090
|
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1951
2091
|
try { await signalHeldChild(child, 'SIGTERM', true, identity); } catch { /* best effort cleanup */ }
|
|
1952
2092
|
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${errorMessage(error)}\nLog: ${log}`);
|
|
@@ -2051,6 +2191,11 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2051
2191
|
// instead of loading and rebuilding a second facts/host table on every empty up.
|
|
2052
2192
|
if (packFactsPaths.length) createRequire(import.meta.url)('@volter/world-core/pack-facts').extendPackFacts(packFactsPaths);
|
|
2053
2193
|
const loaded = { path: found.path, config: materializeConfig(found.config, root) };
|
|
2194
|
+
// Before any output claim or lifecycle write, every saved alias fits the actual current pack.
|
|
2195
|
+
loaded.config.services = loaded.config.services.map(service => (service.type ?? 'twin') === 'twin'
|
|
2196
|
+
? withCurrentCredentialDoor(service, packFacts()[service.id]?.credentialDoor) : service);
|
|
2197
|
+
assertRequiredCredentialBindings(loaded.config, found.path);
|
|
2198
|
+
const required = requiredCredentialClaims(loaded.config);
|
|
2054
2199
|
const name = options.name ?? loaded.config.id;
|
|
2055
2200
|
const mode = options.mode ?? 'local';
|
|
2056
2201
|
assertSafeWorldName(name);
|
|
@@ -2155,7 +2300,6 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2155
2300
|
// Verified teardown is independent of record shape. A fresh boot discards incomplete metadata.
|
|
2156
2301
|
if (existsSync(instanceFile)) {
|
|
2157
2302
|
let prior: WorldInstance | undefined;
|
|
2158
|
-
if (options.keepState) prior = readWorldBootRecordLocked(name, root);
|
|
2159
2303
|
const priorRaw = readFileSync(instanceFile, 'utf8');
|
|
2160
2304
|
const receiptPath = join(instance, 'stopped.json');
|
|
2161
2305
|
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : undefined;
|
|
@@ -2163,6 +2307,13 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2163
2307
|
|| !Number.isFinite(Date.parse(receipt.stoppedAt))) {
|
|
2164
2308
|
throw new Error(`World "${name}" has no verified teardown receipt for its prior instance; use its owning runtime to complete cleanup before replacement`);
|
|
2165
2309
|
}
|
|
2310
|
+
// a resume reads the record its last boot completed; a boot that failed and was rolled back (the receipt above)
|
|
2311
|
+
// completed none, and the resume writes a complete one over the kept state (architecture, the boot record)
|
|
2312
|
+
let priorIncomplete = false;
|
|
2313
|
+
if (options.keepState) {
|
|
2314
|
+
try { prior = readWorldBootRecordLocked(name, root); }
|
|
2315
|
+
catch { priorIncomplete = true; }
|
|
2316
|
+
}
|
|
2166
2317
|
if (!prior) { try { prior = JSON.parse(priorRaw) as WorldInstance; } catch { /* fresh boot needs no retained shape */ } }
|
|
2167
2318
|
if (prior) {
|
|
2168
2319
|
// A readable ceiling remains useful even when unrelated metadata is incomplete.
|
|
@@ -2175,7 +2326,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2175
2326
|
}
|
|
2176
2327
|
const ceiling = recordedWorldNetwork(permissionRecord);
|
|
2177
2328
|
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}`); }
|
|
2329
|
+
} catch { if (options.keepState && !priorIncomplete) throw new Error(`World boot record is incomplete or invalid: ${instanceFile}`); }
|
|
2179
2330
|
}
|
|
2180
2331
|
// the origin belongs to the World, not to its running state (git: a remote outlives every checkout): an up of the
|
|
2181
2332
|
// same config carries the one the last left while world.json's `origin` remote still names it (`remote remove`
|
|
@@ -2277,7 +2428,10 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2277
2428
|
const unissued = new Set<string>();
|
|
2278
2429
|
writeProxyEnv(root, name, env);
|
|
2279
2430
|
|
|
2431
|
+
const requiredCredentials: NonNullable<WorldInstance['requiredCredentials']> = { version: 1,
|
|
2432
|
+
owners: Object.fromEntries([...required].map(([name, claim]) => [name, claim.service])) };
|
|
2280
2433
|
const worldInstance: WorldInstance = {
|
|
2434
|
+
requiredCredentials,
|
|
2281
2435
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2282
2436
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2283
2437
|
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
@@ -2352,14 +2506,15 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2352
2506
|
// own sent and status says that twin's door.
|
|
2353
2507
|
const origin = performance.now();
|
|
2354
2508
|
const steps: Array<{ id: string; queued: number; times: CredentialAskTimes }> = [];
|
|
2355
|
-
|
|
2509
|
+
// A descriptor refusal still settles every owned ask before rollback can retire its custody.
|
|
2510
|
+
const asks = await Promise.allSettled(host.services.map(async (started) => {
|
|
2356
2511
|
const declared = colocated.find((s) => s.id === started.id);
|
|
2357
2512
|
if (!declared) return undefined;
|
|
2358
2513
|
const began = performance.now();
|
|
2359
2514
|
const times: CredentialAskTimes = { ...(declared.package ? { twin: declared.package } : {}) };
|
|
2360
2515
|
steps.push({ id: started.id, queued: began, times });
|
|
2361
2516
|
const own: Record<string, string> = { ...before };
|
|
2362
|
-
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), times);
|
|
2517
|
+
await issueCredentials(declared, started, own, issuing, unissued, ownedPath(data, started.id), required, !options.keepState, times);
|
|
2363
2518
|
return { id: started.id, ms: Math.round(performance.now() - began), own };
|
|
2364
2519
|
}));
|
|
2365
2520
|
const at = (time: number | undefined): string => time === undefined ? '-' : `+${Math.round(time - origin)}`;
|
|
@@ -2368,13 +2523,17 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2368
2523
|
.sort((left, right) => (right.times.filled ?? 0) - (left.times.filled ?? 0))
|
|
2369
2524
|
.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
2525
|
const asked: Array<{ id: string; ms: number }> = [];
|
|
2371
|
-
|
|
2526
|
+
const failures: unknown[] = [];
|
|
2527
|
+
for (const result of asks) {
|
|
2528
|
+
if (result.status === 'rejected') { failures.push(result.reason); continue; }
|
|
2529
|
+
const ask = result.value;
|
|
2372
2530
|
if (!ask) continue;
|
|
2373
2531
|
for (const [key, value] of Object.entries(ask.own)) if (before[key] !== value) env[key] = value;
|
|
2374
2532
|
for (const key of Object.keys(before)) if (!Object.hasOwn(ask.own, key)) delete env[key];
|
|
2375
2533
|
asked.push({ id: ask.id, ms: ask.ms });
|
|
2376
2534
|
}
|
|
2377
2535
|
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`);
|
|
2536
|
+
if (failures.length) throw new AggregateError(failures, `World credential issuance refused: ${failures.map(reason => errorMessage(reason)).join('; ')}`);
|
|
2378
2537
|
writeProxyEnv(root, name, env);
|
|
2379
2538
|
if (colocated.length) boot.progress('co-located twins: credentials issued, proxy environment written');
|
|
2380
2539
|
};
|
|
@@ -2387,7 +2546,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2387
2546
|
for (const service of loaded.config.services) {
|
|
2388
2547
|
if (services[service.id] || !ownInfrastructureService(service)) continue;
|
|
2389
2548
|
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);
|
|
2549
|
+
const starting = startService(loaded.config, service, { ...env }, { root, instance, logs, data }, allocatedPorts, boot, required);
|
|
2391
2550
|
starting.catch((error: unknown) => boot.fail(error instanceof Error ? error : new Error(errorMessage(error))));
|
|
2392
2551
|
early.set(service.id, starting);
|
|
2393
2552
|
}
|
|
@@ -2429,18 +2588,20 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2429
2588
|
attemptProxy();
|
|
2430
2589
|
boot.signal.throwIfAborted();
|
|
2431
2590
|
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));
|
|
2591
|
+
const started = await (starting ?? startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot, required));
|
|
2433
2592
|
// An early start published its teardown when it began; its record moves to its declared position.
|
|
2434
2593
|
if (starting) delete services[service.id];
|
|
2435
2594
|
recordService(started);
|
|
2436
2595
|
recordPidsSoFar();
|
|
2437
2596
|
Object.assign(env, started.env);
|
|
2438
|
-
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id));
|
|
2597
|
+
await issueCredentials(service, started, env, issuing, unissued, ownedPath(data, started.id), required, !options.keepState);
|
|
2439
2598
|
writeProxyEnv(root, name, env);
|
|
2440
2599
|
boot.progress(`${service.id}: started, its environment merged`);
|
|
2441
2600
|
}
|
|
2442
2601
|
if (colocatedReady) await colocatedReady();
|
|
2443
|
-
|
|
2602
|
+
// An abort records its reason while the final owned credential asks settle. No later service may remain
|
|
2603
|
+
// to observe it, so completion itself must reject it before publishing a successful environment.
|
|
2604
|
+
boot.signal.throwIfAborted();
|
|
2444
2605
|
// a name a service of no twin in this World issues (a config edited after init) is left unset, and said
|
|
2445
2606
|
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
2607
|
for (const [name] of issuing) unissued.add(name);
|
|
@@ -2450,6 +2611,7 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2450
2611
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
2451
2612
|
// config test below exercises exactly this.
|
|
2452
2613
|
attemptProxy();
|
|
2614
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2453
2615
|
// Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
|
|
2454
2616
|
// lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
|
|
2455
2617
|
// Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
|
|
@@ -2458,6 +2620,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2458
2620
|
process.stderr.write(`${warning}\n`);
|
|
2459
2621
|
}
|
|
2460
2622
|
|
|
2623
|
+
// This can await the browser materializer's import. Keep boot cancellation attached through that work,
|
|
2624
|
+
// and do not persist completed credential claims or publish the environment until it has finished.
|
|
2625
|
+
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2626
|
+
boot.signal.throwIfAborted();
|
|
2627
|
+
assertRequiredCredentials(required, env, 'World environment publication');
|
|
2628
|
+
requiredCredentials.valuesSha256 = requiredCredentialValuesDigest(required);
|
|
2461
2629
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2462
2630
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2463
2631
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
@@ -2468,11 +2636,12 @@ async function upWorldRecorded(configId: string, options: UpWorldOptions, abort:
|
|
|
2468
2636
|
delete worldInstance.previousEnvOutput;
|
|
2469
2637
|
saveWorldInstance(worldInstance);
|
|
2470
2638
|
boot.progress(`environment written: ${envFile}`);
|
|
2471
|
-
await materializeWorldBrowserFiles(data, root, name, loaded.config.id);
|
|
2472
2639
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
2473
2640
|
saveWorldInstance(worldInstance);
|
|
2474
2641
|
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
2642
|
+
boot.signal.throwIfAborted();
|
|
2475
2643
|
handoffWorldLifecycle(root, name, servicePids);
|
|
2644
|
+
detachBootSignals();
|
|
2476
2645
|
boot.progress(`returning: the World is up; ${lag.account()}`);
|
|
2477
2646
|
if (mode === 'share') {
|
|
2478
2647
|
try {
|
|
@@ -2692,6 +2861,24 @@ function killHeldSurvivorsSync(child: ReturnType<typeof spawn>, graceMs: number)
|
|
|
2692
2861
|
* purge leaves that lock alone; its owner's finally releases it, so waiting claimants never
|
|
2693
2862
|
* enter through a replacement lock while the original is still held.
|
|
2694
2863
|
*/
|
|
2864
|
+
/** A purged World's env output outside its folder (architecture, "World environment exports are outputs"): removed with
|
|
2865
|
+
* the record that proved it the World's own, when it still holds exactly the World's exports as a single-link regular
|
|
2866
|
+
* file and no other World of the root records that path; anything else stays. */
|
|
2867
|
+
function purgeEnvOutput(root: string, name: string, purged: string, instance: WorldInstance): void {
|
|
2868
|
+
const path = typeof instance.envFile === 'string' ? resolve(instance.envFile) : undefined;
|
|
2869
|
+
if (!path || !relative(purged, path).startsWith('..')) return;
|
|
2870
|
+
const others = existsSync(worldBaseDir(root)) ? readdirSync(worldBaseDir(root)).filter((world) => world !== name && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(world)) : [];
|
|
2871
|
+
const shared = others.some((world) => {
|
|
2872
|
+
try { return resolve((JSON.parse(readFileSync(join(instanceDir(root, world), 'instance.json'), 'utf8')) as WorldInstance).envFile) === path; } catch { return false; }
|
|
2873
|
+
});
|
|
2874
|
+
if (shared) return;
|
|
2875
|
+
try {
|
|
2876
|
+
const named = lstatSync(path);
|
|
2877
|
+
if (!named.isFile() || named.nlink !== 1 || !readFileSync(path).equals(Buffer.from(envFileContents(instance.env)))) return;
|
|
2878
|
+
rmSync(path);
|
|
2879
|
+
} catch { /* no output, or one the World cannot read: it stays */ }
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2695
2882
|
function purgeInstanceDir(root: string, name: string): string {
|
|
2696
2883
|
assertSafeWorldName(name);
|
|
2697
2884
|
const base = worldBaseDir(root);
|
|
@@ -2922,6 +3109,7 @@ async function downWorldClaimed(
|
|
|
2922
3109
|
if (!options.purge) return result;
|
|
2923
3110
|
const purged = purgeInstanceDir(resolvedRoot, name);
|
|
2924
3111
|
if (purged && instance) clearBrowserFiles!(instance.dirs.data, resolvedRoot);
|
|
3112
|
+
if (purged && instance) purgeEnvOutput(resolvedRoot, name, purged, instance);
|
|
2925
3113
|
return { ...result, ...(externalAbandoned ? { externalAbandoned } : {}), purged };
|
|
2926
3114
|
}));
|
|
2927
3115
|
}
|
|
@@ -3597,6 +3785,14 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
3597
3785
|
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3598
3786
|
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3599
3787
|
: 'declared boot services have instance records' });
|
|
3788
|
+
// a vendor-backed root whose vendor rotated its refresh token while the seal failed (world-core head.ts
|
|
3789
|
+
// unsealedMarkerOf): it renews only inside the process that held the new one, so a person must seal it again
|
|
3790
|
+
const credentialDir = join(root, stateDirName(), 'credentials');
|
|
3791
|
+
for (const file of existsSync(credentialDir) ? readdirSync(credentialDir).filter((f) => f.endsWith('.unsealed.json')).sort() : []) {
|
|
3792
|
+
let said = '';
|
|
3793
|
+
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 */ }
|
|
3794
|
+
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` });
|
|
3795
|
+
}
|
|
3600
3796
|
|
|
3601
3797
|
if (instance.lifecycle) {
|
|
3602
3798
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
|
@@ -3888,8 +4084,9 @@ export function listWorlds(root = process.cwd()): Array<{ name: string; running:
|
|
|
3888
4084
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
3889
4085
|
}
|
|
3890
4086
|
|
|
3891
|
-
function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv } {
|
|
4087
|
+
function worldAttachedCommandEnv(name: string, root: string, verbose = false, instance?: WorldInstance): { instance: WorldInstance; env: NodeJS.ProcessEnv; required: RequiredCredentialClaims } {
|
|
3892
4088
|
const status = instance ?? statusWorld(name, root);
|
|
4089
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
3893
4090
|
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed' || status.env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
3894
4091
|
const baseEnv = { ...admittedEnv(stripEnvOf(status.env)), ...status.env };
|
|
3895
4092
|
let proxyEnv: Record<string, string> = {};
|
|
@@ -3904,8 +4101,10 @@ function worldAttachedCommandEnv(name: string, root: string, verbose = false, in
|
|
|
3904
4101
|
if (sealed && hostHasRawEgress()) throw new Error(`world "${name}" requires its network policy proxy: attachment proxy failed; refusing to run the command (${errorMessage(error)})`);
|
|
3905
4102
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
3906
4103
|
}
|
|
3907
|
-
|
|
3908
|
-
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' })
|
|
4104
|
+
const env = withAppUrl(name, root, { ...baseEnv, ...proxyEnv, VOLTER_WORLD: name,
|
|
4105
|
+
VOLTER_TWIN_INJECT_QUIET: verbose ? '0' : baseEnv.VOLTER_TWIN_INJECT_QUIET ?? '1' });
|
|
4106
|
+
assertRequiredCredentials(required, env, 'command attachment');
|
|
4107
|
+
return { instance: status, env, required };
|
|
3909
4108
|
}
|
|
3910
4109
|
|
|
3911
4110
|
/** The env with each `$app-url` value made the application URL its booter recorded. Unrecorded, the name is left
|
|
@@ -3929,10 +4128,12 @@ export function withAppUrl<E extends Record<string, string | undefined>>(name: s
|
|
|
3929
4128
|
export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string; verbose?: boolean; env?: Record<string, string> } = {}): Promise<number> {
|
|
3930
4129
|
if (command.length === 0) throw new Error('Missing command after --');
|
|
3931
4130
|
const attached = worldAttachedCommandEnv(name, root, options.verbose);
|
|
4131
|
+
const env = { ...attached.env, ...(options.env ?? {}) };
|
|
4132
|
+
assertRequiredCredentials(attached.required, env, 'SDK command environment');
|
|
3932
4133
|
return new Promise((resolveExit) => {
|
|
3933
4134
|
const child = spawn(command[0]!, command.slice(1), { windowsHide: true,
|
|
3934
4135
|
cwd: resolve(options.cwd ?? root),
|
|
3935
|
-
env
|
|
4136
|
+
env,
|
|
3936
4137
|
stdio: 'inherit',
|
|
3937
4138
|
});
|
|
3938
4139
|
const forward = (signal: NodeJS.Signals) => {
|
|
@@ -3998,6 +4199,8 @@ export async function attachWorld(name: string, command: string[], root = proces
|
|
|
3998
4199
|
// Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
|
|
3999
4200
|
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
4201
|
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`);
|
|
4202
|
+
// A supplied instance is a snapshot, not authority to erase the current boot's required bindings.
|
|
4203
|
+
assertRequiredCredentials(recordedRequiredCredentialClaims(current), attached.env, 'registered command attachment');
|
|
4001
4204
|
// Attaching to a World whose services died is allowed (a command may not need them), but never silent.
|
|
4002
4205
|
for (const [id, state] of Object.entries(current.serviceStates)) {
|
|
4003
4206
|
if (state === 'running' || state === 'external') continue;
|
|
@@ -4139,13 +4342,21 @@ async function runWithWorldEnvLogged(name: string, command: string[], root: stri
|
|
|
4139
4342
|
* TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
|
|
4140
4343
|
* since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
|
|
4141
4344
|
export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
|
|
4345
|
+
const { shell, cwd, env } = worldShellContext(name, root);
|
|
4346
|
+
return { shell, cwd, env };
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
/** Keep the boot's required proof with the shell environment until its proxy transformations have finished. */
|
|
4350
|
+
function worldShellContext(name: string, root: string): { shell: string; cwd: string; env: Record<string, string>; required: RequiredCredentialClaims } {
|
|
4142
4351
|
const status = statusWorld(name, root);
|
|
4143
4352
|
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
|
-
|
|
4353
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4354
|
+
const env = withAppUrl(name, root, { ...admittedEnv(stripEnvOf(status.env)), ...status.env, VOLTER_WORLD: name } as Record<string, string>);
|
|
4355
|
+
assertRequiredCredentials(required, env, 'shell environment');
|
|
4356
|
+
// Windows has no /bin/bash: its shell is the one ComSpec names (cmd.exe), kept by the Windows baseline (admittedEnv).
|
|
4357
|
+
const inherited = admittedEnv(stripEnvOf(status.env));
|
|
4358
|
+
const fallback = process.platform === 'win32' ? (inherited.ComSpec ?? inherited.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe') : '/bin/bash';
|
|
4359
|
+
return { shell: status.env.SHELL || inherited.SHELL || fallback, cwd: resolve(root), env, required };
|
|
4149
4360
|
}
|
|
4150
4361
|
|
|
4151
4362
|
/** Drop into an interactive subshell with the world active — the contained sibling of `activate`
|
|
@@ -4155,7 +4366,7 @@ export function worldShellEnv(name: string, root = process.cwd()): { shell: stri
|
|
|
4155
4366
|
* land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
|
|
4156
4367
|
* trusted ONLY via this subshell's env — never installed system-wide. See docs/guides/route-a-cli-through-the-world.md. */
|
|
4157
4368
|
export async function shellWorld(name: string, root = process.cwd()): Promise<number> {
|
|
4158
|
-
const { shell, cwd, env } =
|
|
4369
|
+
const { shell, cwd, env, required } = worldShellContext(name, root);
|
|
4159
4370
|
const resolvedRoot = resolve(root);
|
|
4160
4371
|
let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
|
|
4161
4372
|
const sealed = env.VOLTER_WORLD_MODE === 'sealed' || env[WORLD_NETWORK_POLICY_ENV] !== undefined;
|
|
@@ -4176,13 +4387,25 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
4176
4387
|
if (sealed) throw new Error(`world "${name}" requires its network policy proxy: openssl is unavailable; refusing to open a shell`);
|
|
4177
4388
|
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
4389
|
}
|
|
4390
|
+
let failed = false;
|
|
4391
|
+
let failure: unknown;
|
|
4179
4392
|
try {
|
|
4393
|
+
assertRequiredCredentials(required, env, 'interactive shell');
|
|
4180
4394
|
const result = spawnSync(shell, ['-i'], { windowsHide: true, cwd, env, stdio: 'inherit' });
|
|
4181
|
-
|
|
4395
|
+
if (result.error) throw result.error;
|
|
4396
|
+
if (result.signal) throw new Error(`World shell ended by ${result.signal}`);
|
|
4397
|
+
if (result.status === null) throw new Error('World shell returned no exit status');
|
|
4398
|
+
return result.status;
|
|
4399
|
+
} catch (error) {
|
|
4400
|
+
failed = true; failure = error;
|
|
4401
|
+
throw error;
|
|
4182
4402
|
} finally {
|
|
4183
4403
|
if (proxy) {
|
|
4184
|
-
await proxy.close();
|
|
4185
|
-
|
|
4404
|
+
try { await proxy.close(); tearDownCa(tlsDir(resolvedRoot, name)); }
|
|
4405
|
+
catch (cleanup) {
|
|
4406
|
+
if (failed) throw new AggregateError([failure, cleanup], `World shell failed (${errorMessage(failure)}); proxy cleanup also failed (${errorMessage(cleanup)})`, { cause: failure });
|
|
4407
|
+
throw cleanup;
|
|
4408
|
+
}
|
|
4186
4409
|
}
|
|
4187
4410
|
}
|
|
4188
4411
|
}
|
|
@@ -4446,6 +4669,7 @@ function clearWorldProxyFiles(root: string, name: string): void {
|
|
|
4446
4669
|
*/
|
|
4447
4670
|
export function activateScript(name: string, root = process.cwd()): string {
|
|
4448
4671
|
const status = statusWorld(name, root);
|
|
4672
|
+
const required = recordedRequiredCredentialClaims(status);
|
|
4449
4673
|
if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-out <path> --name ${name}`);
|
|
4450
4674
|
const baseEnv = status.env ?? {};
|
|
4451
4675
|
// Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
|
|
@@ -4465,6 +4689,8 @@ export function activateScript(name: string, root = process.cwd()): string {
|
|
|
4465
4689
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
4466
4690
|
}
|
|
4467
4691
|
env = withAppUrl(name, root, env);
|
|
4692
|
+
// Exports follow the bookkeeping assignments; the final prompt assignment is the one later value change.
|
|
4693
|
+
assertRequiredCredentials(required, { ...env, PS1: `(world:${name}) ${env.PS1 ?? ''}` }, 'shell activation including its prompt');
|
|
4468
4694
|
const keys = Object.keys(env).sort();
|
|
4469
4695
|
const lines: string[] = [
|
|
4470
4696
|
`# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
|