@volter/twin-world 0.1.0

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/src/runtime.ts ADDED
@@ -0,0 +1,1888 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { createServer } from 'node:net';
4
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve, sep } from 'node:path';
6
+ import { Socket } from 'node:net';
7
+ import { withFileLock } from '@volter/twin';
8
+ import { loadWorldConfig } from './configs.ts';
9
+ import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
10
+ import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
11
+
12
+ const requireFromHere = createRequire(import.meta.url);
13
+
14
+ export type UpWorldOptions = {
15
+ name?: string;
16
+ root?: string;
17
+ mode?: WorldMode;
18
+ /** Overrides the config's `isolation` for this boot (e.g. force 'process' for a share world). */
19
+ isolation?: WorldIsolation;
20
+ envFile?: string;
21
+ share?: Omit<ShareWorldOptions, 'root' | 'service' | 'verifyPath'> & { verifyPath?: string | false };
22
+ };
23
+
24
+ export type ShareWorldOptions = {
25
+ root?: string;
26
+ service?: string;
27
+ provider?: 'cloudflare-quick' | 'command';
28
+ command?: string;
29
+ args?: string[];
30
+ timeoutMs?: number;
31
+ verifyPath?: string | false;
32
+ };
33
+
34
+ export type ShareWorldServicesOptions = Omit<ShareWorldOptions, 'service' | 'verifyPath'> & {
35
+ service?: string;
36
+ verifyPath?: string | false;
37
+ };
38
+
39
+ export type WorldDoctorCheck = {
40
+ id: string;
41
+ ok: boolean;
42
+ message: string;
43
+ };
44
+
45
+ export type WorldDoctorReport = {
46
+ name: string;
47
+ ok: boolean;
48
+ checks: WorldDoctorCheck[];
49
+ };
50
+
51
+ export type RunWorldOptions = UpWorldOptions & {
52
+ keep?: boolean;
53
+ };
54
+
55
+ export type WorldUrlInfo = {
56
+ name: string;
57
+ mode: WorldMode;
58
+ services: Record<string, {
59
+ localUrl: string;
60
+ publicUrl?: string;
61
+ publicReady?: boolean;
62
+ }>;
63
+ };
64
+
65
+ function worldBaseDir(root: string): string {
66
+ return join(root, '.volter', 'worlds');
67
+ }
68
+
69
+ /** World names become a path segment (instanceDir/instanceLockFile) and must never let a
70
+ * caller escape `worldBaseDir` (no `/`, and the leading-alnum requirement rules out a bare
71
+ * `..`). Shared by upWorld's boot-time check and downWorld's `--purge` (the one path that
72
+ * recursively `rmSync`s a name-derived directory, so it is the one that most needs this to
73
+ * hold). */
74
+ function assertSafeWorldName(name: string): void {
75
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Invalid world name: ${name}`);
76
+ }
77
+
78
+ function instanceDir(root: string, name: string): string {
79
+ return join(worldBaseDir(root), name);
80
+ }
81
+
82
+ function instanceFile(root: string, name: string): string {
83
+ return join(instanceDir(root, name), 'instance.json');
84
+ }
85
+
86
+ /** Lockfile guarding the synchronous claim-of-the-instance-dir section of `upWorld` (TWIN-36).
87
+ * It must survive the claim's own `rmSync` of the instance dir, so it lives BESIDE the dirs in
88
+ * a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
89
+ * collide with (or be wiped as) a world's instance dir. */
90
+ function instanceLockFile(root: string, name: string): string {
91
+ return join(worldBaseDir(root), '.locks', `${name}.lock`);
92
+ }
93
+
94
+ /** Claim marker a winning `upWorld` writes into the freshly-wiped instance dir — under the
95
+ * instance lock, before releasing it — and removes once its boot attempt settles. It is what
96
+ * keeps later claimants out for the whole (async, possibly long) boot: mid-boot there are no
97
+ * recorded pids yet, so without it a second `upWorld` would see "nothing running" and rmSync
98
+ * the winner's dir out from under its freshly-spawned services. */
99
+ function bootingFile(root: string, name: string): string {
100
+ return join(instanceDir(root, name), 'booting.json');
101
+ }
102
+
103
+ type BootingClaim = { pid: number; at: string };
104
+
105
+ /** The live booting claim for a world, or null when there is none to honor. Only ever read
106
+ * under the instance lock, and the marker is written under that same lock — so a torn read is
107
+ * impossible; a malformed marker is a corrupt leftover and treated as stale. A claim whose
108
+ * recorded booter pid is dead is stale too (crashed mid-boot): the next claimant reclaims the
109
+ * dir, exactly like livePids() lets a fully-dead world be re-upped. */
110
+ function liveBootingClaim(path: string): BootingClaim | null {
111
+ if (!existsSync(path)) return null;
112
+ let claim: BootingClaim;
113
+ try {
114
+ claim = JSON.parse(readFileSync(path, 'utf8')) as BootingClaim;
115
+ } catch {
116
+ return null;
117
+ }
118
+ if (!Number.isInteger(claim.pid) || claim.pid <= 0) return null;
119
+ return livePids([claim.pid]).length > 0 ? claim : null;
120
+ }
121
+
122
+ /** Where the session-scoped CA + on-the-fly leaf certs live for a world (under the instance dir, so
123
+ * `down`'s teardown removes them — the CA never outlives the world). */
124
+ function tlsDir(root: string, name: string): string {
125
+ return join(instanceDir(root, name), 'tls');
126
+ }
127
+
128
+ /** State of a detached ambient-redirect proxy daemon (so `activate` can export its env and `down`
129
+ * can stop it). Written by the daemon, read by activate/down. */
130
+ function proxyStateFile(root: string, name: string): string {
131
+ return join(instanceDir(root, name), 'proxy.json');
132
+ }
133
+
134
+ function proxyEnvFile(root: string, name: string): string {
135
+ return join(instanceDir(root, name), 'proxy-env.json');
136
+ }
137
+
138
+ type ProxyState = { pid: number; url: string; caCertPath: string };
139
+
140
+ function readProxyState(root: string, name: string): ProxyState | null {
141
+ const file = proxyStateFile(root, name);
142
+ if (!existsSync(file)) return null;
143
+ try {
144
+ return JSON.parse(readFileSync(file, 'utf8')) as ProxyState;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function shellQuote(value: string): string {
151
+ return `'${value.replaceAll("'", "'\\''")}'`;
152
+ }
153
+
154
+ function envFileContents(env: Record<string, string>): string {
155
+ return `${Object.entries(env)
156
+ .sort(([a], [b]) => a.localeCompare(b))
157
+ .map(([key, value]) => `export ${key}=${shellQuote(value)}`)
158
+ .join('\n')}\n`;
159
+ }
160
+
161
+ function injectPreloadSpecifier(): string {
162
+ return requireFromHere.resolve('@volter/twin/inject');
163
+ }
164
+
165
+ function writeProxyEnv(root: string, name: string, env: Record<string, string>): void {
166
+ writeFileSync(proxyEnvFile(root, name), `${JSON.stringify(env, null, 2)}\n`);
167
+ }
168
+
169
+ function readProxyEnv(path: string): Record<string, string> {
170
+ try {
171
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, string>;
172
+ } catch {
173
+ return {};
174
+ }
175
+ }
176
+
177
+ /** Resolve a service's cliRedirect map (env var → template), substituting the literal `${url}`
178
+ * with the service's resolved URL. These flow into the world env so the vendor's real CLI lands
179
+ * in the twin. See docs/WORLD_ACTIVATE.md. */
180
+ function resolveCliRedirect(cliRedirect: Record<string, string> | undefined, url: string): Record<string, string> {
181
+ if (!cliRedirect) return {};
182
+ const out: Record<string, string> = {};
183
+ for (const [key, template] of Object.entries(cliRedirect)) out[key] = template.split('${url}').join(url);
184
+ return out;
185
+ }
186
+
187
+ function resolveServiceEnvTemplates(
188
+ templates: Record<string, string> | undefined,
189
+ service: { host: string; port: number; url: string },
190
+ ): Record<string, string> {
191
+ if (!templates) return {};
192
+ const out: Record<string, string> = {};
193
+ for (const [key, template] of Object.entries(templates)) {
194
+ out[key] = template
195
+ .split('${url}').join(service.url)
196
+ .split('${httpUrl}').join(service.url)
197
+ .split('${host}').join(service.host)
198
+ .split('${port}').join(String(service.port));
199
+ }
200
+ return out;
201
+ }
202
+
203
+ const CONTROL_PLANE_EGRESS_ENV = [
204
+ 'NODE_OPTIONS',
205
+ 'HTTP_PROXY',
206
+ 'HTTPS_PROXY',
207
+ 'ALL_PROXY',
208
+ 'http_proxy',
209
+ 'https_proxy',
210
+ 'all_proxy',
211
+ ];
212
+
213
+ function serviceProcessEnv(
214
+ config: WorldConfig,
215
+ service: WorldServiceConfig,
216
+ worldEnv: Record<string, string>,
217
+ port: number,
218
+ ): Record<string, string> {
219
+ const env: Record<string, string> = {
220
+ ...process.env,
221
+ ...(config.env ?? {}),
222
+ ...worldEnv,
223
+ PORT: String(port),
224
+ };
225
+ env.NO_PROXY = env.NO_PROXY ? `127.0.0.1,localhost,${env.NO_PROXY}` : '127.0.0.1,localhost';
226
+ env.no_proxy = env.no_proxy ? `127.0.0.1,localhost,${env.no_proxy}` : '127.0.0.1,localhost';
227
+ if (service.controlPlane) {
228
+ for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
229
+ }
230
+ return { ...env, ...(service.env ?? {}) };
231
+ }
232
+
233
+ async function allocatePort(): Promise<number> {
234
+ return await new Promise((resolvePort, reject) => {
235
+ const server = createServer();
236
+ server.listen(0, '127.0.0.1', () => {
237
+ const address = server.address();
238
+ if (!address || typeof address === 'string') {
239
+ server.close(() => reject(new Error('Could not allocate a TCP port')));
240
+ return;
241
+ }
242
+ const port = address.port;
243
+ server.close(() => resolvePort(port));
244
+ });
245
+ server.on('error', reject);
246
+ });
247
+ }
248
+
249
+ async function waitForTcp(port: number, timeoutMs = 15_000): Promise<void> {
250
+ const started = Date.now();
251
+ while (Date.now() - started < timeoutMs) {
252
+ const ok = await new Promise<boolean>((resolveOk) => {
253
+ const socket = new Socket();
254
+ socket.setTimeout(500);
255
+ socket.once('connect', () => {
256
+ socket.destroy();
257
+ resolveOk(true);
258
+ });
259
+ socket.once('timeout', () => {
260
+ socket.destroy();
261
+ resolveOk(false);
262
+ });
263
+ socket.once('error', () => resolveOk(false));
264
+ socket.connect(port, '127.0.0.1');
265
+ });
266
+ if (ok) return;
267
+ await new Promise((resolveWait) => setTimeout(resolveWait, 150));
268
+ }
269
+ throw new Error(`Service on port ${port} did not become reachable`);
270
+ }
271
+
272
+ function readPids(path: string): number[] {
273
+ if (!existsSync(path)) return [];
274
+ return readFileSync(path, 'utf8')
275
+ .split(/\r?\n/)
276
+ .map((line) => Number(line.trim()))
277
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
278
+ }
279
+
280
+ function livePids(pids: number[]): number[] {
281
+ return pids.filter((pid) => {
282
+ try {
283
+ process.kill(pid, 0);
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ });
289
+ }
290
+
291
+ function tunnelPids(instance: WorldInstance): number[] {
292
+ return Object.values(instance.services)
293
+ .map((service) => service.tunnel?.pid)
294
+ .filter((pid): pid is number => Boolean(pid));
295
+ }
296
+
297
+ function writePidsFromInstance(instance: WorldInstance): void {
298
+ // Deduped: co-located services share the single host child's pid.
299
+ const pids = [...new Set([
300
+ ...Object.values(instance.services).map((service) => service.pid),
301
+ ...tunnelPids(instance),
302
+ ].filter((pid) => Number.isInteger(pid) && pid > 0))];
303
+ writeFileSync(instance.pidsFile, `${pids.join('\n')}\n`);
304
+ }
305
+
306
+ function saveWorldInstance(instance: WorldInstance): void {
307
+ writeFileSync(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
308
+ }
309
+
310
+ /** The co-located host CHILD process cannot safely rewrite instance.json out from under the
311
+ * parent (which may still be mid-boot, or rewrite it later via share/unshare) — so a worker
312
+ * give-up (host.ts fires it on the first crash — never restarted) is recorded in a sidecar
313
+ * file next to instance.json instead
314
+ * (see host-cli.ts). Folding it in here means every reader (doctor, status, tests) sees the
315
+ * record on `services[<id>].workerGaveUp` exactly as if it had been written into instance.json. */
316
+ function mergeWorkerGaveUp(instance: WorldInstance, instancePath: string): void {
317
+ const sidecar = join(dirname(instancePath), 'host-gaveup.json');
318
+ if (!existsSync(sidecar)) return;
319
+ try {
320
+ const gaveUp = JSON.parse(readFileSync(sidecar, 'utf8')) as Record<string, { at: string; exits: number; detail: string }>;
321
+ for (const [id, record] of Object.entries(gaveUp)) {
322
+ const service = instance.services[id];
323
+ if (service) service.workerGaveUp = record;
324
+ }
325
+ } catch {
326
+ // Best-effort merge: a sidecar torn by a concurrent host-cli write must never break an
327
+ // instance.json read (the give-up record just fails to surface for this one read).
328
+ }
329
+ }
330
+
331
+ function readWorldInstance(name: string, root: string): WorldInstance {
332
+ const path = instanceFile(root, name);
333
+ if (!existsSync(path)) throw new Error(`World instance not found: ${name}`);
334
+ const instance = JSON.parse(readFileSync(path, 'utf8')) as WorldInstance;
335
+ mergeWorkerGaveUp(instance, path);
336
+ return instance;
337
+ }
338
+
339
+ function assertMode(mode: string): asserts mode is WorldMode {
340
+ if (mode !== 'local' && mode !== 'share' && mode !== 'sealed') throw new Error(`Invalid world mode: ${mode}`);
341
+ }
342
+
343
+ function getByJsonPath(value: unknown, jsonPath: string): unknown {
344
+ let current: unknown = value;
345
+ for (const rawSegment of jsonPath.split('.')) {
346
+ const segment = rawSegment.trim();
347
+ if (segment === '') continue;
348
+ if (current === null || current === undefined) return undefined;
349
+ if (Array.isArray(current)) {
350
+ const index = Number(segment);
351
+ if (!Number.isInteger(index)) return undefined;
352
+ current = current[index];
353
+ } else if (typeof current === 'object') {
354
+ current = (current as Record<string, unknown>)[segment];
355
+ } else {
356
+ return undefined;
357
+ }
358
+ }
359
+ return current;
360
+ }
361
+
362
+ function runExternalCommand(
363
+ command: string[],
364
+ cwd: string,
365
+ serviceId: string,
366
+ phase: string,
367
+ env: NodeJS.ProcessEnv = process.env,
368
+ ): { stdout: string; stderr: string } {
369
+ const [bin, ...args] = command;
370
+ if (!bin) throw new Error(`External service "${serviceId}": ${phase} command is empty`);
371
+ if (!commandExists(bin)) {
372
+ throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the ${phase} command's tool is not installed)`);
373
+ }
374
+ // 64MB ceiling (vs Node's 1MB default) so a chatty status/down doesn't ENOBUFS. The `up` phase
375
+ // uses runExternalUp (streamed, uncapped) since it's the one that restores DBs / boots stacks.
376
+ const result = spawnSync(bin, args, { cwd, encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
377
+ if (result.error) {
378
+ throw new Error(`External service "${serviceId}": ${phase} command failed to run (${result.error.message})`);
379
+ }
380
+ if (result.status !== 0) {
381
+ const detail = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
382
+ throw new Error(`External service "${serviceId}": ${phase} command exited ${result.status ?? 'null'}\n${detail}`);
383
+ }
384
+ return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
385
+ }
386
+
387
+ /** Run an external service's `up` command, STREAMING its stdout+stderr to the service log as it runs
388
+ * (instead of buffering through spawnSync's 1MB cap, which a large DB restore would overflow). Returns
389
+ * the combined output so `discover` can read it. Throws with a bounded tail on nonzero exit. */
390
+ async function runExternalUp(
391
+ command: string[],
392
+ cwd: string,
393
+ serviceId: string,
394
+ env: NodeJS.ProcessEnv,
395
+ logPath: string,
396
+ ): Promise<string> {
397
+ const [bin, ...args] = command;
398
+ if (!bin) throw new Error(`External service "${serviceId}": up command is empty`);
399
+ if (!commandExists(bin)) {
400
+ throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the up command's tool is not installed)`);
401
+ }
402
+ writeFileSync(logPath, `$ ${command.join(' ')}\n`);
403
+ const out = openSync(logPath, 'a');
404
+ const chunks: string[] = [];
405
+ try {
406
+ const code = await new Promise<number | null>((resolveCode, reject) => {
407
+ const child = spawn(bin, args, { cwd, env });
408
+ child.on('error', reject);
409
+ const onData = (buf: Buffer) => {
410
+ const text = buf.toString('utf8');
411
+ chunks.push(text);
412
+ try { writeSync(out, text); } catch { /* best effort log */ }
413
+ };
414
+ child.stdout?.on('data', onData);
415
+ child.stderr?.on('data', onData);
416
+ child.on('close', (exitCode) => resolveCode(exitCode));
417
+ });
418
+ if (code !== 0) {
419
+ const detail = chunks.join('').trim().split('\n').slice(-40).join('\n');
420
+ throw new Error(`External service "${serviceId}": up command exited ${code ?? 'null'}\n${detail}`);
421
+ }
422
+ } finally {
423
+ closeSync(out);
424
+ }
425
+ return chunks.join('');
426
+ }
427
+
428
+ /** Resolve `${url}`/`${host}`/`${port}` in a readiness probe's httpUrl against an owned loopback
429
+ * service's allocated port (process/twin readiness — the port is auto-assigned, so it can't be a
430
+ * literal in the config). Other probe kinds (command/stdoutMatch) are returned unchanged. */
431
+ function resolveReadyProbe(ready: WorldExternalReadyWhen, svc: { host: string; port: number; url: string }): WorldExternalReadyWhen {
432
+ if (ready.httpUrl === undefined) return ready;
433
+ const httpUrl = ready.httpUrl
434
+ .split('${url}').join(svc.url)
435
+ .split('${host}').join(svc.host)
436
+ .split('${port}').join(String(svc.port));
437
+ return { ...ready, httpUrl };
438
+ }
439
+
440
+ async function awaitReadiness(
441
+ ready: WorldExternalReadyWhen | undefined,
442
+ logPath: string,
443
+ cwd: string,
444
+ serviceId: string,
445
+ env: NodeJS.ProcessEnv = process.env,
446
+ ): Promise<void> {
447
+ if (!ready) return;
448
+ const timeoutMs = ready.timeoutMs ?? 60_000;
449
+ const intervalMs = ready.intervalMs ?? 500;
450
+ const started = Date.now();
451
+ let lastError = '';
452
+ while (Date.now() - started < timeoutMs) {
453
+ if (ready.command !== undefined) {
454
+ if (!commandExists(ready.command)) {
455
+ throw new Error(`Service "${serviceId}": readiness command \`${ready.command}\` not found on PATH`);
456
+ }
457
+ const result = spawnSync(ready.command, ready.args ?? [], { cwd, encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
458
+ if (result.status === 0) return;
459
+ lastError = `command exited ${result.status ?? 'null'}: ${(result.stderr ?? '').trim()}`;
460
+ } else if (ready.httpUrl !== undefined) {
461
+ try {
462
+ const response = await fetch(ready.httpUrl);
463
+ if (response.status >= 200 && response.status < 400) return;
464
+ lastError = `HTTP ${response.status}`;
465
+ } catch (error) {
466
+ lastError = error instanceof Error ? error.message : String(error);
467
+ }
468
+ } else if (ready.stdoutMatch !== undefined) {
469
+ const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
470
+ if (new RegExp(ready.stdoutMatch).test(text)) return;
471
+ lastError = `pattern /${ready.stdoutMatch}/ not yet in service log`;
472
+ }
473
+ await sleep(intervalMs);
474
+ }
475
+ throw new Error(`Service "${serviceId}": readiness probe timed out after ${timeoutMs}ms (${lastError || 'no probe matched'})`);
476
+ }
477
+
478
+ async function startExternalService(
479
+ service: WorldServiceConfig,
480
+ paths: { root: string; logs: string },
481
+ worldEnv: Record<string, string>,
482
+ ): Promise<WorldServiceInstance> {
483
+ const external = service.external;
484
+ if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
485
+ const log = join(paths.logs, `${service.id}.log`);
486
+ // External commands see the accumulated world env (process.env + earlier services' discovered/
487
+ // injected vars), so a later external can consume an earlier service's connection info.
488
+ const env: NodeJS.ProcessEnv = { ...process.env, ...worldEnv };
489
+
490
+ // 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
491
+ const upOutput = await runExternalUp(external.up, paths.root, service.id, env, log);
492
+
493
+ // 2. readiness — Docker-backed externals take seconds to boot.
494
+ await awaitReadiness(external.readyWhen, log, paths.root, service.id, env);
495
+
496
+ // 3. status — discover connection info (optional; may reuse up output instead).
497
+ let statusOutput = '';
498
+ if (external.status) {
499
+ const statusResult = runExternalCommand(external.status, paths.root, service.id, 'status', env);
500
+ statusOutput = statusResult.stdout || statusResult.stderr;
501
+ appendFileSync(log, `\n$ ${external.status.join(' ')}\n${statusOutput}`);
502
+ }
503
+
504
+ // 4. discover — apply declarative mappings → injected env vars.
505
+ const injected: Record<string, string> = {};
506
+ const sources: Record<'status' | 'up', string> = {
507
+ status: statusOutput,
508
+ up: upOutput,
509
+ };
510
+ const jsonCache: Partial<Record<'status' | 'up', unknown>> = {};
511
+ for (const mapping of external.discover ?? []) {
512
+ const source = mapping.source ?? 'status';
513
+ const raw = sources[source];
514
+ let value: string | undefined;
515
+ if (mapping.jsonPath !== undefined) {
516
+ if (!(source in jsonCache)) {
517
+ try {
518
+ jsonCache[source] = JSON.parse(raw) as unknown;
519
+ } catch (error) {
520
+ throw new Error(`External service "${service.id}": discover "${mapping.as}" expected JSON from ${source} output but parse failed (${error instanceof Error ? error.message : String(error)})\nOutput: ${raw.slice(0, 500)}`);
521
+ }
522
+ }
523
+ const found = getByJsonPath(jsonCache[source], mapping.jsonPath);
524
+ value = found === undefined || found === null ? undefined : String(found);
525
+ } else if (mapping.pattern !== undefined) {
526
+ const match = new RegExp(mapping.pattern).exec(raw);
527
+ value = match?.groups?.value ?? match?.[1] ?? match?.[0];
528
+ }
529
+ if (value === undefined || value === '') {
530
+ throw new Error(`External service "${service.id}": discover "${mapping.as}" found ${value === '' ? 'an empty' : 'no'} value (jsonPath=${mapping.jsonPath ?? '-'} pattern=${mapping.pattern ?? '-'} source=${source})\nOutput: ${raw.slice(0, 500)}`);
531
+ }
532
+ injected[mapping.as] = value;
533
+ }
534
+
535
+ return {
536
+ id: service.id,
537
+ type: 'external',
538
+ command: [...external.up],
539
+ pid: 0,
540
+ log,
541
+ env: injected,
542
+ // Keep the discovered env on the instance so `down` can reference connection info it needs.
543
+ external: { down: [...external.down], cwd: paths.root, discoveredEnv: injected },
544
+ };
545
+ }
546
+
547
+ async function startService(
548
+ config: WorldConfig,
549
+ service: WorldServiceConfig,
550
+ worldEnv: Record<string, string>,
551
+ paths: { root: string; instance: string; logs: string; data: string },
552
+ ): Promise<WorldServiceInstance> {
553
+ if (service.type === 'external') return startExternalService(service, paths, worldEnv);
554
+ if (!service.command) throw new Error(`Service "${service.id}" must define command`);
555
+ const port = service.port === undefined || service.port === 'auto' ? await allocatePort() : service.port;
556
+ const serviceDataDir = join(paths.data, service.id);
557
+ mkdirSync(serviceDataDir, { recursive: true });
558
+
559
+ const args = [...(service.args ?? [])];
560
+ if (service.portArg !== false) args.push(service.portArg ?? '--port', String(port));
561
+ if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
562
+
563
+ const log = join(paths.logs, `${service.id}.log`);
564
+ const out = openSync(log, 'a');
565
+ const env = serviceProcessEnv(config, service, worldEnv, port);
566
+ const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
567
+
568
+ // Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
569
+ // (e.g. a sandbox) WITHOUT replacing the world's injector. Paths resolve to absolute against the
570
+ // service cwd — Node's `--require` treats a bare relative specifier as a package name otherwise.
571
+ // (Setting service.env.NODE_OPTIONS directly still replaces the default — the explicit opt-out.)
572
+ if (service.preload?.length) {
573
+ const requires = service.preload.map((module) => `--require ${resolve(cwd, module)}`).join(' ');
574
+ env.NODE_OPTIONS = env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${requires}` : requires;
575
+ }
576
+ const child = spawn(service.command, args, {
577
+ cwd,
578
+ env,
579
+ detached: true,
580
+ stdio: ['ignore', out, out],
581
+ });
582
+ child.unref();
583
+
584
+ // Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
585
+ // child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
586
+ // and dies — but waitForTcp would then see the OTHER process answering and the world would
587
+ // silently record this service's URL pointing at the WRONG twin (the multi-twin crossing
588
+ // bug). Detecting the child's own exit lets us fail LOUDLY instead.
589
+ // Object holder (not a bare `let`) so TS keeps the union type across the exit closure.
590
+ const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
591
+ child.on('exit', (code, signal) => {
592
+ exitState.value = { code, signal };
593
+ });
594
+
595
+ await waitForTcp(port).catch((error) => {
596
+ try {
597
+ process.kill(-child.pid!, 'SIGTERM');
598
+ } catch {
599
+ // best effort cleanup
600
+ }
601
+ throw new Error(`Service "${service.id}" failed to start: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
602
+ });
603
+
604
+ if (exitState.value !== null) {
605
+ throw new Error(
606
+ `Service "${service.id}" exited during startup (code=${exitState.value.code}, signal=${exitState.value.signal}) even though port ${port} answers — `
607
+ + `the port is almost certainly held by another/stale process, so the twin never bound it. `
608
+ + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
609
+ );
610
+ }
611
+
612
+ const host = '127.0.0.1';
613
+ const url = `http://${host}:${port}`;
614
+
615
+ // Optional readiness probe beyond TCP (httpUrl/command/stdoutMatch): a process can bind its port
616
+ // before it is actually serving, so this makes `up` wait until the service truly responds.
617
+ if (service.ready && service.ready !== 'tcp') {
618
+ await awaitReadiness(resolveReadyProbe(service.ready, { host, port, url }), log, cwd, service.id, env).catch((error) => {
619
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
620
+ throw new Error(`Service "${service.id}" failed its readiness probe: ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
621
+ });
622
+ }
623
+
624
+ return {
625
+ id: service.id,
626
+ type: service.type ?? 'twin',
627
+ command: [service.command, ...args],
628
+ port,
629
+ url,
630
+ pid: child.pid ?? 0,
631
+ log,
632
+ env: {
633
+ ...(service.injectEnv ? { [service.injectEnv]: url } : {}),
634
+ ...resolveServiceEnvTemplates(service.injectEnvTemplates, { host, port, url }),
635
+ ...resolveCliRedirect(service.cliRedirect, url),
636
+ },
637
+ };
638
+ }
639
+
640
+ /**
641
+ * Boot every `colocate`-declaring service inside ONE `volter-world-host` child (see src/host.ts —
642
+ * 'colocated' maps to the host's 'shared' mode, 'worker' to one Worker thread per twin). The
643
+ * recorded instances are shaped exactly like the spawn path's (port/url/injected env), except they
644
+ * all share the host child's pid — so pids/instance.json/downWorld need no special casing.
645
+ */
646
+ async function startColocatedServices(
647
+ config: WorldConfig,
648
+ colocated: WorldServiceConfig[],
649
+ isolation: Exclude<WorldIsolation, 'process'>,
650
+ worldEnv: Record<string, string>,
651
+ paths: { root: string; instance: string; logs: string; data: string },
652
+ ): Promise<WorldServiceInstance[]> {
653
+ const hostCli = join(import.meta.dir, 'host-cli.ts');
654
+ const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker'];
655
+ const allocated: Array<{ service: WorldServiceConfig; port: number }> = [];
656
+ for (const service of colocated) {
657
+ const port = service.port === undefined || service.port === 'auto' ? await allocatePort() : service.port;
658
+ const serviceDataDir = join(paths.data, service.id);
659
+ mkdirSync(serviceDataDir, { recursive: true });
660
+ // Relative module paths resolve from the world root (import() inside the host would
661
+ // otherwise resolve them against the runtime's own source directory).
662
+ const module = service.colocate!.module.startsWith('.') ? resolve(paths.root, service.colocate!.module) : service.colocate!.module;
663
+ args.push('--spec', [service.id, module, service.colocate!.export, String(port), serviceDataDir].join('|'));
664
+ allocated.push({ service, port });
665
+ }
666
+
667
+ const log = join(paths.logs, 'host.log');
668
+ const out = openSync(log, 'a');
669
+ // The host is world infrastructure, not an app process — same egress hygiene as controlPlane
670
+ // services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
671
+ const env: Record<string, string> = { ...process.env as Record<string, string>, ...(config.env ?? {}), ...worldEnv };
672
+ for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
673
+ const child = spawn(process.execPath, args, {
674
+ cwd: paths.root,
675
+ env,
676
+ detached: true,
677
+ stdio: ['ignore', out, out],
678
+ });
679
+ child.unref();
680
+
681
+ const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
682
+ child.on('exit', (code, signal) => {
683
+ exitState.value = { code, signal };
684
+ });
685
+
686
+ const command = [process.execPath, ...args];
687
+ for (const { service, port } of allocated) {
688
+ await waitForTcp(port).catch((error) => {
689
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
690
+ throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
691
+ });
692
+ }
693
+ if (exitState.value !== null) {
694
+ throw new Error(
695
+ `The co-located host exited during startup (code=${exitState.value.code}, signal=${exitState.value.signal}) even though its ports answer — `
696
+ + `they are almost certainly held by other/stale processes. Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
697
+ );
698
+ }
699
+
700
+ const host = '127.0.0.1';
701
+ return allocated.map(({ service, port }) => {
702
+ const url = `http://${host}:${port}`;
703
+ return {
704
+ id: service.id,
705
+ type: service.type ?? 'twin',
706
+ command,
707
+ port,
708
+ url,
709
+ pid: child.pid ?? 0,
710
+ log,
711
+ env: {
712
+ ...(service.injectEnv ? { [service.injectEnv]: url } : {}),
713
+ ...resolveServiceEnvTemplates(service.injectEnvTemplates, { host, port, url }),
714
+ ...resolveCliRedirect(service.cliRedirect, url),
715
+ },
716
+ };
717
+ });
718
+ }
719
+
720
+ export async function upWorld(configId: string, options: UpWorldOptions = {}): Promise<WorldInstance> {
721
+ const root = resolve(options.root ?? process.cwd());
722
+ const loaded = loadWorldConfig(configId, root);
723
+ const name = options.name ?? loaded.config.id;
724
+ const mode = options.mode ?? 'local';
725
+ assertSafeWorldName(name);
726
+ assertMode(mode);
727
+ const isolation = options.isolation ?? loaded.config.isolation ?? 'process';
728
+ // TWIN-67: schema.ts's WorldIsolation contract says process isolation ("one OS process per
729
+ // service") is "the only choice for share/sealed/hosted worlds" — colocating twins in one host
730
+ // process (or worker threads, which still share that process) under `share`/`sealed` puts a
731
+ // semi-untrusted session on colocated twins sharing one host process, exactly what the
732
+ // contract forbids. Enforce it here instead of leaving it a comment nothing checks. Checked
733
+ // before the file-lock claim / any dir creation below, so a rejected combination leaves nothing
734
+ // to clean up.
735
+ if (mode !== 'local' && isolation !== 'process') {
736
+ throw new Error(
737
+ `world "${name}": mode "${mode}" requires isolation "process" (schema.ts: process isolation is the only choice for share/sealed/hosted worlds), got isolation "${isolation}". Use --isolation process (or omit --isolation) with --mode ${mode}.`,
738
+ );
739
+ }
740
+
741
+ const instance = instanceDir(root, name);
742
+ const logs = join(instance, 'logs');
743
+ const data = join(instance, 'data');
744
+ const pidsFile = join(instance, 'pids');
745
+ const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
746
+ const instanceFile = join(instance, 'instance.json');
747
+
748
+ // Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
749
+ // two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
750
+ // dir, and one clobbers the other's freshly-spawned services (orphaned processes + a corrupt
751
+ // instance dir). The lock guards only the SYNCHRONOUS claim below — check, wipe, re-create,
752
+ // write `booting.json` — so it is held for milliseconds and never spans the async service boot
753
+ // (which can exceed withFileLock's 10s contender timeout); the booting marker written inside
754
+ // the lock is what keeps contenders out for the boot's whole duration, and a contender that
755
+ // finds a live booter fails FAST with a clear error instead of waiting on an open-ended boot.
756
+ // The claim fn has no awaits, so two in-process callers cannot interleave inside it either;
757
+ // the lockfile adds the cross-process exclusion, and an in-process lock holder always releases
758
+ // before its first await, so withFileLock's synchronous contender wait can never deadlock the
759
+ // event loop against a holder in the same process.
760
+ withFileLock(instanceLockFile(root, name), () => {
761
+ const live = livePids(readPids(pidsFile));
762
+ if (live.length > 0) {
763
+ throw new Error(`World "${name}" is already running (${live.join(', ')}). Run: volter-world down ${name}`);
764
+ }
765
+ const booting = liveBootingClaim(bootingFile(root, name));
766
+ if (booting) {
767
+ throw new Error(
768
+ `World "${name}" is being booted by another process (pid ${booting.pid}, since ${booting.at}). Retry after it finishes (a dead booter's claim is reclaimed automatically).`,
769
+ );
770
+ }
771
+ rmSync(instance, { recursive: true, force: true });
772
+ mkdirSync(logs, { recursive: true });
773
+ mkdirSync(data, { recursive: true });
774
+ writeFileSync(bootingFile(root, name), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() } satisfies BootingClaim)}\n`);
775
+ });
776
+
777
+ // From here on this call owns the claim; the finally below releases it however the boot
778
+ // settles. On success the recorded live pids (written before returning) take over as the
779
+ // "already running" guard; on failure the world never came up, so the next upWorld must be
780
+ // allowed to claim. Removing the marker is safe — only THIS call's marker can exist at this
781
+ // point (a foreign live claim made the claim section above throw before this try).
782
+ try {
783
+ const services: Record<string, WorldServiceInstance> = {};
784
+ const env: Record<string, string> = {
785
+ ...(loaded.config.env ?? {}),
786
+ VOLTER_WORLD_NAME: name,
787
+ VOLTER_WORLD_MODE: mode,
788
+ VOLTER_WORLD_CONFIG: loaded.path,
789
+ VOLTER_WORLD_INSTANCE: instanceFile,
790
+ // VOLTER_WORLD_SEALED is intent-only; VOLTER_TWIN_STRICT_EGRESS is what the ENFORCEMENT
791
+ // points actually gate on (redirect-proxy.ts's CONNECT/plain-proxy handlers and
792
+ // control-plane/inject.cjs's patched http/https/fetch) — both block any untwinned
793
+ // non-loopback host once this is set. Without it "sealed" only records intent and every
794
+ // untwinned host is blind-tunneled to the real internet (TWIN-60).
795
+ ...(mode === 'sealed' ? { VOLTER_WORLD_SEALED: '1', VOLTER_TWIN_STRICT_EGRESS: '1' } : {}),
796
+ NODE_OPTIONS: `--require ${injectPreloadSpecifier()}${process.env.NODE_OPTIONS ? ` ${process.env.NODE_OPTIONS}` : ''}`,
797
+ };
798
+ env.NO_PROXY = process.env.NO_PROXY ? `127.0.0.1,localhost,${process.env.NO_PROXY}` : '127.0.0.1,localhost';
799
+ env.no_proxy = process.env.no_proxy ? `127.0.0.1,localhost,${process.env.no_proxy}` : '127.0.0.1,localhost';
800
+ writeProxyEnv(root, name, env);
801
+
802
+ try {
803
+ // Non-'process' isolation: boot every colocate-declaring service inside ONE host child
804
+ // first; anything without `colocate` (and externals) still goes through the spawn path.
805
+ if (isolation !== 'process') {
806
+ const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
807
+ for (const started of await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data })) {
808
+ services[started.id] = started;
809
+ Object.assign(env, started.env);
810
+ }
811
+ writeProxyEnv(root, name, env);
812
+ }
813
+ let proxyForServices: ReturnType<typeof ensureWorldProxyFromEnv> | null = null;
814
+ // TWIN-64: attempt the ambient proxy once activeVendorMap(env) has at least one twin — tried
815
+ // again after each service in case ITS start is what makes the map non-empty. A sealed world
816
+ // must never come up silently unsealed: if openssl itself is unavailable (no proxy is even
817
+ // possible) or the proxy daemon fails/times out, refuse rather than let unmodified CLIs bypass
818
+ // the twins undetected.
819
+ const attemptProxy = (): void => {
820
+ if (proxyForServices || Object.keys(activeVendorMap(env)).length === 0) return;
821
+ if (!opensslAvailable()) {
822
+ if (mode === 'sealed') {
823
+ throw new Error(
824
+ `sealed world "${name}": openssl is unavailable — the ambient redirect proxy cannot start, so unmodified CLIs (curl, gh, stripe, …) could bypass the twins undetected; refusing to come up unsealed. Install openssl or use --mode local.`,
825
+ );
826
+ }
827
+ return;
828
+ }
829
+ proxyForServices = ensureWorldProxyFromEnv(name, root, env);
830
+ if (proxyForServices?.url) {
831
+ Object.assign(env, proxyForServices.env);
832
+ } else if (mode === 'sealed') {
833
+ // The ambient redirect proxy timed out (see the loud WARN already emitted by
834
+ // ensureWorldProxyFromEnv). Composes with the existing catch below: stops the proxy
835
+ // daemon, kills started services, rethrows.
836
+ throw new Error(`sealed world "${name}": ambient redirect proxy failed to start — refusing to come up unsealed`);
837
+ }
838
+ };
839
+ for (const service of loaded.config.services) {
840
+ if (services[service.id]) continue; // already up co-located
841
+ attemptProxy();
842
+ const started = await startService(loaded.config, service, env, { root, instance, logs, data });
843
+ services[started.id] = started;
844
+ Object.assign(env, started.env);
845
+ writeProxyEnv(root, name, env);
846
+ }
847
+ // Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
848
+ // only lands in `env` once IT has started, so a check that only runs "before starting the
849
+ // next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
850
+ // config test below exercises exactly this.
851
+ attemptProxy();
852
+ } catch (error) {
853
+ stopWorldProxy(root, name);
854
+ const pids = [...new Set(Object.values(services).map((service) => service.pid).filter(Boolean))];
855
+ signalPids(pids, 'SIGTERM');
856
+ // Stop any self-managed externals that DID come up, so a failed `up` never leaves a
857
+ // half-running external stack behind. Best-effort: the original error is what we throw —
858
+ // but a `down` that ITSELF fails must not be silent (the operator would have no hint that
859
+ // an external stack is still half-running), so those failures are appended to its message.
860
+ const rollbackErrors: string[] = [];
861
+ for (const service of Object.values(services)) {
862
+ if (service.type === 'external' && service.external) {
863
+ // Same env shape as up/status (startExternalService) and downWorld's teardown:
864
+ // process.env underneath so the tool is even findable on PATH — without it every
865
+ // rollback `down` died with "Executable not found" (silently, until surfaced below).
866
+ const downEnv = { ...process.env, ...env, ...(service.external.discoveredEnv ?? {}) };
867
+ try {
868
+ runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
869
+ } catch (downError) {
870
+ rollbackErrors.push(downError instanceof Error ? downError.message : String(downError));
871
+ }
872
+ }
873
+ }
874
+ // Same SIGTERM→SIGKILL contract as downWorld: a service that ignores SIGTERM must not
875
+ // outlive a failed boot either. The poll exits as soon as everything is dead, so the
876
+ // common case (services honor SIGTERM) adds no delay to the failure path.
877
+ await killSurvivorsAfterGrace(pids, DOWN_GRACE_MS_DEFAULT);
878
+ if (rollbackErrors.length > 0) {
879
+ const suffix = `\n(rollback: external down failed — the stack may still be half-running: ${rollbackErrors.join('; ')})`;
880
+ // Mutate rather than re-wrap: callers match on the ORIGINAL message (which must keep
881
+ // leading) and the original stack/type stay intact.
882
+ if (error instanceof Error) error.message += suffix;
883
+ else throw new Error(`${String(error)}${suffix}`);
884
+ }
885
+ throw error;
886
+ }
887
+
888
+ const worldInstance: WorldInstance = {
889
+ name,
890
+ config: loaded.config.id,
891
+ configPath: loaded.path,
892
+ root,
893
+ createdAt: new Date().toISOString(),
894
+ mode,
895
+ dirs: { instance, logs, data },
896
+ services,
897
+ env,
898
+ envFile,
899
+ pidsFile,
900
+ ...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
901
+ ...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
902
+ };
903
+
904
+ mkdirSync(dirname(envFile), { recursive: true });
905
+ writeFileSync(envFile, envFileContents(env));
906
+ writePidsFromInstance(worldInstance);
907
+ saveWorldInstance(worldInstance);
908
+ if (mode === 'share') {
909
+ try {
910
+ return await shareWorldServices(name, { root, ...(options.share ?? {}) });
911
+ } catch (error) {
912
+ await downWorld(name, root);
913
+ throw error;
914
+ }
915
+ }
916
+ return worldInstance;
917
+ } finally {
918
+ rmSync(bootingFile(root, name), { force: true });
919
+ }
920
+ }
921
+
922
+ function teardownExternalServices(instance: WorldInstance): { stopped: string[]; errors: string[] } {
923
+ const stopped: string[] = [];
924
+ const errors: string[] = [];
925
+ for (const service of Object.values(instance.services)) {
926
+ if (service.type !== 'external' || !service.external) continue;
927
+ const downEnv = { ...process.env, ...(instance.env ?? {}), ...(service.external.discoveredEnv ?? {}) };
928
+ try {
929
+ runExternalCommand(service.external.down, service.external.cwd, service.id, 'down', downEnv);
930
+ stopped.push(service.id);
931
+ } catch (error) {
932
+ errors.push(error instanceof Error ? error.message : String(error));
933
+ }
934
+ }
935
+ return { stopped, errors };
936
+ }
937
+
938
+ /** SIGTERM contract (documented in WORLD.md § Teardown): services get SIGTERM to their process
939
+ * GROUP, then `downWorld` waits up to this grace for them to exit before SIGKILLing survivors.
940
+ * Overridable per call via `options.graceMs` (the CLI exposes `--grace-ms`). */
941
+ const DOWN_GRACE_MS_DEFAULT = 5_000;
942
+
943
+ /** Signal each pid's process GROUP (services are spawned `detached`, so each pid is a group
944
+ * leader), falling back to the single pid when the group signal fails. Returns the pids that
945
+ * accepted the signal. */
946
+ function signalPids(pids: number[], signal: NodeJS.Signals): number[] {
947
+ const signalled: number[] = [];
948
+ for (const pid of pids) {
949
+ try {
950
+ process.kill(-pid, signal);
951
+ signalled.push(pid);
952
+ } catch {
953
+ try {
954
+ process.kill(pid, signal);
955
+ signalled.push(pid);
956
+ } catch {}
957
+ }
958
+ }
959
+ return signalled;
960
+ }
961
+
962
+ /** A zombie (exited but unreaped — e.g. its spawning `up` process is gone and this machine's
963
+ * PID 1 doesn't reap orphans, as in minimal containers) still answers `kill(pid, 0)` yet is dead
964
+ * for every practical purpose: it holds no ports and cannot receive SIGKILL. The escalation
965
+ * grace must not wait out a zombie — or worse, report "escalating" one. Linux: state field from
966
+ * /proc (the char right after the parenthesized comm). Elsewhere (macOS has no /proc): `ps`. */
967
+ function zombiePid(pid: number): boolean {
968
+ try {
969
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
970
+ return stat.slice(stat.lastIndexOf(')') + 2).charAt(0) === 'Z';
971
+ } catch {
972
+ const result = spawnSync('ps', ['-o', 'state=', '-p', String(pid)], { encoding: 'utf8' });
973
+ return result.status === 0 && result.stdout.trim().startsWith('Z');
974
+ }
975
+ }
976
+
977
+ /** Pids from `pids` that are neither dead nor zombied — i.e. still need killing. Shared by both
978
+ * the async and sync flavors of the grace-then-SIGKILL poll below. */
979
+ function undeadPids(pids: number[]): number[] {
980
+ return livePids(pids).filter((pid) => !zombiePid(pid));
981
+ }
982
+
983
+ /** Bounded extra wait (ms) for a pid to actually finish dying once SIGKILL has been sent. SIGKILL
984
+ * cannot be caught/blocked, but delivery + the kernel's exit transition is not instantaneous —
985
+ * callers that gate on "confirmed dead" (purge, unshareWorld) need this, not just the signal sent. */
986
+ const KILL_CONFIRM_MS = 2_000;
987
+
988
+ /** The SIGKILL half of SIGTERM→SIGKILL escalation: bounded poll on pid liveness (returns as soon
989
+ * as everything is dead — no fixed sleep), then SIGKILL whatever survived the grace, group-first
990
+ * exactly like the SIGTERM was delivered, then a short bounded confirm-wait so survivors are
991
+ * actually gone (not just signalled) by the time this returns. Returns the pids that needed
992
+ * escalation. */
993
+ async function killSurvivorsAfterGrace(pids: number[], graceMs: number): Promise<number[]> {
994
+ const deadline = Date.now() + graceMs;
995
+ while (undeadPids(pids).length > 0 && Date.now() < deadline) {
996
+ await new Promise((resolveWait) => setTimeout(resolveWait, 50));
997
+ }
998
+ const survivors = undeadPids(pids);
999
+ signalPids(survivors, 'SIGKILL');
1000
+ const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
1001
+ while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
1002
+ await new Promise((resolveWait) => setTimeout(resolveWait, 20));
1003
+ }
1004
+ return survivors;
1005
+ }
1006
+
1007
+ /** Synchronous twin of `killSurvivorsAfterGrace` for callers (`unshareWorld`) that must stay
1008
+ * synchronous — same "poll until dead or grace expires, then SIGKILL survivors, then confirm they
1009
+ * are actually gone" contract, using an `Atomics.wait` backoff (already this file's pattern for
1010
+ * synchronous polling — see `ensureWorldProxy`) instead of `setTimeout`, since a sync function has
1011
+ * no event loop to yield to. */
1012
+ function killSurvivorsAfterGraceSync(pids: number[], graceMs: number): number[] {
1013
+ const deadline = Date.now() + graceMs;
1014
+ while (undeadPids(pids).length > 0 && Date.now() < deadline) {
1015
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
1016
+ }
1017
+ const survivors = undeadPids(pids);
1018
+ signalPids(survivors, 'SIGKILL');
1019
+ const confirmDeadline = Date.now() + KILL_CONFIRM_MS;
1020
+ while (undeadPids(survivors).length > 0 && Date.now() < confirmDeadline) {
1021
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
1022
+ }
1023
+ return survivors;
1024
+ }
1025
+
1026
+ /** Async solely for the SIGTERM→SIGKILL grace poll — everything else (proxy stop, SIGTERM,
1027
+ * external teardown) still runs in the first synchronous slice, so a legacy caller that fires
1028
+ * without awaiting gets exactly the old behavior plus the deferred escalation. A blocking
1029
+ * sleep-loop would keep the sync signature but stall the caller's event loop for up to the whole
1030
+ * grace; a detached watchdog child would escalate too but leak orphan processes — the await is
1031
+ * the honest shape (upWorld/runWorld/the CLI are already async). */
1032
+ /**
1033
+ * Delete a world's instance dir entirely — the `--purge` half of `down` (TWIN-45 dev/02b): no
1034
+ * twin data (per-service `data/<id>`, logs, instance.json) survives past teardown. Plain `rm`,
1035
+ * same honesty as `world scrub` on the control-plane side (see docs/DATA_AT_REST.md).
1036
+ *
1037
+ * Safety (never delete more than THIS world's own instance dir):
1038
+ * - `assertSafeWorldName` rejects any name containing `/` or starting with `.` — the only
1039
+ * names `instanceDir`/`instanceLockFile` can ever have produced — so `dir` cannot resolve
1040
+ * outside `worldBaseDir(root)` and cannot collide with a sibling instance's directory.
1041
+ * - `dir` is asserted to be an actual child of `worldBaseDir(root)` (defense in depth against
1042
+ * a future refactor of `instanceDir` weakening that guarantee) and can never equal
1043
+ * `worldBaseDir` itself (which would nuke every world) or its `.locks` sibling.
1044
+ * - The per-world claim lock (`instanceLockFile`) lives OUTSIDE the instance dir precisely so
1045
+ * `upWorld`'s claim survives an in-flight `rmSync` of the instance dir (see its own comment);
1046
+ * purge cleans up this world's own leftover lock file by exact name — never the `.locks` dir,
1047
+ * never another world's `.lock` file.
1048
+ */
1049
+ function purgeInstanceDir(root: string, name: string): string {
1050
+ assertSafeWorldName(name);
1051
+ const base = worldBaseDir(root);
1052
+ const dir = instanceDir(root, name);
1053
+ const rel = relative(base, dir);
1054
+ if (!rel || rel === '.' || rel.startsWith('..') || rel.split(sep)[0] !== name) {
1055
+ throw new Error(`Refusing to purge: resolved instance dir "${dir}" is not a direct child of "${base}"`);
1056
+ }
1057
+ rmSync(dir, { recursive: true, force: true });
1058
+ rmSync(instanceLockFile(root, name), { force: true }); // this world's own claim lock, if left over
1059
+ return dir;
1060
+ }
1061
+
1062
+ export async function downWorld(
1063
+ name: string,
1064
+ root = process.cwd(),
1065
+ options: { graceMs?: number; purge?: boolean } = {},
1066
+ ): Promise<{ name: string; stopped: number[]; escalated: number[]; externalStopped: string[]; externalErrors: string[]; purged?: string }> {
1067
+ const resolvedRoot = resolve(root);
1068
+ const dir = instanceDir(resolvedRoot, name);
1069
+ const pidsFile = join(dir, 'pids');
1070
+ // Tear down the ambient-redirect proxy + its session CA FIRST, so the trusted CA never outlives
1071
+ // the world (it was only ever trusted via per-shell env, never system-wide).
1072
+ stopWorldProxy(resolvedRoot, name);
1073
+ const pids = readPids(pidsFile);
1074
+ const stopped = signalPids(pids, 'SIGTERM');
1075
+
1076
+ // Stop self-managed externals via their declared `down` command. Tolerate a missing
1077
+ // instance file (nothing to tear down), but surface external `down` failures to the caller.
1078
+ let externalStopped: string[] = [];
1079
+ let externalErrors: string[] = [];
1080
+ try {
1081
+ const instance = readWorldInstance(name, resolvedRoot);
1082
+ const result = teardownExternalServices(instance);
1083
+ externalStopped = result.stopped;
1084
+ externalErrors = result.errors;
1085
+ } catch {
1086
+ // no instance.json (never fully came up) — only owned processes needed stopping.
1087
+ }
1088
+
1089
+ // Escalate AFTER the (synchronous) external teardown so externals never wait on the grace, and
1090
+ // poll the full pids list rather than `stopped`: a pid whose SIGTERM errored is almost always
1091
+ // already dead, and livePids() re-checks anyway.
1092
+ const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
1093
+
1094
+ rmSync(pidsFile, { force: true });
1095
+
1096
+ // Purge LAST, only after every process (including SIGKILL escalations) is confirmed dead —
1097
+ // removing the instance dir out from under a still-running service would orphan it with no
1098
+ // recorded pid to ever find it again.
1099
+ if (!options.purge) return { name, stopped, escalated, externalStopped, externalErrors };
1100
+ if (externalErrors.length > 0) {
1101
+ // TWIN-61: instance.json is the ONLY record of a self-managed external's `down` command and
1102
+ // discovered env (schema.ts's WorldServiceInstance.external). If its `down` just failed, the
1103
+ // external stack is (probably) still running — deleting that record here would leave the
1104
+ // operator with a live external and no way to ever find how to stop it again. Skip the rmSync
1105
+ // and report the failure via `externalErrors` (already populated above); `purged` stays
1106
+ // undefined so the caller can tell purge did NOT happen.
1107
+ return { name, stopped, escalated, externalStopped, externalErrors };
1108
+ }
1109
+ const purged = purgeInstanceDir(resolvedRoot, name);
1110
+ return { name, stopped, escalated, externalStopped, externalErrors, purged };
1111
+ }
1112
+
1113
+ export function statusWorld(name: string, root = process.cwd()): WorldInstance & { running: boolean; livePids: number[] } {
1114
+ const worldInstance = readWorldInstance(name, resolve(root));
1115
+ const live = livePids(readPids(worldInstance.pidsFile));
1116
+ return { ...worldInstance, running: live.length > 0, livePids: live };
1117
+ }
1118
+
1119
+ function shareCommand(options: ShareWorldOptions, serviceUrl: string): { provider: 'cloudflare-quick' | 'command'; command: string; args: string[] } {
1120
+ if (!options.command && options.provider === 'command') {
1121
+ throw new Error(`share provider "command" requires a tunnel command (share.command / --command)`);
1122
+ }
1123
+ if (options.command) {
1124
+ return {
1125
+ provider: options.provider ?? 'command',
1126
+ command: options.command,
1127
+ args: (options.args ?? []).map((arg) => arg.replaceAll('{url}', serviceUrl)),
1128
+ };
1129
+ }
1130
+ return {
1131
+ provider: 'cloudflare-quick',
1132
+ command: 'cloudflared',
1133
+ args: ['tunnel', '--url', serviceUrl],
1134
+ };
1135
+ }
1136
+
1137
+ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string, timeoutMs: number, requireTryCloudflare: boolean): Promise<string> {
1138
+ const urlPattern = /https:\/\/[a-zA-Z0-9.-]+/g;
1139
+ let exited = false;
1140
+ let exitSummary = '';
1141
+ child.once('exit', (code, signal) => {
1142
+ exited = true;
1143
+ exitSummary = `code=${code ?? 'null'} signal=${signal ?? 'null'}`;
1144
+ });
1145
+ child.once('error', (error) => {
1146
+ exited = true;
1147
+ exitSummary = error.message;
1148
+ });
1149
+
1150
+ const started = Date.now();
1151
+ while (Date.now() - started < timeoutMs) {
1152
+ const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
1153
+ const matches = text.match(urlPattern);
1154
+ const url = requireTryCloudflare
1155
+ ? matches?.find((candidate) => candidate.includes('trycloudflare.com'))
1156
+ : matches?.find((candidate) => candidate.includes('trycloudflare.com')) ?? matches?.[0];
1157
+ if (url) return url;
1158
+ if (exited) throw new Error(`Tunnel process exited before printing a public URL (${exitSummary}). Log: ${logPath}`);
1159
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
1160
+ }
1161
+ throw new Error(`Tunnel did not print a public https URL within ${timeoutMs}ms. Log: ${logPath}`);
1162
+ }
1163
+
1164
+ function sleep(ms: number): Promise<void> {
1165
+ return new Promise((resolveWait) => setTimeout(resolveWait, ms));
1166
+ }
1167
+
1168
+ function commandExists(command: string): boolean {
1169
+ // Pass the live env so PATH reflects the current process (some runtimes snapshot env otherwise).
1170
+ const result = spawnSync('which', [command], { stdio: 'ignore', env: process.env });
1171
+ return result.status === 0;
1172
+ }
1173
+
1174
+ function resolveWithPublicDns(hostname: string): string | undefined {
1175
+ const result = spawnSync('dig', ['+short', '@1.1.1.1', hostname, 'A'], {
1176
+ encoding: 'utf8',
1177
+ });
1178
+ if (result.status !== 0) return undefined;
1179
+ return result.stdout
1180
+ .split(/\r?\n/)
1181
+ .map((line) => line.trim())
1182
+ .find((line) => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(line));
1183
+ }
1184
+
1185
+ async function waitForPublicDns(hostname: string, timeoutMs: number): Promise<string | undefined> {
1186
+ const started = Date.now();
1187
+ while (Date.now() - started < timeoutMs) {
1188
+ const ip = resolveWithPublicDns(hostname);
1189
+ if (ip) return ip;
1190
+ await sleep(1_000);
1191
+ }
1192
+ return undefined;
1193
+ }
1194
+
1195
+ function publicHealthUrl(publicUrl: string, path: string): string {
1196
+ const url = new URL(publicUrl);
1197
+ url.pathname = path.startsWith('/') ? path : `/${path}`;
1198
+ url.search = '';
1199
+ url.hash = '';
1200
+ return url.toString();
1201
+ }
1202
+
1203
+ async function verifyPublicUrl(
1204
+ publicUrl: string,
1205
+ path: string,
1206
+ timeoutMs: number,
1207
+ ): Promise<NonNullable<WorldServiceInstance['publicVerification']>> {
1208
+ if (!commandExists('dig')) throw new Error('Public URL verification requires dig');
1209
+ if (!commandExists('curl')) throw new Error('Public URL verification requires curl');
1210
+ const hostname = new URL(publicUrl).hostname;
1211
+ const started = Date.now();
1212
+ const resolvedIp = await waitForPublicDns(hostname, Math.min(timeoutMs, 60_000));
1213
+ if (!resolvedIp) {
1214
+ throw new Error(`Public URL did not resolve through @1.1.1.1 within ${Math.min(timeoutMs, 60_000)}ms: ${hostname}`);
1215
+ }
1216
+
1217
+ const target = publicHealthUrl(publicUrl, path);
1218
+ let lastError = '';
1219
+ while (Date.now() - started < timeoutMs) {
1220
+ const remainingMs = Math.max(timeoutMs - (Date.now() - started), 1);
1221
+ const result = spawnSync(
1222
+ 'curl',
1223
+ [
1224
+ '-sS',
1225
+ '-o',
1226
+ '-',
1227
+ '-w',
1228
+ '\n%{http_code}',
1229
+ '--max-time',
1230
+ String(Math.min(10, Math.max(1, Math.ceil(remainingMs / 1000)))),
1231
+ '--resolve',
1232
+ `${hostname}:443:${resolvedIp}`,
1233
+ target,
1234
+ ],
1235
+ { encoding: 'utf8' },
1236
+ );
1237
+ if (result.status === 0) {
1238
+ const output = result.stdout;
1239
+ const separator = output.lastIndexOf('\n');
1240
+ const body = separator >= 0 ? output.slice(0, separator).trim() : output.trim();
1241
+ const status = Number(separator >= 0 ? output.slice(separator + 1).trim() : '0');
1242
+ if (status >= 200 && status < 400) {
1243
+ return {
1244
+ path,
1245
+ checkedAt: new Date().toISOString(),
1246
+ hostname,
1247
+ resolvedIp,
1248
+ status,
1249
+ body: body.slice(0, 500),
1250
+ };
1251
+ }
1252
+ lastError = `HTTP ${status}${body ? `: ${body.slice(0, 200)}` : ''}`;
1253
+ } else {
1254
+ lastError = result.stderr.trim() || `curl exited ${result.status}`;
1255
+ }
1256
+ await sleep(Math.min(2_000, Math.max(timeoutMs - (Date.now() - started), 0)));
1257
+ }
1258
+ throw new Error(`Public URL did not pass health verification at ${target}: ${lastError || 'timed out'}`);
1259
+ }
1260
+
1261
+ export async function shareWorld(name: string, options: ShareWorldOptions = {}): Promise<WorldInstance> {
1262
+ const root = resolve(options.root ?? process.cwd());
1263
+ const serviceId = options.service ?? 'app';
1264
+ const instance = readWorldInstance(name, root);
1265
+ const service = instance.services[serviceId];
1266
+ if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
1267
+ if (service.tunnel && livePids([service.tunnel.pid]).length > 0) {
1268
+ throw new Error(`World "${name}" service "${serviceId}" is already shared at ${service.publicUrl}`);
1269
+ }
1270
+ if (!service.url) {
1271
+ throw new Error(`World "${name}" service "${serviceId}" has no local URL to share (external/self-managed services are not assigned one)`);
1272
+ }
1273
+
1274
+ const tunnel = shareCommand(options, service.url);
1275
+ const log = join(instance.dirs.logs, `${serviceId}.tunnel.log`);
1276
+ const out = openSync(log, 'a');
1277
+ const child = spawn(tunnel.command, tunnel.args, {
1278
+ cwd: root,
1279
+ env: process.env,
1280
+ detached: true,
1281
+ stdio: ['ignore', out, out],
1282
+ });
1283
+ closeSync(out);
1284
+
1285
+ let publicUrl: string;
1286
+ let publicVerification: WorldServiceInstance['publicVerification'];
1287
+ try {
1288
+ publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
1289
+ if (options.verifyPath !== false) {
1290
+ publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
1291
+ }
1292
+ } catch (error) {
1293
+ // TWIN-62: never-half-shared means the tunnel must actually be confirmed dead before we
1294
+ // rethrow, not just best-effort SIGTERM'd — the same SIGTERM→grace→SIGKILL contract `downWorld`
1295
+ // gives owned services (a cloudflared that ignores SIGTERM must not keep serving after a
1296
+ // failed/rejected `share`).
1297
+ if (child.pid) {
1298
+ signalPids([child.pid], 'SIGTERM');
1299
+ await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
1300
+ }
1301
+ throw error;
1302
+ }
1303
+ child.unref();
1304
+
1305
+ service.publicUrl = publicUrl;
1306
+ service.publicUrlEphemeral = tunnel.provider === 'cloudflare-quick';
1307
+ service.publicReady = options.verifyPath !== false;
1308
+ if (publicVerification) service.publicVerification = publicVerification;
1309
+ else delete service.publicVerification;
1310
+ service.tunnel = {
1311
+ provider: tunnel.provider,
1312
+ pid: child.pid ?? 0,
1313
+ log,
1314
+ command: [tunnel.command, ...tunnel.args],
1315
+ startedAt: new Date().toISOString(),
1316
+ };
1317
+ writePidsFromInstance(instance);
1318
+ saveWorldInstance(instance);
1319
+ return instance;
1320
+ }
1321
+
1322
+ function configuredShareTargets(
1323
+ instance: WorldInstance,
1324
+ requestedService?: string,
1325
+ verifyPath?: string | false,
1326
+ ): Array<{ id: string; verifyPath?: string | false }> {
1327
+ if (requestedService) return [{ id: requestedService, verifyPath }];
1328
+ const config = loadWorldConfig(instance.configPath, instance.root).config;
1329
+ if (config.share?.services.length) {
1330
+ return config.share.services.map((service) => ({
1331
+ id: service.id,
1332
+ verifyPath: verifyPath ?? service.verifyPath,
1333
+ }));
1334
+ }
1335
+ return [{ id: 'app', verifyPath }];
1336
+ }
1337
+
1338
+ export async function shareWorldServices(name: string, options: ShareWorldServicesOptions = {}): Promise<WorldInstance> {
1339
+ const root = resolve(options.root ?? process.cwd());
1340
+ let instance = readWorldInstance(name, root);
1341
+ const config = loadWorldConfig(instance.configPath, instance.root).config;
1342
+ const targets = configuredShareTargets(instance, options.service, options.verifyPath);
1343
+ for (const target of targets) {
1344
+ instance = await shareWorld(name, {
1345
+ ...options,
1346
+ root,
1347
+ provider: options.provider ?? config.share?.provider,
1348
+ command: options.command ?? config.share?.command,
1349
+ args: options.args ?? config.share?.args,
1350
+ service: target.id,
1351
+ verifyPath: target.verifyPath,
1352
+ });
1353
+ }
1354
+ return instance;
1355
+ }
1356
+
1357
+ /** TWIN-62: `unshare` must give tunnels the same contract plain `downWorld` already gives owned
1358
+ * services — SIGTERM, confirm dead (escalating to SIGKILL past the grace), and only THEN erase the
1359
+ * tunnel facts/pids. A best-effort SIGTERM with no confirmation can leave a `cloudflared` that
1360
+ * ignores SIGTERM serving the public URL forever while instance.json (and the pids file) say
1361
+ * nothing is shared — the exact "half-shared" state WORLD.md promises cannot exist. Stays
1362
+ * synchronous (via `killSurvivorsAfterGraceSync`) to match the existing signature/call sites
1363
+ * (the CLI does not await it). Returns the instance plus the tunnel pids that needed escalating,
1364
+ * for callers that want to report it. */
1365
+ export function unshareWorld(name: string, options: { root?: string; service?: string; graceMs?: number } = {}): WorldInstance & { escalated: number[] } {
1366
+ const root = resolve(options.root ?? process.cwd());
1367
+ const instance = readWorldInstance(name, root);
1368
+ const serviceIds = options.service ? [options.service] : Object.keys(instance.services);
1369
+ const escalated: number[] = [];
1370
+ const stillLive: string[] = [];
1371
+ for (const serviceId of serviceIds) {
1372
+ const service = instance.services[serviceId];
1373
+ if (!service) throw new Error(`World "${name}" has no service "${serviceId}"`);
1374
+ if (service.tunnel?.pid) {
1375
+ const pid = service.tunnel.pid;
1376
+ signalPids([pid], 'SIGTERM');
1377
+ const survivors = killSurvivorsAfterGraceSync([pid], options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
1378
+ if (survivors.length > 0) escalated.push(...survivors);
1379
+ // `undeadPids` (not the raw `livePids`) is the right "confirmed dead" test here: a zombie
1380
+ // (exited but unreaped — likely since we just polled it synchronously via `Atomics.wait`,
1381
+ // which blocks the event loop that would otherwise let this process auto-reap its child) still
1382
+ // answers `kill(pid, 0)` yet is dead for every practical purpose — see `zombiePid`'s own
1383
+ // comment. Using plain `livePids` here would treat every zombie as "still shared".
1384
+ if (undeadPids([pid]).length > 0) {
1385
+ // Even SIGKILL didn't confirm it dead (e.g. no permission to signal it) — refuse to erase
1386
+ // the only record of this tunnel rather than orphan it with instance.json saying "unshared".
1387
+ stillLive.push(serviceId);
1388
+ continue;
1389
+ }
1390
+ }
1391
+ delete service.publicUrl;
1392
+ delete service.publicUrlEphemeral;
1393
+ delete service.publicReady;
1394
+ delete service.publicVerification;
1395
+ delete service.tunnel;
1396
+ }
1397
+ writePidsFromInstance(instance);
1398
+ saveWorldInstance(instance);
1399
+ if (stillLive.length > 0) {
1400
+ throw new Error(`World "${name}": tunnel process(es) for ${stillLive.join(', ')} could not be confirmed dead (even after SIGKILL) — refusing to erase their records`);
1401
+ }
1402
+ return Object.assign(instance, { escalated });
1403
+ }
1404
+
1405
+ /** Short, hardcoded timeout for `doctorWorld`'s external re-probe (TWIN-68) — this is a health
1406
+ * CHECK, not a boot wait, so it must never make `doctor` hang: a dead external should fail FAST. */
1407
+ const DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS = 3_000;
1408
+
1409
+ /** Run an external service's declared readiness probe exactly ONCE (unlike `awaitReadiness`, which
1410
+ * polls until it passes or times out — appropriate at boot, wrong for a doctor health check, which
1411
+ * must report the CURRENT state, not wait for it to become healthy). Reuses the same probe shapes
1412
+ * (`command`/`httpUrl`/`stdoutMatch`) declared in config for the boot-time `readyWhen`. */
1413
+ async function probeExternalOnce(
1414
+ ready: WorldExternalReadyWhen,
1415
+ cwd: string,
1416
+ env: NodeJS.ProcessEnv,
1417
+ logPath: string,
1418
+ ): Promise<{ ok: boolean; message: string }> {
1419
+ if (ready.command !== undefined) {
1420
+ if (!commandExists(ready.command)) {
1421
+ return { ok: false, message: `readiness command \`${ready.command}\` not found on PATH` };
1422
+ }
1423
+ const result = spawnSync(ready.command, ready.args ?? [], {
1424
+ cwd, encoding: 'utf8', env, timeout: DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
1425
+ });
1426
+ if (result.status === 0) return { ok: true, message: 'probe ok (command)' };
1427
+ const timedOut = result.signal !== null && result.status === null;
1428
+ return { ok: false, message: timedOut ? `probe command timed out after ${DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS}ms` : `probe command exited ${result.status ?? 'null'}: ${(result.stderr ?? '').trim().slice(0, 300)}` };
1429
+ }
1430
+ if (ready.httpUrl !== undefined) {
1431
+ try {
1432
+ const response = await fetch(ready.httpUrl, { signal: AbortSignal.timeout(DOCTOR_EXTERNAL_PROBE_TIMEOUT_MS) });
1433
+ if (response.status >= 200 && response.status < 400) return { ok: true, message: `probe ok (HTTP ${response.status})` };
1434
+ return { ok: false, message: `probe returned HTTP ${response.status}` };
1435
+ } catch (error) {
1436
+ return { ok: false, message: `probe failed: ${error instanceof Error ? error.message : String(error)}` };
1437
+ }
1438
+ }
1439
+ if (ready.stdoutMatch !== undefined) {
1440
+ const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
1441
+ if (new RegExp(ready.stdoutMatch).test(text)) return { ok: true, message: 'probe ok (stdoutMatch)' };
1442
+ return { ok: false, message: `probe pattern /${ready.stdoutMatch}/ not found in log` };
1443
+ }
1444
+ return { ok: false, message: 'malformed readiness probe (no command/httpUrl/stdoutMatch)' };
1445
+ }
1446
+
1447
+ export async function doctorWorld(name: string, options: { root?: string; verifyPublic?: boolean; timeoutMs?: number } = {}): Promise<WorldDoctorReport> {
1448
+ const root = resolve(options.root ?? process.cwd());
1449
+ const checks: WorldDoctorCheck[] = [];
1450
+ let instance: WorldInstance;
1451
+ try {
1452
+ instance = readWorldInstance(name, root);
1453
+ checks.push({ id: 'instance', ok: true, message: `instance=${instanceFile(root, name)}` });
1454
+ } catch (error) {
1455
+ const message = error instanceof Error ? error.message : String(error);
1456
+ return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
1457
+ }
1458
+
1459
+ checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
1460
+
1461
+ const live = livePids(readPids(instance.pidsFile));
1462
+ checks.push({
1463
+ id: 'pids',
1464
+ ok: live.length > 0,
1465
+ message: live.length > 0 ? `live=${live.join(', ')}` : 'no live world processes',
1466
+ });
1467
+
1468
+ // Loaded once, best-effort: only used to find each external service's declared readyWhen/status
1469
+ // probe (TWIN-68) — if the config can't be reloaded (moved/deleted since `up`), externals just
1470
+ // fall back to the "no probe declared" informational check rather than failing doctor entirely.
1471
+ // (Named distinctly from the `config` reloaded further below for the share-mode checks, which
1472
+ // intentionally keeps its own separate, non-best-effort load.)
1473
+ let externalProbeConfig: WorldConfig | undefined;
1474
+ try {
1475
+ externalProbeConfig = loadWorldConfig(instance.configPath, instance.root).config;
1476
+ } catch {
1477
+ externalProbeConfig = undefined;
1478
+ }
1479
+
1480
+ for (const service of Object.values(instance.services)) {
1481
+ if (service.type === 'external') {
1482
+ // We don't own the external tool's process or its port — report what it discovered, plus
1483
+ // (TWIN-68) re-run its declared probe once so a stack that died mid-session goes red instead
1484
+ // of reporting an unconditional ok:true forever.
1485
+ const discovered = Object.keys(service.env ?? {});
1486
+ const discoveredMessage = discovered.length > 0 ? `discovered ${discovered.join(', ')}` : 'self-managed (no discovered env)';
1487
+ const configuredExternal = externalProbeConfig?.services.find((candidate) => candidate.id === service.id)?.external;
1488
+ const probeCwd = service.external?.cwd ?? root;
1489
+ const probeEnv = { ...process.env, ...(instance.env ?? {}), ...(service.external?.discoveredEnv ?? {}) };
1490
+ if (configuredExternal?.readyWhen) {
1491
+ const probe = await probeExternalOnce(configuredExternal.readyWhen, probeCwd, probeEnv, service.log);
1492
+ checks.push({ id: `service:${service.id}:external`, ok: probe.ok, message: `${discoveredMessage}; ${probe.message}` });
1493
+ } else if (configuredExternal?.status) {
1494
+ try {
1495
+ runExternalCommand(configuredExternal.status, probeCwd, service.id, 'doctor-probe', probeEnv);
1496
+ checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; probe ok (status)` });
1497
+ } catch (error) {
1498
+ checks.push({ id: `service:${service.id}:external`, ok: false, message: `${discoveredMessage}; probe failed: ${error instanceof Error ? error.message : String(error)}` });
1499
+ }
1500
+ } else {
1501
+ checks.push({ id: `service:${service.id}:external`, ok: true, message: `${discoveredMessage}; no probe declared — health unknown` });
1502
+ }
1503
+ continue;
1504
+ }
1505
+ const pidLive = livePids([service.pid]).length > 0;
1506
+ checks.push({
1507
+ id: `service:${service.id}:pid`,
1508
+ ok: pidLive,
1509
+ message: pidLive ? `pid=${service.pid}` : `pid ${service.pid} is not live`,
1510
+ });
1511
+ if (service.workerGaveUp) {
1512
+ // A worker-isolated twin whose Worker thread crashed: the host process (and its pid) is
1513
+ // still alive, but this twin's own worker was given up on (never restarted) — surface it
1514
+ // explicitly rather than let it hide behind an otherwise-generic tcp-refused check below.
1515
+ checks.push({
1516
+ id: `service:${service.id}:worker`,
1517
+ ok: false,
1518
+ message: `worker exited and was not restarted (${service.workerGaveUp.detail}), at ${service.workerGaveUp.at}`,
1519
+ });
1520
+ }
1521
+ try {
1522
+ await waitForTcp(service.port!, 1_000);
1523
+ checks.push({ id: `service:${service.id}:tcp`, ok: true, message: service.url! });
1524
+ } catch (error) {
1525
+ checks.push({
1526
+ id: `service:${service.id}:tcp`,
1527
+ ok: false,
1528
+ message: error instanceof Error ? error.message : String(error),
1529
+ });
1530
+ }
1531
+ if (service.tunnel || service.publicUrl) {
1532
+ const tunnelLive = service.tunnel ? livePids([service.tunnel.pid]).length > 0 : false;
1533
+ checks.push({
1534
+ id: `service:${service.id}:tunnel`,
1535
+ ok: tunnelLive,
1536
+ message: service.tunnel ? `pid=${service.tunnel.pid}` : 'missing tunnel process',
1537
+ });
1538
+ checks.push({
1539
+ id: `service:${service.id}:public-ready`,
1540
+ ok: service.publicReady === true,
1541
+ message: service.publicUrl ?? 'missing public URL',
1542
+ });
1543
+ if (options.verifyPublic && service.publicUrl && service.publicVerification?.path) {
1544
+ try {
1545
+ const verification = await verifyPublicUrl(service.publicUrl, service.publicVerification.path, options.timeoutMs ?? 10_000);
1546
+ checks.push({
1547
+ id: `service:${service.id}:public-health`,
1548
+ ok: true,
1549
+ message: `${verification.status} ${service.publicUrl}${service.publicVerification.path}`,
1550
+ });
1551
+ } catch (error) {
1552
+ checks.push({
1553
+ id: `service:${service.id}:public-health`,
1554
+ ok: false,
1555
+ message: error instanceof Error ? error.message : String(error),
1556
+ });
1557
+ }
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ if (instance.mode === 'share') {
1563
+ const config = loadWorldConfig(instance.configPath, instance.root).config;
1564
+ const expected = config.share?.services.length ? config.share.services : [{ id: 'app' }];
1565
+ for (const target of expected) {
1566
+ const service = instance.services[target.id];
1567
+ checks.push({
1568
+ id: `share:${target.id}`,
1569
+ ok: service?.publicReady === true && Boolean(service.publicUrl),
1570
+ message: service?.publicUrl ?? 'not shared',
1571
+ });
1572
+ }
1573
+ }
1574
+
1575
+ return { name: instance.name, ok: checks.every((check) => check.ok), checks };
1576
+ }
1577
+
1578
+ export function urlsWorld(name: string, root = process.cwd()): WorldUrlInfo {
1579
+ const instance = readWorldInstance(name, resolve(root));
1580
+ return {
1581
+ name: instance.name,
1582
+ mode: instance.mode,
1583
+ services: Object.fromEntries(Object.values(instance.services).map((service) => [
1584
+ service.id,
1585
+ {
1586
+ localUrl: service.url ?? '',
1587
+ ...(service.publicUrl ? { publicUrl: service.publicUrl } : {}),
1588
+ ...(service.publicReady !== undefined ? { publicReady: service.publicReady } : {}),
1589
+ },
1590
+ ])),
1591
+ };
1592
+ }
1593
+
1594
+ export async function runWorld(configId: string, command: string[], options: RunWorldOptions = {}): Promise<{ instance: WorldInstance; exitCode: number }> {
1595
+ if (command.length === 0) throw new Error('Missing command after --');
1596
+ const instance = await upWorld(configId, options);
1597
+ let exitCode = 1;
1598
+ try {
1599
+ exitCode = runWithWorldEnv(instance.name, command, instance.root);
1600
+ return { instance, exitCode };
1601
+ } finally {
1602
+ if (!options.keep) await downWorld(instance.name, instance.root);
1603
+ }
1604
+ }
1605
+
1606
+ export function listWorlds(root = process.cwd()): Array<{ name: string; running: boolean; config?: string }> {
1607
+ const base = worldBaseDir(resolve(root));
1608
+ if (!existsSync(base)) return [];
1609
+ return readdirSync(base, { withFileTypes: true })
1610
+ .filter((entry) => entry.isDirectory())
1611
+ .map((entry) => {
1612
+ try {
1613
+ const status = statusWorld(entry.name, root);
1614
+ return { name: entry.name, running: status.running, config: status.config };
1615
+ } catch {
1616
+ return { name: entry.name, running: false };
1617
+ }
1618
+ })
1619
+ .sort((a, b) => a.name.localeCompare(b.name));
1620
+ }
1621
+
1622
+ export function runWithWorldEnv(name: string, command: string[], root = process.cwd()): number {
1623
+ if (command.length === 0) throw new Error('Missing command after --');
1624
+ const status = statusWorld(name, root);
1625
+ const sealed = status.env.VOLTER_WORLD_MODE === 'sealed';
1626
+ let proxyEnv: Record<string, string> = {};
1627
+ try {
1628
+ const proxy = ensureWorldProxy(name, root);
1629
+ if (proxy?.url) {
1630
+ proxyEnv = proxy.env;
1631
+ } else if (sealed && proxy === null) {
1632
+ // TWIN-64: ensureWorldProxy() returns bare `null` when openssl is unavailable — silently, by
1633
+ // design, for the non-sealed env-only fallback. A SEALED world running a command without the
1634
+ // ambient proxy is a different story: the Node injector (loaded via NODE_OPTIONS, still in
1635
+ // `status.env`) keeps blocking Node http/https/fetch calls, but unmodified non-Node CLIs
1636
+ // (curl, gh, stripe, …) have no HTTPS_PROXY to redirect them and can bypass the twins
1637
+ // entirely. Never let that be silent — the daemon-timeout case is already loud (see the WARN
1638
+ // `ensureWorldProxy` itself emits), this covers the "no openssl at all" gap.
1639
+ process.stderr.write(
1640
+ `!! WARN: world "${name}" is sealed but openssl is unavailable — NOT proxy-sealed for this command; unmodified CLIs (curl, gh, stripe, …) can bypass the twins here (Node http/https/fetch calls are still blocked via the injector).\n`,
1641
+ );
1642
+ }
1643
+ } catch (error) {
1644
+ if (sealed) {
1645
+ // Never swallow a sealed world's proxy failure silently (TWIN-64): the command is about to
1646
+ // run NOT proxy-sealed for unmodified CLIs even though Node calls stay guarded by the
1647
+ // injector.
1648
+ process.stderr.write(
1649
+ `!! WARN: world "${name}" is sealed but the ambient redirect proxy failed to start (${error instanceof Error ? error.message : String(error)}) — NOT proxy-sealed for this command; unmodified CLIs (curl, gh, stripe, …) can bypass the twins here.\n`,
1650
+ );
1651
+ }
1652
+ // Keep env-only redirect if the ambient proxy cannot start.
1653
+ }
1654
+ const result = spawnSync(command[0]!, command.slice(1), {
1655
+ cwd: resolve(root),
1656
+ env: { ...process.env, ...status.env, ...proxyEnv },
1657
+ stdio: 'inherit',
1658
+ });
1659
+ return result.status ?? 1;
1660
+ }
1661
+
1662
+ /** The shell + env + cwd a `volter-world shell` subshell launches with (extracted for testability):
1663
+ * the world env (twin `*_URL`s + fake keys + the Node injector + cliRedirect endpoint vars) on top
1664
+ * of the caller's env, plus `VOLTER_WORLD` so a prompt can show which world is active. The ambient
1665
+ * TLS-proxy env (HTTPS_PROXY + the CA-trust vars) is layered in by `shellWorld` once the proxy is up,
1666
+ * since starting a server is async; this pure helper stays synchronous + side-effect-free for tests. */
1667
+ export function worldShellEnv(name: string, root = process.cwd()): { shell: string; cwd: string; env: Record<string, string> } {
1668
+ const status = statusWorld(name, root);
1669
+ if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
1670
+ return {
1671
+ shell: process.env.SHELL || '/bin/bash',
1672
+ cwd: resolve(root),
1673
+ env: { ...process.env, ...status.env, VOLTER_WORLD: name } as Record<string, string>,
1674
+ };
1675
+ }
1676
+
1677
+ /** Drop into an interactive subshell with the world active — the contained sibling of `activate`
1678
+ * (which wires your CURRENT shell + sets the `(world:<name>)` prompt marker). The subshell gets the
1679
+ * world env AND, when openssl is available, an in-process ambient TLS redirect proxy: unmodified
1680
+ * `gh`/`stripe`/`curl`/app in the subshell transparently hit the twins. Vendor calls in this subshell
1681
+ * land in the twins; `exit` restores normal (the proxy + its session CA are torn down). The CA is
1682
+ * trusted ONLY via this subshell's env — never installed system-wide. See docs/WORLD_ACTIVATE.md. */
1683
+ export async function shellWorld(name: string, root = process.cwd()): Promise<number> {
1684
+ const { shell, cwd, env } = worldShellEnv(name, root);
1685
+ const resolvedRoot = resolve(root);
1686
+ let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
1687
+ if (opensslAvailable()) {
1688
+ try {
1689
+ proxy = await startRedirectProxy({ env, tlsDir: tlsDir(resolvedRoot, name) });
1690
+ Object.assign(env, proxy.proxyEnv());
1691
+ process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (ambient proxy ${proxy.url}). type 'exit' to leave.\n`);
1692
+ } catch (error) {
1693
+ process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${error instanceof Error ? error.message : String(error)}); falling back to env-only redirect.\n`);
1694
+ }
1695
+ } else {
1696
+ 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`);
1697
+ }
1698
+ try {
1699
+ const result = spawnSync(shell, ['-i'], { cwd, env, stdio: 'inherit' });
1700
+ return result.status ?? 0;
1701
+ } finally {
1702
+ if (proxy) {
1703
+ await proxy.close();
1704
+ tearDownCa(tlsDir(resolvedRoot, name));
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ /** The daemon-poll deadline (ms) used by `ensureWorldProxy`/`ensureWorldProxyFromEnv` below. Hardcoded
1710
+ * to 5000ms in production; `VOLTER_PROXY_DEADLINE_MS` is a TEST-ONLY seam so tests can force a
1711
+ * deterministic timeout without an actual multi-second wait. Never set this env var in real usage. */
1712
+ function proxyDaemonDeadlineMs(): number {
1713
+ const raw = process.env.VOLTER_PROXY_DEADLINE_MS;
1714
+ if (raw === undefined) return 5000;
1715
+ const parsed = Number(raw);
1716
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5000;
1717
+ }
1718
+
1719
+ /** Ensure a DETACHED ambient-redirect proxy daemon is running for this world (used by `activate`,
1720
+ * which only emits a script and can't host an in-process server for the life of the shell). Idempotent:
1721
+ * reuses a live daemon. Returns its proxy env, or null if openssl is missing (env-only fallback). The
1722
+ * daemon is recorded in `proxy.json` and stopped by `down`. */
1723
+ export function ensureWorldProxy(name: string, root = process.cwd()): { url: string; caCertPath: string; env: Record<string, string> } | null {
1724
+ const resolvedRoot = resolve(root);
1725
+ if (!opensslAvailable()) return null;
1726
+ const existing = readProxyState(resolvedRoot, name);
1727
+ if (existing && isAlive(existing.pid)) {
1728
+ return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath) };
1729
+ }
1730
+ // Pre-create the CA synchronously so we can return its path immediately; the daemon reuses it.
1731
+ const ca = ensureCa(tlsDir(resolvedRoot, name));
1732
+ const child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', resolvedRoot], {
1733
+ cwd: resolvedRoot,
1734
+ env: process.env,
1735
+ detached: true,
1736
+ stdio: 'ignore',
1737
+ });
1738
+ child.unref();
1739
+ // Wait briefly for the daemon to write its state (port).
1740
+ const deadlineMs = proxyDaemonDeadlineMs();
1741
+ const deadline = Date.now() + deadlineMs;
1742
+ while (Date.now() < deadline) {
1743
+ const state = readProxyState(resolvedRoot, name);
1744
+ if (state && state.pid === child.pid) {
1745
+ return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath) };
1746
+ }
1747
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // 50ms synchronous backoff
1748
+ }
1749
+ // Daemon didn't report in time — fall back to env-only redirect rather than fabricate success.
1750
+ // LOUD, never silent: a world that appears sealed but isn't is a purpose-1 integrity hole.
1751
+ process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
1752
+ return { url: '', caCertPath: ca.caCert, env: {} };
1753
+ }
1754
+
1755
+ function ensureWorldProxyFromEnv(name: string, root: string, env: Record<string, string>): { url: string; caCertPath: string; env: Record<string, string> } | null {
1756
+ const resolvedRoot = resolve(root);
1757
+ if (!opensslAvailable()) return null;
1758
+ writeProxyEnv(resolvedRoot, name, env);
1759
+ const existing = readProxyState(resolvedRoot, name);
1760
+ if (existing && isAlive(existing.pid)) {
1761
+ return { url: existing.url, caCertPath: existing.caCertPath, env: proxyEnvFor(existing.url, existing.caCertPath) };
1762
+ }
1763
+ const ca = ensureCa(tlsDir(resolvedRoot, name));
1764
+ const child = spawn(process.execPath, [proxyDaemonEntry(), name, '--root', resolvedRoot, '--env-file', proxyEnvFile(resolvedRoot, name)], {
1765
+ cwd: resolvedRoot,
1766
+ env: process.env,
1767
+ detached: true,
1768
+ stdio: 'ignore',
1769
+ });
1770
+ child.unref();
1771
+ const deadlineMs = proxyDaemonDeadlineMs();
1772
+ const deadline = Date.now() + deadlineMs;
1773
+ while (Date.now() < deadline) {
1774
+ const state = readProxyState(resolvedRoot, name);
1775
+ if (state && state.pid === child.pid) {
1776
+ return { url: state.url, caCertPath: state.caCertPath, env: proxyEnvFor(state.url, state.caCertPath) };
1777
+ }
1778
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
1779
+ }
1780
+ // Daemon didn't report in time — fall back to env-only redirect rather than fabricate success.
1781
+ // LOUD, never silent: `upWorld` (sealed mode) turns this into a hard failure; other callers just
1782
+ // see the empty url and keep the env-only redirect, but the operator must be told either way.
1783
+ process.stderr.write(`!! WARN: ambient proxy daemon for world "${name}" did not report within ${deadlineMs}ms — world is NOT proxy-sealed; unmodified CLIs will bypass the twins (env-only redirect still active).\n`);
1784
+ return { url: '', caCertPath: ca.caCert, env: {} };
1785
+ }
1786
+
1787
+ function isAlive(pid: number): boolean {
1788
+ if (!pid) return false;
1789
+ try {
1790
+ process.kill(pid, 0);
1791
+ return true;
1792
+ } catch {
1793
+ return false;
1794
+ }
1795
+ }
1796
+
1797
+ function proxyDaemonEntry(): string {
1798
+ return new URL('./proxy-daemon.ts', import.meta.url).pathname;
1799
+ }
1800
+
1801
+ /** The detached proxy daemon's body (invoked as its own process by `ensureWorldProxy`). Starts the
1802
+ * redirect proxy bound to an ephemeral port, records its pid/url/CA in `proxy.json`, and stays up
1803
+ * until killed by `down`. Exported so the daemon entry file can delegate to it. */
1804
+ export async function runProxyDaemon(name: string, root = process.cwd(), envFile?: string): Promise<void> {
1805
+ const resolvedRoot = resolve(root);
1806
+ const envLoader = envFile
1807
+ ? () => ({ ...process.env, ...readProxyEnv(envFile) } as Record<string, string>)
1808
+ : undefined;
1809
+ let env: Record<string, string> | undefined;
1810
+ if (!envLoader) {
1811
+ const status = statusWorld(name, resolvedRoot);
1812
+ if (!status.running) throw new Error(`World "${name}" is not running`);
1813
+ env = { ...process.env, ...status.env } as Record<string, string>;
1814
+ }
1815
+ const proxy = await startRedirectProxy({ env, envLoader, tlsDir: tlsDir(resolvedRoot, name) });
1816
+ const state: ProxyState = { pid: process.pid, url: proxy.url, caCertPath: proxy.caCertPath };
1817
+ writeFileSync(proxyStateFile(resolvedRoot, name), JSON.stringify(state));
1818
+ const stop = async () => {
1819
+ try { await proxy.close(); } catch { /* ignore */ }
1820
+ try { rmSync(proxyStateFile(resolvedRoot, name), { force: true }); } catch { /* ignore */ }
1821
+ process.exit(0);
1822
+ };
1823
+ process.on('SIGTERM', stop);
1824
+ process.on('SIGINT', stop);
1825
+ }
1826
+
1827
+ /** Stop a world's ambient-redirect proxy daemon (if any) and drop its session CA. Best-effort —
1828
+ * called from `downWorld` so the trusted CA never outlives the world. */
1829
+ function stopWorldProxy(root: string, name: string): void {
1830
+ const state = readProxyState(root, name);
1831
+ if (state && isAlive(state.pid)) {
1832
+ try { process.kill(state.pid, 'SIGTERM'); } catch { /* ignore */ }
1833
+ }
1834
+ try { rmSync(proxyStateFile(root, name), { force: true }); } catch { /* ignore */ }
1835
+ try { rmSync(proxyEnvFile(root, name), { force: true }); } catch { /* ignore */ }
1836
+ tearDownCa(tlsDir(root, name));
1837
+ }
1838
+
1839
+ /**
1840
+ * Emit a POSIX (bash/zsh) script to `eval` that ACTIVATES the world in the current shell — a
1841
+ * virtualenv for vendor APIs. It exports the world env (twin `*_URL`s + fake keys + the Node
1842
+ * injector + any `cliRedirect` endpoint vars), prepends `(world:<name>)` to the prompt so you can
1843
+ * always see you're pointed at twins (a safety signal, not cosmetic), and defines a `deactivate`
1844
+ * function that restores the prompt and unsets the vars. Usage: `eval "$(volter-world activate dev)"`.
1845
+ *
1846
+ * It ALSO starts (idempotently) the ambient TLS redirect proxy daemon for this world and exports
1847
+ * `HTTPS_PROXY`/`HTTP_PROXY` + the CA-trust vars (`NODE_EXTRA_CA_CERTS`, `CURL_CA_BUNDLE`, …) so an
1848
+ * UNMODIFIED `gh`/`stripe`/`curl`/app in the shell transparently hits the twins with zero per-tool
1849
+ * config (WORLD_ACTIVATE.md tier 1). The session CA is trusted ONLY via these per-shell exports,
1850
+ * never system-wide; `down` stops the daemon and drops the CA. If openssl is unavailable the script
1851
+ * still emits the env-only redirect (Phase-1 behavior) — it just skips the proxy exports.
1852
+ * See docs/WORLD_ACTIVATE.md.
1853
+ */
1854
+ export function activateScript(name: string, root = process.cwd()): string {
1855
+ const status = statusWorld(name, root);
1856
+ if (!status.running) throw new Error(`World "${name}" is not running — run: volter-world up <config> --env-file <path> --name ${name}`);
1857
+ const baseEnv = status.env ?? {};
1858
+ // Bring up the ambient redirect proxy (best-effort) and fold its env in so the activated shell
1859
+ // routes https through the twins too — not just the Node injector / cliRedirect endpoint vars.
1860
+ let proxyNote = '';
1861
+ let env: Record<string, string> = { ...baseEnv };
1862
+ try {
1863
+ const proxy = ensureWorldProxy(name, root);
1864
+ if (proxy && proxy.url) {
1865
+ env = { ...baseEnv, ...proxy.env };
1866
+ proxyNote = ` (ambient proxy ${proxy.url})`;
1867
+ }
1868
+ } catch {
1869
+ // openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
1870
+ }
1871
+ const keys = Object.keys(env).sort();
1872
+ const lines: string[] = [
1873
+ `# volter-world activate ${name} — run: eval "$(volter-world activate ${name})"`,
1874
+ `if [ -n "\${_VOLTER_WORLD:-}" ]; then deactivate 2>/dev/null || true; fi`,
1875
+ `_VOLTER_WORLD=${shellQuote(name)}`,
1876
+ `_VOLTER_WORLD_OLD_PS1="\${PS1:-}"`,
1877
+ `deactivate () {`,
1878
+ ...keys.map((k) => ` unset ${k}`),
1879
+ ` PS1="\${_VOLTER_WORLD_OLD_PS1:-}"; export PS1`,
1880
+ ` unset _VOLTER_WORLD _VOLTER_WORLD_OLD_PS1`,
1881
+ ` unset -f deactivate`,
1882
+ `}`,
1883
+ ...keys.map((k) => `export ${k}=${shellQuote(env[k]!)}`),
1884
+ `PS1="(world:${name}) \${PS1:-}"; export PS1`,
1885
+ `printf '%s\\n' "world '${name}' active — vendor calls now hit the twins${proxyNote}. run: deactivate" >&2`,
1886
+ ];
1887
+ return `${lines.join('\n')}\n`;
1888
+ }