@volter/world-runtime 2.0.25 → 2.0.27

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/src/cli.js CHANGED
@@ -8,7 +8,7 @@ import { dirname, join, resolve } from 'node:path';
8
8
  import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, resetWorld, seedWorld, worldOrigin, approveWorldChangeset, checkPrerequisites, coverWorld, createWorldChangeset, diffWorld, doctorWorld, downWorld, findWorldChangeset, formatCoverageReport, formatInitReport, formatPrerequisiteChecks, formatProjectInspection, initWorld, inspectProject, isRemoteWorldRef, listWorldChangesets, listWorlds, markWorld, migrateWorldConfig, readReflectRoutes, reflectRoutesPath, replayWorldChangeset, resolveWorldRef, attachWorld, retireWorldConsumers, runWorld, shareWorldServices, shellWorld, startReflectFront, startReflectResolver, statusWorld, statusWorldChangeset, tailWorldActions, unshareWorld, urlsWorld, urlWorld, verifyWorldChangeset, worldManifest, writeReflectRoutes, readReflectManifest, writeReflectManifest, clearReflectManifest, composeOverrideForReflect, splitDockerComposeArgs, dockerComposeWithOverride, ATTACHED_CA_PATH } from "./index.js";
9
9
  import { formatApplication, formatChangeset, formatRebaseReport, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, parseVerifierExpression } from '@volter/world-core';
10
10
  import { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer } from "./serve.js";
11
- import { clockFile, instanceDir, pruneWorlds } from "./runtime.js";
11
+ import { clockFile, instanceDir, pruneWorlds, withoutVaultSignIn } from "./runtime.js";
12
12
  import { worldResourceReport } from "./resources.js";
13
13
  import { worldConsumers, worldUsage } from "./consumers.js";
14
14
  import { addAppHosts, appUrlUnsetMessage, detectAppUrl, readAppUrl, setAppUrl } from "./app-url.js";
@@ -990,7 +990,7 @@ async function main() {
990
990
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge');
991
991
  const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
992
992
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
993
- const commandEnv = { ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref };
993
+ const commandEnv = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
994
994
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
995
995
  const child = spawn(command[0], command.slice(1), { windowsHide: true, env: commandEnv, stdio: 'inherit' });
996
996
  const code = await new Promise((resolveExit) => { child.on('exit', (status) => resolveExit(status ?? 1)); child.on('error', () => resolveExit(127)); });
@@ -1012,7 +1012,7 @@ async function main() {
1012
1012
  const manifest = readReflectManifest(attachRoot, resolved.ref);
1013
1013
  if (!manifest)
1014
1014
  throw new Error(`volter-world attach --via reflect: no reflect front is running for world ${resolved.ref} — start one: volter-world reflect ${resolved.ref} --target-ip <ip> --port 443 --resolver-port 53`);
1015
- const env = { ...process.env, VOLTER_WORLD: resolved.ref, VOLTER_REFLECT_DNS: manifest.targetIp, VOLTER_WORLD_CA: manifest.caCertPath };
1015
+ const env = { ...withoutVaultSignIn(process.env), VOLTER_WORLD: resolved.ref, VOLTER_REFLECT_DNS: manifest.targetIp, VOLTER_WORLD_CA: manifest.caCertPath };
1016
1016
  Object.assign(env, sessionTrustEnv(manifest.caCertPath, env));
1017
1017
  let toRun = command;
1018
1018
  const compose = splitDockerComposeArgs(command);
@@ -13,6 +13,7 @@ import { keepProcessAlive } from '@volter/world-core/lifecycle';
13
13
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { startColocatedHost } from "./host.js";
16
+ import { publishHostReady } from "./host-ready.js";
16
17
  const argv = process.argv.slice(2);
17
18
  const opt = (name, fb = '') => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fb; };
18
19
  const isolation = opt('--isolation', 'shared');
@@ -85,10 +86,20 @@ const host = await startColocatedHost(specs, {
85
86
  recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
86
87
  },
87
88
  });
88
- for (const [id, port] of Object.entries(host.ports))
89
- process.stdout.write(`ready id=${id} port=${port}\n`);
90
- process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
91
89
  const shutdown = async () => { await host.stop(); process.exit(0); };
92
90
  process.on('SIGTERM', shutdown);
93
91
  process.on('SIGINT', shutdown);
92
+ const readyFile = opt('--ready-file');
93
+ if (readyFile) {
94
+ try {
95
+ publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports);
96
+ }
97
+ catch (error) {
98
+ await host.stop();
99
+ throw error;
100
+ }
101
+ }
102
+ for (const [id, port] of Object.entries(host.ports))
103
+ process.stdout.write(`ready id=${id} port=${port}\n`);
104
+ process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
94
105
  await keepProcessAlive();
@@ -0,0 +1,4 @@
1
+ /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
2
+ export declare function publishHostReady(path: string, boot: string, ports: Record<string, number>): void;
3
+ /** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
4
+ export declare function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
4
+ export function publishHostReady(path, boot, ports) {
5
+ if (!boot)
6
+ throw new Error('A co-located host readiness receipt requires its World boot identity.');
7
+ mkdirSync(dirname(path), { recursive: true });
8
+ const temporary = `${path}.${process.pid}.tmp`;
9
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
10
+ renameSync(temporary, path);
11
+ }
12
+ /** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
13
+ export async function waitForHostReady(path, boot, ports, timeoutMs, abort) {
14
+ const started = Date.now();
15
+ for (;;) {
16
+ abort.throwIfAborted();
17
+ let bytes;
18
+ try {
19
+ bytes = readFileSync(path);
20
+ }
21
+ catch (error) {
22
+ if (error.code !== 'ENOENT')
23
+ throw error;
24
+ }
25
+ if (bytes) {
26
+ if (bytes.byteLength > 256 * 1024)
27
+ throw new Error('The co-located host readiness receipt exceeds its bounds.');
28
+ const value = JSON.parse(bytes.toString('utf8'));
29
+ if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
30
+ throw new Error('The co-located host readiness receipt does not belong to this boot.');
31
+ }
32
+ const answered = value.ports;
33
+ if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
34
+ throw new Error('The co-located host readiness receipt does not cover its declared ports.');
35
+ }
36
+ return;
37
+ }
38
+ if (Date.now() - started >= timeoutMs)
39
+ throw new Error(`The co-located host did not complete every declared start within ${timeoutMs} ms.`);
40
+ await new Promise((resolve, reject) => {
41
+ const cancel = () => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
42
+ const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
43
+ abort.addEventListener('abort', cancel, { once: true });
44
+ if (abort.aborted)
45
+ cancel();
46
+ });
47
+ }
48
+ }
@@ -39,7 +39,7 @@ export { fetchFromOrigin, worldOrigin } from './origin.js';
39
39
  export type { FetchOptions, FetchOutcome } from './origin.js';
40
40
  export { branchWorld, checkoutWorld } from './branch.js';
41
41
  export type { BranchOptions } from './branch.js';
42
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
42
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.js';
43
43
  export type { DeployTwinOutcome, MaterializedRoot } from './root.js';
44
44
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.js';
45
45
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
@@ -51,3 +51,4 @@ export type { LocalBranchesOptions } from './local-branches.js';
51
51
  export type { ServedWorld } from './served-world.js';
52
52
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.js';
53
53
  export { assertWorldStateRemovable } from './ancestry.js';
54
+ export { withoutVaultSignIn } from './runtime.js';
package/dist/src/index.js CHANGED
@@ -29,10 +29,11 @@ export { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttac
29
29
  export { resetWorld, seedEntryFor, seedWorld } from "./placeholder.js";
30
30
  export { fetchFromOrigin, worldOrigin } from "./origin.js";
31
31
  export { branchWorld, checkoutWorld } from "./branch.js";
32
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
32
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
33
33
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
34
34
  export { LocalBranches } from "./local-branches.js";
35
35
  export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
36
36
  export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
37
37
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
38
38
  export { assertWorldStateRemovable } from "./ancestry.js";
39
+ export { withoutVaultSignIn } from "./runtime.js";
@@ -202,6 +202,15 @@ export declare const APP_READ_ENDPOINT_ENV: Record<string, {
202
202
  /** Render the private managed-infrastructure definition — deterministic, with no absolute paths.
203
203
  * Only meaningful when `plan.compose` is non-empty (writeWorldInit skips it otherwise). */
204
204
  export declare function renderComposeFile(plan: InitPlan): string;
205
+ export type ParsedEnvEntry = {
206
+ name: string;
207
+ value: string;
208
+ source: string;
209
+ };
210
+ /** Parse one dotenv-ish file into ordered NAME/value pairs. Deliberately conservative: the same
211
+ * `NAME=` line shape `projectEnvNames` detects, plus quote stripping, so the two views of a repo's
212
+ * env agree on which names exist. */
213
+ export declare function parseEnvFile(text: string, source: string): ParsedEnvEntry[];
205
214
  export declare function planWorldInit(name: string, repoPath: string, options?: InitOptions): InitPlan;
206
215
  /** The `app.env` rendering of the SAME map the config's `env` block holds — a dotenv for anything
207
216
  * attached to the World (a shell, app process, or CI step), annotated with the
package/dist/src/init.js CHANGED
@@ -409,16 +409,17 @@ export function renderComposeFile(plan) {
409
409
  /** Parse one dotenv-ish file into ordered NAME/value pairs. Deliberately conservative: the same
410
410
  * `NAME=` line shape `projectEnvNames` detects, plus quote stripping, so the two views of a repo's
411
411
  * env agree on which names exist. */
412
- function parseEnvFile(text, source) {
412
+ export function parseEnvFile(text, source) {
413
413
  const entries = [];
414
414
  for (const line of text.split(/\r?\n/)) {
415
415
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$/);
416
416
  if (!match)
417
417
  continue;
418
418
  let value = match[2].trim();
419
- if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2)
420
- || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
421
- value = value.slice(1, -1);
419
+ // A quoted value ends at its closing quote; what follows it (` # a comment`) is not the value.
420
+ const quoted = value.match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
421
+ if (quoted) {
422
+ value = quoted[2];
422
423
  }
423
424
  else {
424
425
  // Unquoted: a `#` after whitespace opens a trailing comment (dotenv semantics). Without this
@@ -15,6 +15,18 @@ export declare function sealedCredentialInfo(worldRoot: string, vendor: string):
15
15
  } | null;
16
16
  /** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
17
17
  export declare function twinSigningSecret(worldRoot: string, vendor: string): Promise<string | null>;
18
+ /**
19
+ * A root's credential when none is piped in. A vault is never required (decision 0033): signed in, the vault's
20
+ * `kv/vendors/<vendor>` record (its `credential` field: a token, or the payload's JSON) is the real credential, and a
21
+ * vault that answers but refuses stops the seal rather than falling back; otherwise, or when the vault holds none or
22
+ * cannot be reached, the one credential the repo's `.env` files hold for the vendor (`.env.local` over `.env`, root
23
+ * and workspace members). `notes` say why a source was passed over, and are always shown.
24
+ */
25
+ export declare function rootCredentialInput(worldRoot: string, vendor: string, env?: NodeJS.ProcessEnv): Promise<{
26
+ input: string | null;
27
+ from: string;
28
+ notes: string[];
29
+ }>;
18
30
  /** A bare token becomes the vendor's own header. A JSON object is taken as the payload. */
19
31
  export declare function credentialPayloadFrom(input: string, vendor: string): CredentialPayload;
20
32
  /** The root config carried in the state dir also names where the credential is sealed, so the
package/dist/src/root.js CHANGED
@@ -8,14 +8,19 @@
8
8
  // root through the pack's adapters over the kernel executor, with the world's checks in front;
9
9
  // `deploy: auto` does the same the moment an entry is appended (the host installs the hook).
10
10
  import { packEntry } from "./catalog.js";
11
- import { existsSync, readdirSync } from 'node:fs';
12
- import { dirname, join, resolve } from 'node:path';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join, relative, resolve } from 'node:path';
13
14
  import { authStrategyFor, getActiveWorldStore, getPack, sealingKey, buildRemoteExecute, clearRoot, openSealedCredential, readRoot, registerStateSystem, sealCredential, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
14
15
  import { loadWorldConfig, writeWorldConfig } from "./configs.js";
15
16
  import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
16
17
  import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
17
18
  import { importModule } from "./import-module.js";
18
19
  import { statusWorld } from "./runtime.js";
20
+ import { envNameVendor } from "./covers.js";
21
+ import { parseEnvFile } from "./init.js";
22
+ import { fakeEnvValue } from "./fixture-env.js";
23
+ import { projectManifestDirs } from "./project-inspect.js";
19
24
  import { findInstalledPackage, twinPackageName } from "./catalog.js";
20
25
  // ── the user's sealing key ──────────────────────────────────────────────────────────────────
21
26
  /** `$XDG_CONFIG_HOME/volter/kek`, else `~/.config/volter/kek`: the key every credential this user
@@ -45,6 +50,127 @@ export async function twinSigningSecret(worldRoot, vendor) {
45
50
  const payload = await openSealedCredential(sealingKey(), JSON.parse(text), vendor);
46
51
  return typeof payload.signingSecret === 'string' && payload.signingSecret.length >= 8 ? payload.signingSecret : null;
47
52
  }
53
+ // ── where a root's credential comes from (company decision 0033) ────────────────────────────
54
+ /** A vault the person is signed in to, as `bao login` (or `vault login`) leaves it: an address, a token of the same
55
+ * CLI (`BAO_*` with `BAO_*`, `VAULT_*` with `VAULT_*`, else the CLI's token file), and its namespace if any. */
56
+ function signedInVault(env) {
57
+ for (const prefix of ['BAO', 'VAULT']) {
58
+ const addr = env[`${prefix}_ADDR`];
59
+ if (!addr)
60
+ continue;
61
+ let token = env[`${prefix}_TOKEN`];
62
+ if (!token) {
63
+ try {
64
+ token = readFileSync(join(homedir(), '.vault-token'), 'utf8').trim();
65
+ }
66
+ catch { /* none */ }
67
+ }
68
+ if (!token)
69
+ return null;
70
+ vaultAddress(`${prefix}_ADDR`, addr); // a token never travels in the clear off this machine
71
+ const namespace = env[`${prefix}_NAMESPACE`];
72
+ return { addr: addr.replace(/\/+$/, ''), token, ...(namespace ? { namespace } : {}) };
73
+ }
74
+ return null;
75
+ }
76
+ /** A vault's address: https, or http only to this machine; no userinfo. Any host or IP: a company vault often sits on a
77
+ * private name (`vault.acme.internal`, `10.1.2.3`), which a vendor's address may never be. */
78
+ function vaultAddress(variable, addr) {
79
+ let url;
80
+ try {
81
+ url = new URL(addr);
82
+ }
83
+ catch {
84
+ throw new Error(`${variable} is not a URL: ${addr}`);
85
+ }
86
+ if (url.username || url.password)
87
+ throw new Error(`${variable} must not carry credentials in its URL`);
88
+ // an IPv4 address in 127/8 (not a name that starts with "127."), localhost or [::1]
89
+ const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(\.\d{1,3}){3}$/.test(url.hostname);
90
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
91
+ throw new Error(`${variable} must be https (http only to this machine): ${url.origin}`);
92
+ }
93
+ // The suffixes that carry a credential itself; a publishable key, a webhook or signing secret, an id, a URL or a DSN is
94
+ // not what a root sends (covers.ts's broad list, less those).
95
+ const ROOT_SECRET_SUFFIX = /_(SECRET_ACCESS_KEY|SERVICE_ROLE_KEY|OAUTH_TOKEN|AUTH_TOKEN|ACCESS_TOKEN|BEARER_TOKEN|BOT_TOKEN|API_TOKEN|API_SECRET|API_KEY|APIKEY|SECRET_KEY|SECRET|TOKEN|KEY)$/;
96
+ // an OAuth app's client secret signs it in to the vendor; it is not the bearer a root sends
97
+ const NOT_A_ROOT_SECRET = /_(PUBLISHABLE_KEY|WEBHOOK_SECRET|SIGNING_SECRET|ACCESS_KEY_ID|CLIENT_ID|CLIENT_SECRET|CONSUMER_KEY|ACCOUNT_SID)$/;
98
+ /** A World's own fake for that name, or a twin's minted one: never a real credential. */
99
+ function isFakeValue(name, value) {
100
+ return value === fakeEnvValue(name) || /twin[-_]?fake|_twin_|twin_bootstrap|twin_sandbox/i.test(value);
101
+ }
102
+ /**
103
+ * A root's credential when none is piped in. A vault is never required (decision 0033): signed in, the vault's
104
+ * `kv/vendors/<vendor>` record (its `credential` field: a token, or the payload's JSON) is the real credential, and a
105
+ * vault that answers but refuses stops the seal rather than falling back; otherwise, or when the vault holds none or
106
+ * cannot be reached, the one credential the repo's `.env` files hold for the vendor (`.env.local` over `.env`, root
107
+ * and workspace members). `notes` say why a source was passed over, and are always shown.
108
+ */
109
+ export async function rootCredentialInput(worldRoot, vendor, env = process.env) {
110
+ const notes = [];
111
+ const vault = signedInVault(env);
112
+ if (vault) {
113
+ const at = `${vault.addr}/v1/kv/data/vendors/${encodeURIComponent(vendor)}`;
114
+ let response;
115
+ try {
116
+ response = await fetch(at, { headers: { 'x-vault-token': vault.token, ...(vault.namespace ? { 'x-vault-namespace': vault.namespace } : {}) }, signal: AbortSignal.timeout(10_000) });
117
+ }
118
+ catch (error) {
119
+ notes.push(`the vault at ${vault.addr} did not answer (${error.message}; a private CA is not read from BAO_CACERT/VAULT_CACERT here)`);
120
+ }
121
+ if (response) {
122
+ if (response.status === 403)
123
+ throw new Error(`the vault at ${vault.addr} refused kv/vendors/${vendor} (403): sign in again, or with a token that may read it`);
124
+ if (response.ok) {
125
+ let credential;
126
+ try {
127
+ credential = (await response.json()).data?.data?.credential;
128
+ }
129
+ catch {
130
+ throw new Error(`the vault at ${vault.addr} answered kv/vendors/${vendor} with no JSON`);
131
+ }
132
+ if (typeof credential === 'string' && credential.trim())
133
+ return { input: credential, from: `vault ${vault.addr} kv/vendors/${vendor}`, notes };
134
+ if (credential && typeof credential === 'object' && !Array.isArray(credential) && typeof credential.headers === 'object') {
135
+ return { input: JSON.stringify(credential), from: `vault ${vault.addr} kv/vendors/${vendor}`, notes };
136
+ }
137
+ notes.push(`the vault's kv/vendors/${vendor} holds no credential (a token, or a payload with headers)`);
138
+ }
139
+ else if (response.status === 404)
140
+ notes.push(`the vault holds no kv/vendors/${vendor}`);
141
+ else
142
+ throw new Error(`the vault at ${vault.addr} answered ${response.status} for kv/vendors/${vendor}`);
143
+ }
144
+ }
145
+ else
146
+ notes.push('not signed in to a vault (BAO_ADDR or VAULT_ADDR, and a token)');
147
+ // every .env the repo's members hold; within a member, .env.local over .env
148
+ const found = new Map();
149
+ for (const dir of projectManifestDirs(resolve(worldRoot))) {
150
+ const member = new Map();
151
+ for (const file of ['.env', '.env.local']) {
152
+ const path = join(dir, file);
153
+ if (!existsSync(path))
154
+ continue;
155
+ for (const entry of parseEnvFile(readFileSync(path, 'utf8'), relative(resolve(worldRoot), path) || file)) {
156
+ if (envNameVendor(entry.name) !== vendor || !ROOT_SECRET_SUFFIX.test(entry.name) || NOT_A_ROOT_SECRET.test(entry.name))
157
+ continue;
158
+ if (!entry.value || isFakeValue(entry.name, entry.value))
159
+ continue;
160
+ member.set(entry.name, { value: entry.value, source: entry.source });
161
+ }
162
+ }
163
+ for (const [name, hit] of member)
164
+ found.set(`${name} (${hit.source})`, hit);
165
+ }
166
+ const values = new Set([...found.values()].map((hit) => hit.value));
167
+ if (values.size === 1)
168
+ return { input: [...values][0], from: [...found.keys()].join(', '), notes };
169
+ if (values.size > 1)
170
+ throw new Error(`the repo's .env files hold more than one ${vendor} credential (${[...found.keys()].join(', ')}); pipe the one to seal`);
171
+ notes.push(`the repo's .env files hold no ${vendor} credential`);
172
+ return { input: null, from: '', notes };
173
+ }
48
174
  /** A bare token becomes the vendor's own header. A JSON object is taken as the payload. */
49
175
  export function credentialPayloadFrom(input, vendor) {
50
176
  const text = input.trim();
@@ -74,6 +74,10 @@ export type SharingClaim = {
74
74
  };
75
75
  /** Exported for direct drive: the liveness rule, pure over a claim. */
76
76
  export declare function sharingClaimAlive(claim: SharingClaim): boolean;
77
+ /** A person's vault sign-in (`BAO_*`, `VAULT_*`) opens their real credentials (company decision 0033). The processes that
78
+ * run code that is not Volter's (a World's apps, a command attached to a World, a tunnel, a dependency install) are
79
+ * given an environment without it. */
80
+ export declare function withoutVaultSignIn<T extends Record<string, string | undefined>>(env: T): T;
77
81
  /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
78
82
  export declare function admittedEnv(strip: string[] | undefined, env?: NodeJS.ProcessEnv): Record<string, string>;
79
83
  export declare function saveWorldInstance(instance: WorldInstance): void;
@@ -1,6 +1,7 @@
1
1
  import { claimEnvOutput, publishEnvOutput } from "./env-output.js";
2
2
  import { offSignal } from "./signals.js";
3
3
  import { parallelStartup } from "./startup.js";
4
+ import { waitForHostReady } from "./host-ready.js";
4
5
  import { assertWorldStateRemovable, removeWorldState } from "./ancestry.js";
5
6
  import { packCli, packEntry } from "./catalog.js";
6
7
  import { spawn, spawnSync } from 'node:child_process';
@@ -111,16 +112,25 @@ async function untilReadyOrEnded(wait, ended, boot, what, log) {
111
112
  * port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
112
113
  * every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
113
114
  * to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
114
- * ports (a co-located host's)
115
- * always runs in full: one port's answer says nothing of the others'. Programs that are not twins are never asked. */
116
- async function confirmOwnPort(port, boot, ended, twin, what, log, early = false) {
115
+ * ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
116
+ * Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
117
+ async function confirmOwnPort(port, boot, ended, twin, what, log, early = false, sharedSettle) {
117
118
  // A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
118
119
  boot.signal.throwIfAborted();
119
120
  const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
120
121
  const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' : new Promise(() => undefined))) : new Promise(() => undefined);
121
122
  // `ended` absent: the process was already settled (a co-located host settles once for all its twins).
122
123
  if (ended) {
123
- const settled = await Promise.race([own, ended.then(() => 'ended'), new Promise((resolveAlive) => setTimeout(() => resolveAlive('alive'), 300))]);
124
+ let timer;
125
+ let settled;
126
+ try {
127
+ const settle = sharedSettle ?? new Promise((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
128
+ settled = await Promise.race([own, ended.then(() => 'ended'), settle]);
129
+ }
130
+ finally {
131
+ if (timer !== undefined)
132
+ clearTimeout(timer);
133
+ }
124
134
  boot.signal.throwIfAborted();
125
135
  if (settled === 'own')
126
136
  return;
@@ -504,6 +514,16 @@ const CONTROL_PLANE_EGRESS_ENV = [
504
514
  'https_proxy',
505
515
  'all_proxy',
506
516
  ];
517
+ /** A person's vault sign-in (`BAO_*`, `VAULT_*`) opens their real credentials (company decision 0033). The processes that
518
+ * run code that is not Volter's (a World's apps, a command attached to a World, a tunnel, a dependency install) are
519
+ * given an environment without it. */
520
+ export function withoutVaultSignIn(env) {
521
+ const out = { ...env };
522
+ for (const name of Object.keys(out))
523
+ if (/^(BAO|VAULT)_/.test(name))
524
+ delete out[name];
525
+ return out;
526
+ }
507
527
  /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
508
528
  export function admittedEnv(strip, env = process.env) {
509
529
  const out = {};
@@ -519,7 +539,7 @@ export function admittedEnv(strip, env = process.env) {
519
539
  continue;
520
540
  out[k] = v;
521
541
  }
522
- return out;
542
+ return withoutVaultSignIn(out);
523
543
  }
524
544
  // The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
525
545
  function stripEnvOf(instanceEnv) {
@@ -1308,7 +1328,8 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1308
1328
  if (colocated.length === 0)
1309
1329
  return [];
1310
1330
  const hostCli = siblingScript(import.meta.url, 'host-cli');
1311
- const args = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker'];
1331
+ const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
1332
+ const args = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
1312
1333
  const allocated = [];
1313
1334
  for (const service of colocated) {
1314
1335
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
@@ -1344,6 +1365,16 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1344
1365
  exitState.value = { code, signal };
1345
1366
  });
1346
1367
  const command = [process.execPath, ...args];
1368
+ const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1369
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1370
+ if (undeadPids([child.pid]).length)
1371
+ recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1372
+ try {
1373
+ process.kill(-child.pid, 'SIGTERM');
1374
+ }
1375
+ catch { /* best effort cleanup */ }
1376
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1377
+ });
1347
1378
  await parallelStartup(allocated, async ({ service, port }) => {
1348
1379
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1349
1380
  await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
@@ -1356,18 +1387,27 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1356
1387
  throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1357
1388
  });
1358
1389
  });
1359
- await parallelStartup(allocated, async ({ service, port }, index) => {
1360
- // A host holding one twin settles for that one port, so its twin may be confirmed early like a lone service's.
1361
- await confirmOwnPort(port, boot, index === 0 ? ended : undefined, true, `Co-located twin "${service.id}"`, log, allocated.length === 1).catch((error) => {
1362
- if (undeadPids([child.pid]).length)
1363
- recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1364
- try {
1365
- process.kill(-child.pid, 'SIGTERM');
1366
- }
1367
- catch { /* best effort cleanup */ }
1368
- throw error;
1390
+ let hostSettleTimer;
1391
+ const hostSettle = new Promise((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
1392
+ try {
1393
+ await parallelStartup(allocated, async ({ service, port }) => {
1394
+ // The host has completed every start. Each matching boot answer can now
1395
+ // end its own settle; legacy twins share one full host guard.
1396
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1397
+ if (undeadPids([child.pid]).length)
1398
+ recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1399
+ try {
1400
+ process.kill(-child.pid, 'SIGTERM');
1401
+ }
1402
+ catch { /* best effort cleanup */ }
1403
+ throw error;
1404
+ });
1369
1405
  });
1370
- });
1406
+ }
1407
+ finally {
1408
+ if (hostSettleTimer !== undefined)
1409
+ clearTimeout(hostSettleTimer);
1410
+ }
1371
1411
  if (exitState.value !== null) {
1372
1412
  throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
1373
1413
  + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
@@ -2503,7 +2543,7 @@ async function shareWorldReserved(name, options) {
2503
2543
  const out = openPrivateLog(log, 'w');
2504
2544
  const child = spawn(tunnel.command, tunnel.args, { windowsHide: true,
2505
2545
  cwd: root,
2506
- env: process.env,
2546
+ env: withoutVaultSignIn(process.env),
2507
2547
  detached: true,
2508
2548
  stdio: ['ignore', out, out],
2509
2549
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.25",
3
+ "version": "2.0.27",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@ import { activateScript, branchWorld, checkoutWorld, fetchFromOrigin, pushWorldC
9
9
  import { formatApplication, formatChangeset, formatRebaseReport, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, parseVerifierExpression } from '@volter/world-core';
10
10
  import { advertiseWorldManifest, fetchRemoteManifest, MANIFEST_PATH, remoteAttachEnv, startManifestServer } from './serve.ts';
11
11
  import type { WorldManifest } from './attach.ts';
12
- import { clockFile, instanceDir, pruneWorlds } from './runtime.ts';
12
+ import { clockFile, instanceDir, pruneWorlds, withoutVaultSignIn } from './runtime.ts';
13
13
  import { worldResourceReport } from './resources.ts';
14
14
  import { worldConsumers, worldUsage } from './consumers.ts';
15
15
  import { addAppHosts, appUrlUnsetMessage, detectAppUrl, readAppUrl, setAppUrl } from './app-url.ts';
@@ -866,7 +866,7 @@ async function main(): Promise<void> {
866
866
  const { bridgeStreams, drainStreams } = createRequire(import.meta.url)('@volter/world-core/stream-bridge') as { bridgeStreams: (streams: WorldManifest['streams'], token: string) => Promise<{ env: Record<string, string> }>; drainStreams: (ms?: number) => Promise<void> };
867
867
  const bridged = manifest.streams ? await bridgeStreams(manifest.streams, attachToken ?? manifest.env.VOLTER_TWINS_KEY ?? '') : { env: {} };
868
868
  // VOLTER_WORLD names the World to the injector: a request addressed to its paths carries its key
869
- const commandEnv: Record<string, string | undefined> = { ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref };
869
+ const commandEnv: Record<string, string | undefined> = withoutVaultSignIn({ ...process.env, ...env, ...bridged.env, VOLTER_WORLD: resolved.ref });
870
870
  commandEnv.VOLTER_TWIN_INJECT_QUIET = head.includes('--verbose') ? '0' : commandEnv.VOLTER_TWIN_INJECT_QUIET ?? '1';
871
871
  const child = spawn(command[0]!, command.slice(1), { windowsHide: true, env: commandEnv, stdio: 'inherit' });
872
872
  const code = await new Promise<number>((resolveExit) => { child.on('exit', (status) => resolveExit(status ?? 1)); child.on('error', () => resolveExit(127)); });
@@ -886,7 +886,7 @@ async function main(): Promise<void> {
886
886
  if (command.length === 0) throw new Error('volter-world attach: pass the command after --');
887
887
  const manifest = readReflectManifest(attachRoot, resolved.ref);
888
888
  if (!manifest) throw new Error(`volter-world attach --via reflect: no reflect front is running for world ${resolved.ref} — start one: volter-world reflect ${resolved.ref} --target-ip <ip> --port 443 --resolver-port 53`);
889
- const env: Record<string, string> = { ...process.env as Record<string, string>, VOLTER_WORLD: resolved.ref, VOLTER_REFLECT_DNS: manifest.targetIp, VOLTER_WORLD_CA: manifest.caCertPath };
889
+ const env: Record<string, string> = { ...withoutVaultSignIn(process.env) as Record<string, string>, VOLTER_WORLD: resolved.ref, VOLTER_REFLECT_DNS: manifest.targetIp, VOLTER_WORLD_CA: manifest.caCertPath };
890
890
  Object.assign(env, sessionTrustEnv(manifest.caCertPath, env));
891
891
  let toRun = command;
892
892
  const compose = splitDockerComposeArgs(command);
package/src/host-cli.ts CHANGED
@@ -13,6 +13,7 @@ import { keepProcessAlive } from '@volter/world-core/lifecycle';
13
13
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { startColocatedHost, type ColocatedTwinSpec, type HostIsolation } from './host.ts';
16
+ import { publishHostReady } from './host-ready.ts';
16
17
 
17
18
  const argv = process.argv.slice(2);
18
19
  const opt = (name: string, fb = ''): string => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1]! : fb; };
@@ -83,10 +84,14 @@ const host = await startColocatedHost(specs, {
83
84
  if (e.kind === 'gaveup') recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
84
85
  },
85
86
  });
86
- for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
87
- process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
88
-
89
87
  const shutdown = async () => { await host.stop(); process.exit(0); };
90
88
  process.on('SIGTERM', shutdown);
91
89
  process.on('SIGINT', shutdown);
90
+ const readyFile = opt('--ready-file');
91
+ if (readyFile) {
92
+ try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports); }
93
+ catch (error) { await host.stop(); throw error; }
94
+ }
95
+ for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
96
+ process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
92
97
  await keepProcessAlive();
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
5
+ export function publishHostReady(path: string, boot: string, ports: Record<string, number>): void {
6
+ if (!boot) throw new Error('A co-located host readiness receipt requires its World boot identity.');
7
+ mkdirSync(dirname(path), { recursive: true });
8
+ const temporary = `${path}.${process.pid}.tmp`;
9
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
10
+ renameSync(temporary, path);
11
+ }
12
+
13
+ /** Receipt admission is finite and cancellation-aware; stale or malformed state is never readiness. */
14
+ export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal): Promise<void> {
15
+ const started = Date.now();
16
+ for (;;) {
17
+ abort.throwIfAborted();
18
+ let bytes: Buffer | undefined;
19
+ try { bytes = readFileSync(path); }
20
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
21
+ if (bytes) {
22
+ if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
23
+ const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown } | null;
24
+ if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
25
+ throw new Error('The co-located host readiness receipt does not belong to this boot.');
26
+ }
27
+ const answered = value.ports as Record<string, unknown>;
28
+ if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
29
+ throw new Error('The co-located host readiness receipt does not cover its declared ports.');
30
+ }
31
+ return;
32
+ }
33
+ if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host did not complete every declared start within ${timeoutMs} ms.`);
34
+ await new Promise<void>((resolve, reject) => {
35
+ const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
36
+ const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
37
+ abort.addEventListener('abort', cancel, { once: true });
38
+ if (abort.aborted) cancel();
39
+ });
40
+ }
41
+ }
package/src/index.ts CHANGED
@@ -161,7 +161,7 @@ export type { FetchOptions, FetchOutcome } from './origin.ts';
161
161
 
162
162
  export { branchWorld, checkoutWorld } from './branch.ts';
163
163
  export type { BranchOptions } from './branch.ts';
164
- export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
164
+ export { deployWorld, refreshTwin, adaptersFor, credentialPath, credentialPayloadFrom, rootCredentialInput, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from './root.ts';
165
165
  export type { DeployTwinOutcome, MaterializedRoot } from './root.ts';
166
166
  export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.ts';
167
167
  export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.ts';
@@ -174,3 +174,5 @@ export type { ServedWorld } from './served-world.ts';
174
174
  export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type ConsoleMount } from './console-apart.ts';
175
175
 
176
176
  export { assertWorldStateRemovable } from './ancestry.ts';
177
+
178
+ export { withoutVaultSignIn } from './runtime.ts';
package/src/init.ts CHANGED
@@ -607,20 +607,21 @@ export function renderComposeFile(plan: InitPlan): string {
607
607
  return `${lines.join('\n')}\n`;
608
608
  }
609
609
 
610
- type ParsedEnvEntry = { name: string; value: string; source: string };
610
+ export type ParsedEnvEntry = { name: string; value: string; source: string };
611
611
 
612
612
  /** Parse one dotenv-ish file into ordered NAME/value pairs. Deliberately conservative: the same
613
613
  * `NAME=` line shape `projectEnvNames` detects, plus quote stripping, so the two views of a repo's
614
614
  * env agree on which names exist. */
615
- function parseEnvFile(text: string, source: string): ParsedEnvEntry[] {
615
+ export function parseEnvFile(text: string, source: string): ParsedEnvEntry[] {
616
616
  const entries: ParsedEnvEntry[] = [];
617
617
  for (const line of text.split(/\r?\n/)) {
618
618
  const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$/);
619
619
  if (!match) continue;
620
620
  let value = match[2]!.trim();
621
- if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2)
622
- || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
623
- value = value.slice(1, -1);
621
+ // A quoted value ends at its closing quote; what follows it (` # a comment`) is not the value.
622
+ const quoted = value.match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
623
+ if (quoted) {
624
+ value = quoted[2]!;
624
625
  } else {
625
626
  // Unquoted: a `#` after whitespace opens a trailing comment (dotenv semantics). Without this
626
627
  // a line like `NEXTAUTH_URL=http://localhost:3000 # dev` yields a value with the comment
package/src/root.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  // root through the pack's adapters over the kernel executor, with the world's checks in front;
9
9
  // `deploy: auto` does the same the moment an entry is appended (the host installs the hook).
10
10
  import { packEntry } from './catalog.ts';
11
- import { existsSync, readdirSync } from 'node:fs';
12
- import { basename, dirname, join, resolve } from 'node:path';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { basename, dirname, join, relative, resolve } from 'node:path';
13
14
  import {
14
15
  NO_SECRETS_CHECK, authStrategyFor, getActiveWorldStore, getPack, sealingKey, buildRemoteExecute, clearRoot, observeAppends, openSealedCredential, readRoot, registerStateSystem, sealCredential, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
15
16
  } from '@volter/world-core';
@@ -18,6 +19,10 @@ import { changesetReadiness, collectObservations, type Observation, foldObservat
18
19
  import { findWorldChangeset, listWorldChangesets } from './changeset.ts';
19
20
  import { importModule } from './import-module.ts';
20
21
  import { statusWorld } from './runtime.ts';
22
+ import { envNameVendor } from './covers.ts';
23
+ import { parseEnvFile } from './init.ts';
24
+ import { fakeEnvValue } from './fixture-env.ts';
25
+ import { projectManifestDirs } from './project-inspect.ts';
21
26
  import { findInstalledPackage, twinPackageName } from './catalog.ts';
22
27
  import type { WorldConfig } from './schema.ts';
23
28
 
@@ -51,6 +56,100 @@ export async function twinSigningSecret(worldRoot: string, vendor: string): Prom
51
56
  return typeof payload.signingSecret === 'string' && payload.signingSecret.length >= 8 ? payload.signingSecret : null;
52
57
  }
53
58
 
59
+ // ── where a root's credential comes from (company decision 0033) ────────────────────────────
60
+
61
+ /** A vault the person is signed in to, as `bao login` (or `vault login`) leaves it: an address, a token of the same
62
+ * CLI (`BAO_*` with `BAO_*`, `VAULT_*` with `VAULT_*`, else the CLI's token file), and its namespace if any. */
63
+ function signedInVault(env: NodeJS.ProcessEnv): { addr: string; token: string; namespace?: string } | null {
64
+ for (const prefix of ['BAO', 'VAULT'] as const) {
65
+ const addr = env[`${prefix}_ADDR`];
66
+ if (!addr) continue;
67
+ let token = env[`${prefix}_TOKEN`];
68
+ if (!token) { try { token = readFileSync(join(homedir(), '.vault-token'), 'utf8').trim(); } catch { /* none */ } }
69
+ if (!token) return null;
70
+ vaultAddress(`${prefix}_ADDR`, addr); // a token never travels in the clear off this machine
71
+ const namespace = env[`${prefix}_NAMESPACE`];
72
+ return { addr: addr.replace(/\/+$/, ''), token, ...(namespace ? { namespace } : {}) };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** A vault's address: https, or http only to this machine; no userinfo. Any host or IP: a company vault often sits on a
78
+ * private name (`vault.acme.internal`, `10.1.2.3`), which a vendor's address may never be. */
79
+ function vaultAddress(variable: string, addr: string): void {
80
+ let url: URL;
81
+ try { url = new URL(addr); } catch { throw new Error(`${variable} is not a URL: ${addr}`); }
82
+ if (url.username || url.password) throw new Error(`${variable} must not carry credentials in its URL`);
83
+ // an IPv4 address in 127/8 (not a name that starts with "127."), localhost or [::1]
84
+ const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(\.\d{1,3}){3}$/.test(url.hostname);
85
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) throw new Error(`${variable} must be https (http only to this machine): ${url.origin}`);
86
+ }
87
+
88
+ // The suffixes that carry a credential itself; a publishable key, a webhook or signing secret, an id, a URL or a DSN is
89
+ // not what a root sends (covers.ts's broad list, less those).
90
+ const ROOT_SECRET_SUFFIX = /_(SECRET_ACCESS_KEY|SERVICE_ROLE_KEY|OAUTH_TOKEN|AUTH_TOKEN|ACCESS_TOKEN|BEARER_TOKEN|BOT_TOKEN|API_TOKEN|API_SECRET|API_KEY|APIKEY|SECRET_KEY|SECRET|TOKEN|KEY)$/;
91
+ // an OAuth app's client secret signs it in to the vendor; it is not the bearer a root sends
92
+ const NOT_A_ROOT_SECRET = /_(PUBLISHABLE_KEY|WEBHOOK_SECRET|SIGNING_SECRET|ACCESS_KEY_ID|CLIENT_ID|CLIENT_SECRET|CONSUMER_KEY|ACCOUNT_SID)$/;
93
+ /** A World's own fake for that name, or a twin's minted one: never a real credential. */
94
+ function isFakeValue(name: string, value: string): boolean {
95
+ return value === fakeEnvValue(name) || /twin[-_]?fake|_twin_|twin_bootstrap|twin_sandbox/i.test(value);
96
+ }
97
+
98
+ /**
99
+ * A root's credential when none is piped in. A vault is never required (decision 0033): signed in, the vault's
100
+ * `kv/vendors/<vendor>` record (its `credential` field: a token, or the payload's JSON) is the real credential, and a
101
+ * vault that answers but refuses stops the seal rather than falling back; otherwise, or when the vault holds none or
102
+ * cannot be reached, the one credential the repo's `.env` files hold for the vendor (`.env.local` over `.env`, root
103
+ * and workspace members). `notes` say why a source was passed over, and are always shown.
104
+ */
105
+ export async function rootCredentialInput(worldRoot: string, vendor: string, env: NodeJS.ProcessEnv = process.env): Promise<{ input: string | null; from: string; notes: string[] }> {
106
+ const notes: string[] = [];
107
+ const vault = signedInVault(env);
108
+ if (vault) {
109
+ const at = `${vault.addr}/v1/kv/data/vendors/${encodeURIComponent(vendor)}`;
110
+ let response: Response | undefined;
111
+ try {
112
+ response = await fetch(at, { headers: { 'x-vault-token': vault.token, ...(vault.namespace ? { 'x-vault-namespace': vault.namespace } : {}) }, signal: AbortSignal.timeout(10_000) });
113
+ } catch (error) {
114
+ notes.push(`the vault at ${vault.addr} did not answer (${(error as Error).message}; a private CA is not read from BAO_CACERT/VAULT_CACERT here)`);
115
+ }
116
+ if (response) {
117
+ if (response.status === 403) throw new Error(`the vault at ${vault.addr} refused kv/vendors/${vendor} (403): sign in again, or with a token that may read it`);
118
+ if (response.ok) {
119
+ let credential: unknown;
120
+ try { credential = ((await response.json()) as { data?: { data?: { credential?: unknown } } }).data?.data?.credential; }
121
+ catch { throw new Error(`the vault at ${vault.addr} answered kv/vendors/${vendor} with no JSON`); }
122
+ if (typeof credential === 'string' && credential.trim()) return { input: credential, from: `vault ${vault.addr} kv/vendors/${vendor}`, notes };
123
+ if (credential && typeof credential === 'object' && !Array.isArray(credential) && typeof (credential as { headers?: unknown }).headers === 'object') {
124
+ return { input: JSON.stringify(credential), from: `vault ${vault.addr} kv/vendors/${vendor}`, notes };
125
+ }
126
+ notes.push(`the vault's kv/vendors/${vendor} holds no credential (a token, or a payload with headers)`);
127
+ } else if (response.status === 404) notes.push(`the vault holds no kv/vendors/${vendor}`);
128
+ else throw new Error(`the vault at ${vault.addr} answered ${response.status} for kv/vendors/${vendor}`);
129
+ }
130
+ } else notes.push('not signed in to a vault (BAO_ADDR or VAULT_ADDR, and a token)');
131
+ // every .env the repo's members hold; within a member, .env.local over .env
132
+ const found = new Map<string, { value: string; source: string }>();
133
+ for (const dir of projectManifestDirs(resolve(worldRoot))) {
134
+ const member = new Map<string, { value: string; source: string }>();
135
+ for (const file of ['.env', '.env.local']) {
136
+ const path = join(dir, file);
137
+ if (!existsSync(path)) continue;
138
+ for (const entry of parseEnvFile(readFileSync(path, 'utf8'), relative(resolve(worldRoot), path) || file)) {
139
+ if (envNameVendor(entry.name) !== vendor || !ROOT_SECRET_SUFFIX.test(entry.name) || NOT_A_ROOT_SECRET.test(entry.name)) continue;
140
+ if (!entry.value || isFakeValue(entry.name, entry.value)) continue;
141
+ member.set(entry.name, { value: entry.value, source: entry.source });
142
+ }
143
+ }
144
+ for (const [name, hit] of member) found.set(`${name} (${hit.source})`, hit);
145
+ }
146
+ const values = new Set([...found.values()].map((hit) => hit.value));
147
+ if (values.size === 1) return { input: [...values][0]!, from: [...found.keys()].join(', '), notes };
148
+ if (values.size > 1) throw new Error(`the repo's .env files hold more than one ${vendor} credential (${[...found.keys()].join(', ')}); pipe the one to seal`);
149
+ notes.push(`the repo's .env files hold no ${vendor} credential`);
150
+ return { input: null, from: '', notes };
151
+ }
152
+
54
153
  /** A bare token becomes the vendor's own header. A JSON object is taken as the payload. */
55
154
  export function credentialPayloadFrom(input: string, vendor: string): CredentialPayload {
56
155
  const text = input.trim();
package/src/runtime.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { claimEnvOutput, publishEnvOutput } from './env-output.ts';
2
2
  import { offSignal } from './signals.ts';
3
3
  import { parallelStartup } from './startup.ts';
4
+ import { waitForHostReady } from './host-ready.ts';
4
5
  import { assertWorldStateRemovable, removeWorldState } from './ancestry.ts';
5
6
  import { packCli, packEntry } from './catalog.ts';
6
7
  import { spawn, spawnSync } from 'node:child_process';
@@ -105,16 +106,21 @@ async function untilReadyOrEnded<T>(wait: (abort: AbortSignal) => Promise<T>, en
105
106
  * port alone (`early`), a twin's answer with this boot's id ends it early: a server of this boot holds the port, and
106
107
  * every port this boot assigns is kept apart from its siblings', so short of the kernel handing the just-probed port
107
108
  * to another of this boot's port-0 listeners, that server is the service just started. A settle standing for several
108
- * ports (a co-located host's)
109
- * always runs in full: one port's answer says nothing of the others'. Programs that are not twins are never asked. */
110
- async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string, early = false): Promise<void> {
109
+ * ports runs in full unless an all-started receipt permits each port's own early identity confirmation.
110
+ * Programs that are not twins are never asked. A shared settle keeps legacy co-located ports within one guard. */
111
+ async function confirmOwnPort(port: number, boot: BootContext, ended: Promise<void> | undefined, twin: boolean, what: string, log: string, early = false, sharedSettle?: Promise<'alive'>): Promise<void> {
111
112
  // A twin is asked at once, raced with the settle: a server slow to answer costs no more than the settle did.
112
113
  boot.signal.throwIfAborted();
113
114
  const answer = twin ? worldBootOf(port, boot.signal) : Promise.resolve(null);
114
115
  const own = early && twin ? answer.then((a) => (a && a.boot === boot.bootId ? 'own' as const : new Promise<never>(() => undefined))) : new Promise<never>(() => undefined);
115
116
  // `ended` absent: the process was already settled (a co-located host settles once for all its twins).
116
117
  if (ended) {
117
- const settled = await Promise.race([own, ended.then(() => 'ended' as const), new Promise<'alive'>((resolveAlive) => setTimeout(() => resolveAlive('alive'), 300))]);
118
+ let timer: ReturnType<typeof setTimeout> | undefined;
119
+ let settled: 'own' | 'ended' | 'alive';
120
+ try {
121
+ const settle = sharedSettle ?? new Promise<'alive'>((resolveAlive) => { timer = setTimeout(() => resolveAlive('alive'), 300); });
122
+ settled = await Promise.race([own, ended.then(() => 'ended' as const), settle]);
123
+ } finally { if (timer !== undefined) clearTimeout(timer); }
118
124
  boot.signal.throwIfAborted();
119
125
  if (settled === 'own') return;
120
126
  if (settled === 'ended') throw new Error(`${what} ended right after its port first answered, so another process probably holds the port: the service ${endedAccount(log)}\nLog: ${log}`);
@@ -555,6 +561,15 @@ const CONTROL_PLANE_EGRESS_ENV = [
555
561
  'all_proxy',
556
562
  ];
557
563
 
564
+ /** A person's vault sign-in (`BAO_*`, `VAULT_*`) opens their real credentials (company decision 0033). The processes that
565
+ * run code that is not Volter's (a World's apps, a command attached to a World, a tunnel, a dependency install) are
566
+ * given an environment without it. */
567
+ export function withoutVaultSignIn<T extends Record<string, string | undefined>>(env: T): T {
568
+ const out = { ...env };
569
+ for (const name of Object.keys(out)) if (/^(BAO|VAULT)_/.test(name)) delete out[name];
570
+ return out;
571
+ }
572
+
558
573
  /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
559
574
  export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
560
575
  const out: Record<string, string> = {};
@@ -567,7 +582,7 @@ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv
567
582
  if (rules.some((r) => ('prefix' in r ? k.startsWith(r.prefix) : k === r.name))) continue;
568
583
  out[k] = v;
569
584
  }
570
- return out;
585
+ return withoutVaultSignIn(out);
571
586
  }
572
587
  // The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
573
588
  function stripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
@@ -1324,7 +1339,8 @@ async function startColocatedServices(
1324
1339
  ): Promise<WorldServiceInstance[]> {
1325
1340
  if (colocated.length === 0) return [];
1326
1341
  const hostCli = siblingScript(import.meta.url, 'host-cli');
1327
- const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker'];
1342
+ const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
1343
+ const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
1328
1344
  const allocated: Array<{ service: WorldServiceConfig; port: number; mounted: MountedPackage }> = [];
1329
1345
  for (const service of colocated) {
1330
1346
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort(allocatedPorts) : service.port;
@@ -1362,6 +1378,12 @@ async function startColocatedServices(
1362
1378
  });
1363
1379
 
1364
1380
  const command = [process.execPath, ...args];
1381
+ const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1382
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1383
+ if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1384
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1385
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1386
+ });
1365
1387
  await parallelStartup(allocated, async ({ service, port }) => {
1366
1388
  const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1367
1389
  await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
@@ -1370,14 +1392,19 @@ async function startColocatedServices(
1370
1392
  throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1371
1393
  });
1372
1394
  });
1373
- await parallelStartup(allocated, async ({ service, port }, index) => {
1374
- // A host holding one twin settles for that one port, so its twin may be confirmed early like a lone service's.
1375
- await confirmOwnPort(port, boot, index === 0 ? ended : undefined, true, `Co-located twin "${service.id}"`, log, allocated.length === 1).catch((error) => {
1376
- if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1377
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1378
- throw error;
1395
+ let hostSettleTimer: ReturnType<typeof setTimeout> | undefined;
1396
+ const hostSettle = new Promise<'alive'>((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
1397
+ try {
1398
+ await parallelStartup(allocated, async ({ service, port }) => {
1399
+ // The host has completed every start. Each matching boot answer can now
1400
+ // end its own settle; legacy twins share one full host guard.
1401
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1402
+ if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1403
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1404
+ throw error;
1405
+ });
1379
1406
  });
1380
- });
1407
+ } finally { if (hostSettleTimer !== undefined) clearTimeout(hostSettleTimer); }
1381
1408
  if (exitState.value !== null) {
1382
1409
  throw new Error(
1383
1410
  `The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
@@ -2467,7 +2494,7 @@ async function shareWorldReserved(name: string, options: ShareWorldOptions): Pro
2467
2494
  const out = openPrivateLog(log, 'w');
2468
2495
  const child = spawn(tunnel.command, tunnel.args, { windowsHide: true,
2469
2496
  cwd: root,
2470
- env: process.env,
2497
+ env: withoutVaultSignIn(process.env),
2471
2498
  detached: true,
2472
2499
  stdio: ['ignore', out, out],
2473
2500
  });