@volter/twin-world 0.1.0 → 0.1.2
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/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
package/src/runtime.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { createServer } from 'node:net';
|
|
4
|
-
import {
|
|
4
|
+
import { createServer, isIP } from 'node:net';
|
|
5
|
+
import { constants as osConstants, hostname as osHostname } from 'node:os';
|
|
6
|
+
import { appendFileSync, chmodSync, closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
|
|
5
7
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
8
|
import { Socket } from 'node:net';
|
|
7
|
-
import { withFileLock } from '@volter/twin';
|
|
9
|
+
import { buildLocalActionPlan, recordPlanReview, withFileLock, stateDirName} from '@volter/twin';
|
|
10
|
+
import type { LocalActionPlan, PlanReviewDecision, PlanReviewRecord } from '@volter/twin';
|
|
8
11
|
import { loadWorldConfig } from './configs.ts';
|
|
12
|
+
import { inertInjectEnvWarnings } from './inject-map.ts';
|
|
9
13
|
import { activeVendorMap, ensureCa, opensslAvailable, proxyEnvFor, startRedirectProxy, tearDownCa } from './redirect-proxy.ts';
|
|
10
|
-
import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
|
|
14
|
+
import type { WorldConfig, WorldExternalReadyWhen, WorldInstance, WorldIsolation, WorldMode, WorldRunOutcome, WorldRunRecord, WorldServiceConfig, WorldServiceInstance } from './schema.ts';
|
|
15
|
+
import { claimWorldResources, handoffWorldResourceClaim, recordWorldResourceEvent, releaseWorldResources, requestedWorldResources } from './resources.ts';
|
|
11
16
|
|
|
12
17
|
const requireFromHere = createRequire(import.meta.url);
|
|
13
18
|
|
|
@@ -27,6 +32,7 @@ export type ShareWorldOptions = {
|
|
|
27
32
|
provider?: 'cloudflare-quick' | 'command';
|
|
28
33
|
command?: string;
|
|
29
34
|
args?: string[];
|
|
35
|
+
ephemeral?: boolean;
|
|
30
36
|
timeoutMs?: number;
|
|
31
37
|
verifyPath?: string | false;
|
|
32
38
|
};
|
|
@@ -63,7 +69,11 @@ export type WorldUrlInfo = {
|
|
|
63
69
|
};
|
|
64
70
|
|
|
65
71
|
function worldBaseDir(root: string): string {
|
|
66
|
-
|
|
72
|
+
// `stateDirName()`, not a literal: VOLTER_STATE_DIR is the documented way to point a process at
|
|
73
|
+
// a different state directory, and control-plane honours it everywhere. This package did not,
|
|
74
|
+
// so a caller that set it got worlds written under `.volter/` while the kernel read from the
|
|
75
|
+
// requested directory — two halves of the same world in two places.
|
|
76
|
+
return join(root, stateDirName(), 'worlds');
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** World names become a path segment (instanceDir/instanceLockFile) and must never let a
|
|
@@ -75,7 +85,16 @@ function assertSafeWorldName(name: string): void {
|
|
|
75
85
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) throw new Error(`Invalid world name: ${name}`);
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
/** Exported for the reflect attachment (reflect.ts), whose routes file lives in
|
|
89
|
+
* the instance dir — one layout owner, no duplicated path knowledge. */
|
|
90
|
+
/** THE WORLD CLOCK file (TWIN-PROGRAMMING-MODEL P3): a single frozen ISO instant every twin
|
|
91
|
+
* stamps from (kernel worldNow()); absent → real wall-clock. Written only by the
|
|
92
|
+
* `volter-world clock` operator door — time is physics, advanced explicitly, never drifting. */
|
|
93
|
+
export function clockFile(root: string, name: string): string {
|
|
94
|
+
return join(instanceDir(root, name), 'clock');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function instanceDir(root: string, name: string): string {
|
|
79
98
|
return join(worldBaseDir(root), name);
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -83,6 +102,10 @@ function instanceFile(root: string, name: string): string {
|
|
|
83
102
|
return join(instanceDir(root, name), 'instance.json');
|
|
84
103
|
}
|
|
85
104
|
|
|
105
|
+
function foregroundRunFile(root: string, name: string): string {
|
|
106
|
+
return join(instanceDir(root, name), 'foreground-run.json');
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
/** Lockfile guarding the synchronous claim-of-the-instance-dir section of `upWorld` (TWIN-36).
|
|
87
110
|
* It must survive the claim's own `rmSync` of the instance dir, so it lives BESIDE the dirs in
|
|
88
111
|
* a dot-prefixed sibling: world names must start with an alphanumeric, so `.locks` can never
|
|
@@ -91,6 +114,102 @@ function instanceLockFile(root: string, name: string): string {
|
|
|
91
114
|
return join(worldBaseDir(root), '.locks', `${name}.lock`);
|
|
92
115
|
}
|
|
93
116
|
|
|
117
|
+
const SHARE_CLAIM_STALE_MS = 60_000;
|
|
118
|
+
const SHARE_CLAIM_HEARTBEAT_MS = 5_000;
|
|
119
|
+
|
|
120
|
+
type SharingClaim = {
|
|
121
|
+
token: string;
|
|
122
|
+
pid: number;
|
|
123
|
+
hostname: string;
|
|
124
|
+
serviceId?: string;
|
|
125
|
+
processIdentity?: string;
|
|
126
|
+
claimedAt: string;
|
|
127
|
+
heartbeatAt: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function processStartIdentity(pid: number): string | undefined {
|
|
131
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' });
|
|
132
|
+
if (result.status !== 0) return undefined;
|
|
133
|
+
const value = result.stdout.trim();
|
|
134
|
+
return value || undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const currentProcessIdentity = processStartIdentity(process.pid);
|
|
138
|
+
|
|
139
|
+
function sharingClaimFile(root: string, name: string): string {
|
|
140
|
+
return join(worldBaseDir(root), '.locks', `${name}.share.json`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readSharingClaim(path: string): SharingClaim | undefined {
|
|
144
|
+
if (!existsSync(path)) return undefined;
|
|
145
|
+
try {
|
|
146
|
+
const claim = JSON.parse(readFileSync(path, 'utf8')) as SharingClaim;
|
|
147
|
+
if (!claim || typeof claim !== 'object' || typeof claim.token !== 'string'
|
|
148
|
+
|| !Number.isInteger(claim.pid) || claim.pid <= 0 || typeof claim.hostname !== 'string'
|
|
149
|
+
|| typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string'
|
|
150
|
+
|| (claim.serviceId !== undefined && typeof claim.serviceId !== 'string')
|
|
151
|
+
|| (claim.processIdentity !== undefined && typeof claim.processIdentity !== 'string')) return undefined;
|
|
152
|
+
return claim;
|
|
153
|
+
} catch { return undefined; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sharingClaimAlive(claim: SharingClaim): boolean {
|
|
157
|
+
const heartbeatAt = Date.parse(claim.heartbeatAt);
|
|
158
|
+
const heartbeatAge = Date.now() - heartbeatAt;
|
|
159
|
+
const fresh = Number.isFinite(heartbeatAt)
|
|
160
|
+
&& heartbeatAge >= -SHARE_CLAIM_STALE_MS && heartbeatAge <= SHARE_CLAIM_STALE_MS;
|
|
161
|
+
if (claim.hostname !== osHostname()) return fresh;
|
|
162
|
+
if (livePids([claim.pid]).length === 0) return false;
|
|
163
|
+
const identity = processStartIdentity(claim.pid);
|
|
164
|
+
if (claim.processIdentity && identity) return claim.processIdentity === identity;
|
|
165
|
+
// A legacy or ps-uninspectable claim cannot rely on pid alone: pid reuse would wedge sharing.
|
|
166
|
+
// Its owner heartbeat keeps it live, while a crashed claim becomes reclaimable on a bound.
|
|
167
|
+
return fresh;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function reserveSharingClaim(root: string, name: string, serviceId: string): Promise<SharingClaim> {
|
|
171
|
+
const path = sharingClaimFile(root, name);
|
|
172
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
173
|
+
while (true) {
|
|
174
|
+
const claim = withFileLock(`${path}.lock`, () => {
|
|
175
|
+
const existing = readSharingClaim(path);
|
|
176
|
+
if (existing && sharingClaimAlive(existing)) {
|
|
177
|
+
if (existing.serviceId === serviceId) {
|
|
178
|
+
throw new Error(`World "${name}" service "${serviceId}" is already being shared by pid ${existing.pid}`);
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const now = new Date().toISOString();
|
|
183
|
+
const reserved: SharingClaim = {
|
|
184
|
+
token: randomUUID(), pid: process.pid, hostname: osHostname(), serviceId,
|
|
185
|
+
claimedAt: now, heartbeatAt: now,
|
|
186
|
+
...(currentProcessIdentity ? { processIdentity: currentProcessIdentity } : {}),
|
|
187
|
+
};
|
|
188
|
+
writeTextAtomic(path, `${JSON.stringify(reserved)}\n`);
|
|
189
|
+
return reserved;
|
|
190
|
+
});
|
|
191
|
+
if (claim) return claim;
|
|
192
|
+
// A different service is mutating the same instance/pids projection. Wait for its bounded,
|
|
193
|
+
// heartbeated claim to settle, then reload the committed instance before starting this child.
|
|
194
|
+
await sleep(50);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function refreshSharingClaim(root: string, name: string, token: string): void {
|
|
199
|
+
const path = sharingClaimFile(root, name);
|
|
200
|
+
withFileLock(`${path}.lock`, () => {
|
|
201
|
+
const claim = readSharingClaim(path);
|
|
202
|
+
if (claim?.token === token) writeTextAtomic(path, `${JSON.stringify({ ...claim, heartbeatAt: new Date().toISOString() })}\n`);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function releaseSharingClaim(root: string, name: string, token: string): void {
|
|
207
|
+
const path = sharingClaimFile(root, name);
|
|
208
|
+
withFileLock(`${path}.lock`, () => {
|
|
209
|
+
if (readSharingClaim(path)?.token === token) rmSync(path, { force: true });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
94
213
|
/** Claim marker a winning `upWorld` writes into the freshly-wiped instance dir — under the
|
|
95
214
|
* instance lock, before releasing it — and removes once its boot attempt settles. It is what
|
|
96
215
|
* keeps later claimants out for the whole (async, possibly long) boot: mid-boot there are no
|
|
@@ -294,17 +413,36 @@ function tunnelPids(instance: WorldInstance): number[] {
|
|
|
294
413
|
.filter((pid): pid is number => Boolean(pid));
|
|
295
414
|
}
|
|
296
415
|
|
|
416
|
+
let atomicWriteSequence = 0;
|
|
417
|
+
function writeTextAtomic(path: string, value: string): void {
|
|
418
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${atomicWriteSequence++}.tmp`;
|
|
419
|
+
try {
|
|
420
|
+
writeFileSync(temporary, value, { mode: 0o600 });
|
|
421
|
+
renameSync(temporary, path);
|
|
422
|
+
chmodSync(path, 0o600);
|
|
423
|
+
} finally {
|
|
424
|
+
rmSync(temporary, { force: true });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function openPrivateLog(path: string, flags: 'a' | 'w'): number {
|
|
429
|
+
const descriptor = openSync(path, flags, 0o600);
|
|
430
|
+
chmodSync(path, 0o600);
|
|
431
|
+
return descriptor;
|
|
432
|
+
}
|
|
433
|
+
|
|
297
434
|
function writePidsFromInstance(instance: WorldInstance): void {
|
|
298
435
|
// Deduped: co-located services share the single host child's pid.
|
|
299
436
|
const pids = [...new Set([
|
|
300
437
|
...Object.values(instance.services).map((service) => service.pid),
|
|
301
438
|
...tunnelPids(instance),
|
|
439
|
+
...(instance.resources?.holderPid ? [instance.resources.holderPid] : []),
|
|
302
440
|
].filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
303
|
-
|
|
441
|
+
writeTextAtomic(instance.pidsFile, `${pids.join('\n')}\n`);
|
|
304
442
|
}
|
|
305
443
|
|
|
306
444
|
function saveWorldInstance(instance: WorldInstance): void {
|
|
307
|
-
|
|
445
|
+
writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
308
446
|
}
|
|
309
447
|
|
|
310
448
|
/** The co-located host CHILD process cannot safely rewrite instance.json out from under the
|
|
@@ -328,11 +466,38 @@ function mergeWorkerGaveUp(instance: WorldInstance, instancePath: string): void
|
|
|
328
466
|
}
|
|
329
467
|
}
|
|
330
468
|
|
|
469
|
+
/** `world run` persists this sidecar before the foreground consumer starts. If the runner itself
|
|
470
|
+
* is hard-killed, it cannot record its own signal or execute `finally`; the next World read turns
|
|
471
|
+
* that orphaned running marker into an explicit, durable abrupt-termination diagnosis. */
|
|
472
|
+
function mergeForegroundRun(instance: WorldInstance): void {
|
|
473
|
+
const path = foregroundRunFile(instance.root, instance.name);
|
|
474
|
+
if (!existsSync(path)) return;
|
|
475
|
+
try {
|
|
476
|
+
let record = JSON.parse(readFileSync(path, 'utf8')) as WorldRunRecord;
|
|
477
|
+
if (record.state === 'running' && !isAlive(record.runnerPid)) {
|
|
478
|
+
record = {
|
|
479
|
+
state: 'abrupt',
|
|
480
|
+
runnerPid: record.runnerPid,
|
|
481
|
+
...(record.consumerPid ? { consumerPid: record.consumerPid } : {}),
|
|
482
|
+
startedAt: record.startedAt,
|
|
483
|
+
observedAt: new Date().toISOString(),
|
|
484
|
+
log: record.log,
|
|
485
|
+
error: 'World runner disappeared without an exit record; it was hard-killed or its host/session terminated abruptly',
|
|
486
|
+
};
|
|
487
|
+
writeTextAtomic(path, `${JSON.stringify(record, null, 2)}\n`);
|
|
488
|
+
}
|
|
489
|
+
instance.lastRun = record;
|
|
490
|
+
} catch {
|
|
491
|
+
// Preserve instance readability if a process vanished between the sidecar's atomic writes.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
331
495
|
function readWorldInstance(name: string, root: string): WorldInstance {
|
|
332
496
|
const path = instanceFile(root, name);
|
|
333
497
|
if (!existsSync(path)) throw new Error(`World instance not found: ${name}`);
|
|
334
498
|
const instance = JSON.parse(readFileSync(path, 'utf8')) as WorldInstance;
|
|
335
499
|
mergeWorkerGaveUp(instance, path);
|
|
500
|
+
mergeForegroundRun(instance);
|
|
336
501
|
return instance;
|
|
337
502
|
}
|
|
338
503
|
|
|
@@ -399,8 +564,9 @@ async function runExternalUp(
|
|
|
399
564
|
if (!commandExists(bin)) {
|
|
400
565
|
throw new Error(`External service "${serviceId}": \`${bin}\` not found on PATH (the up command's tool is not installed)`);
|
|
401
566
|
}
|
|
402
|
-
writeFileSync(logPath, `$ ${command.join(' ')}\n
|
|
403
|
-
|
|
567
|
+
writeFileSync(logPath, `$ ${command.join(' ')}\n`, { mode: 0o600 });
|
|
568
|
+
chmodSync(logPath, 0o600);
|
|
569
|
+
const out = openPrivateLog(logPath, 'a');
|
|
404
570
|
const chunks: string[] = [];
|
|
405
571
|
try {
|
|
406
572
|
const code = await new Promise<number | null>((resolveCode, reject) => {
|
|
@@ -483,20 +649,28 @@ async function startExternalService(
|
|
|
483
649
|
const external = service.external;
|
|
484
650
|
if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
|
|
485
651
|
const log = join(paths.logs, `${service.id}.log`);
|
|
652
|
+
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
486
653
|
// External commands see the accumulated world env (process.env + earlier services' discovered/
|
|
487
654
|
// injected vars), so a later external can consume an earlier service's connection info.
|
|
488
|
-
const env: NodeJS.ProcessEnv = {
|
|
655
|
+
const env: NodeJS.ProcessEnv = {
|
|
656
|
+
...process.env,
|
|
657
|
+
...worldEnv,
|
|
658
|
+
...(service.env ?? {}),
|
|
659
|
+
VOLTER_WORLD_SERVICE_ID: service.id,
|
|
660
|
+
VOLTER_WORLD_SERVICE_LOG: log,
|
|
661
|
+
};
|
|
662
|
+
if (service.controlPlane) for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
|
|
489
663
|
|
|
490
664
|
// 1. up — start the self-managed stack, STREAMING its output to the service log (no 1MB cap).
|
|
491
|
-
const upOutput = await runExternalUp(external.up,
|
|
665
|
+
const upOutput = await runExternalUp(external.up, cwd, service.id, env, log);
|
|
492
666
|
|
|
493
667
|
// 2. readiness — Docker-backed externals take seconds to boot.
|
|
494
|
-
await awaitReadiness(external.readyWhen, log,
|
|
668
|
+
await awaitReadiness(external.readyWhen, log, cwd, service.id, env);
|
|
495
669
|
|
|
496
670
|
// 3. status — discover connection info (optional; may reuse up output instead).
|
|
497
671
|
let statusOutput = '';
|
|
498
672
|
if (external.status) {
|
|
499
|
-
const statusResult = runExternalCommand(external.status,
|
|
673
|
+
const statusResult = runExternalCommand(external.status, cwd, service.id, 'status', env);
|
|
500
674
|
statusOutput = statusResult.stdout || statusResult.stderr;
|
|
501
675
|
appendFileSync(log, `\n$ ${external.status.join(' ')}\n${statusOutput}`);
|
|
502
676
|
}
|
|
@@ -540,10 +714,23 @@ async function startExternalService(
|
|
|
540
714
|
log,
|
|
541
715
|
env: injected,
|
|
542
716
|
// Keep the discovered env on the instance so `down` can reference connection info it needs.
|
|
543
|
-
external: { down: [...external.down], cwd
|
|
717
|
+
external: { down: [...external.down], cwd, discoveredEnv: injected },
|
|
544
718
|
};
|
|
545
719
|
}
|
|
546
720
|
|
|
721
|
+
// PIDS OF CHILDREN STILL COMING UP. A service is recorded in the instance only once it is ready;
|
|
722
|
+
// until then only this set knows its pid. A boot interrupted by a signal (an operator's kill, an
|
|
723
|
+
// OOM reaper) tears these down too, so a half-started service never outlives the boot that
|
|
724
|
+
// spawned it (the `volter-livekit-redis-<port>` containers and vendor twins that outlived every
|
|
725
|
+
// World that made them were exactly the children of boots killed mid-way).
|
|
726
|
+
const bootingPids = new Set<number>();
|
|
727
|
+
function trackBootingChild(child: { pid?: number; on: (event: 'exit', listener: () => void) => unknown }): void {
|
|
728
|
+
const pid = child.pid;
|
|
729
|
+
if (!pid) return;
|
|
730
|
+
bootingPids.add(pid);
|
|
731
|
+
child.on('exit', () => bootingPids.delete(pid));
|
|
732
|
+
}
|
|
733
|
+
|
|
547
734
|
async function startService(
|
|
548
735
|
config: WorldConfig,
|
|
549
736
|
service: WorldServiceConfig,
|
|
@@ -561,8 +748,10 @@ async function startService(
|
|
|
561
748
|
if (service.rootArg !== false) args.push(service.rootArg ?? '--root', serviceDataDir);
|
|
562
749
|
|
|
563
750
|
const log = join(paths.logs, `${service.id}.log`);
|
|
564
|
-
const out =
|
|
751
|
+
const out = openPrivateLog(log, 'a');
|
|
565
752
|
const env = serviceProcessEnv(config, service, worldEnv, port);
|
|
753
|
+
env.VOLTER_WORLD_SERVICE_ID = service.id;
|
|
754
|
+
env.VOLTER_WORLD_SERVICE_LOG = log;
|
|
566
755
|
const cwd = service.cwd ? resolve(paths.root, service.cwd) : paths.root;
|
|
567
756
|
|
|
568
757
|
// Augment NODE_OPTIONS with declared extra preloads, so a service can add an app-local preload
|
|
@@ -580,6 +769,7 @@ async function startService(
|
|
|
580
769
|
stdio: ['ignore', out, out],
|
|
581
770
|
});
|
|
582
771
|
child.unref();
|
|
772
|
+
trackBootingChild(child);
|
|
583
773
|
|
|
584
774
|
// Track an early exit. allocatePort() is TOCTOU (it closes its probe listener before the
|
|
585
775
|
// child binds), so if another/stale process already holds `port`, the child gets EADDRINUSE
|
|
@@ -592,7 +782,11 @@ async function startService(
|
|
|
592
782
|
exitState.value = { code, signal };
|
|
593
783
|
});
|
|
594
784
|
|
|
595
|
-
|
|
785
|
+
// A service that declares a readiness budget gets it for its bind too: under load (a box
|
|
786
|
+
// swapping, a docker build beside the boot) a heavy service takes longer than the 15s default
|
|
787
|
+
// to answer on its port, and failing it there wastes the whole boot.
|
|
788
|
+
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
789
|
+
await waitForTcp(port, bindTimeoutMs).catch((error) => {
|
|
596
790
|
try {
|
|
597
791
|
process.kill(-child.pid!, 'SIGTERM');
|
|
598
792
|
} catch {
|
|
@@ -665,7 +859,7 @@ async function startColocatedServices(
|
|
|
665
859
|
}
|
|
666
860
|
|
|
667
861
|
const log = join(paths.logs, 'host.log');
|
|
668
|
-
const out =
|
|
862
|
+
const out = openPrivateLog(log, 'a');
|
|
669
863
|
// The host is world infrastructure, not an app process — same egress hygiene as controlPlane
|
|
670
864
|
// services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
|
|
671
865
|
const env: Record<string, string> = { ...process.env as Record<string, string>, ...(config.env ?? {}), ...worldEnv };
|
|
@@ -677,6 +871,7 @@ async function startColocatedServices(
|
|
|
677
871
|
stdio: ['ignore', out, out],
|
|
678
872
|
});
|
|
679
873
|
child.unref();
|
|
874
|
+
trackBootingChild(child);
|
|
680
875
|
|
|
681
876
|
const exitState: { value: { code: number | null; signal: NodeJS.Signals | null } | null } = { value: null };
|
|
682
877
|
child.on('exit', (code, signal) => {
|
|
@@ -685,7 +880,8 @@ async function startColocatedServices(
|
|
|
685
880
|
|
|
686
881
|
const command = [process.execPath, ...args];
|
|
687
882
|
for (const { service, port } of allocated) {
|
|
688
|
-
|
|
883
|
+
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
884
|
+
await waitForTcp(port, bindTimeoutMs).catch((error) => {
|
|
689
885
|
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
690
886
|
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
691
887
|
});
|
|
@@ -724,18 +920,25 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
724
920
|
const mode = options.mode ?? 'local';
|
|
725
921
|
assertSafeWorldName(name);
|
|
726
922
|
assertMode(mode);
|
|
727
|
-
|
|
923
|
+
let isolation = options.isolation ?? loaded.config.isolation ?? 'process';
|
|
728
924
|
// TWIN-67: schema.ts's WorldIsolation contract says process isolation ("one OS process per
|
|
729
925
|
// service") is "the only choice for share/sealed/hosted worlds" — colocating twins in one host
|
|
730
926
|
// process (or worker threads, which still share that process) under `share`/`sealed` puts a
|
|
731
927
|
// semi-untrusted session on colocated twins sharing one host process, exactly what the
|
|
732
928
|
// contract forbids. Enforce it here instead of leaving it a comment nothing checks. Checked
|
|
733
929
|
// before the file-lock claim / any dir creation below, so a rejected combination leaves nothing
|
|
734
|
-
// to clean up.
|
|
930
|
+
// to clean up. A CONFIG-sourced isolation is a local-mode preference (init emits
|
|
931
|
+
// `isolation: 'colocated'` so a local world costs one twin process — R2a), so share/sealed
|
|
932
|
+
// quietly boot it per-process, their contractual shape; only an EXPLICIT --isolation flag
|
|
933
|
+
// contradicting the mode is an error, because the caller asked for two things at once.
|
|
735
934
|
if (mode !== 'local' && isolation !== 'process') {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
935
|
+
if (options.isolation === undefined) {
|
|
936
|
+
isolation = 'process';
|
|
937
|
+
} else {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`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}.`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
739
942
|
}
|
|
740
943
|
|
|
741
944
|
const instance = instanceDir(root, name);
|
|
@@ -744,6 +947,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
744
947
|
const pidsFile = join(instance, 'pids');
|
|
745
948
|
const envFile = resolve(root, options.envFile ?? join(instance, 'world.env'));
|
|
746
949
|
const instanceFile = join(instance, 'instance.json');
|
|
950
|
+
const requestedResources = requestedWorldResources(loaded.config.resources);
|
|
951
|
+
let resourcesClaimed = false;
|
|
952
|
+
let resourceHolderPid = 0;
|
|
747
953
|
|
|
748
954
|
// Claim the instance dir under the kernel's cross-process file lock (TWIN-36). Without it,
|
|
749
955
|
// two concurrent `upWorld` calls on one name can both observe "no live pids", both rmSync the
|
|
@@ -757,22 +963,36 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
757
963
|
// the lockfile adds the cross-process exclusion, and an in-process lock holder always releases
|
|
758
964
|
// before its first await, so withFileLock's synchronous contender wait can never deadlock the
|
|
759
965
|
// event loop against a holder in the same process.
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
966
|
+
try {
|
|
967
|
+
withFileLock(instanceLockFile(root, name), () => {
|
|
968
|
+
const live = livePids(readPids(pidsFile));
|
|
969
|
+
if (live.length > 0) {
|
|
970
|
+
throw new Error(`World "${name}" is already running (${live.join(', ')}). Run: volter-world down ${name}`);
|
|
971
|
+
}
|
|
972
|
+
const booting = liveBootingClaim(bootingFile(root, name));
|
|
973
|
+
if (booting) {
|
|
974
|
+
throw new Error(
|
|
975
|
+
`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).`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
// Capacity is claimed while the instance-name lock is still held, after proving this is not
|
|
979
|
+
// an already-running World and before creating the instance or starting its first service.
|
|
980
|
+
// The resource module has its own cross-World lock, so two different names cannot both pass
|
|
981
|
+
// the same last slice of capacity.
|
|
982
|
+
claimWorldResources(root, name, requestedResources);
|
|
983
|
+
resourcesClaimed = true;
|
|
984
|
+
rmSync(instance, { recursive: true, force: true });
|
|
985
|
+
mkdirSync(logs, { mode: 0o700, recursive: true });
|
|
986
|
+
mkdirSync(data, { mode: 0o700, recursive: true });
|
|
987
|
+
chmodSync(instance, 0o700);
|
|
988
|
+
chmodSync(logs, 0o700);
|
|
989
|
+
chmodSync(data, 0o700);
|
|
990
|
+
writeFileSync(bootingFile(root, name), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() } satisfies BootingClaim)}\n`, { mode: 0o600 });
|
|
991
|
+
});
|
|
992
|
+
} catch (error) {
|
|
993
|
+
if (resourcesClaimed) releaseWorldResources(root, name);
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
776
996
|
|
|
777
997
|
// From here on this call owns the claim; the finally below releases it however the boot
|
|
778
998
|
// settles. On success the recorded live pids (written before returning) take over as the
|
|
@@ -787,6 +1007,12 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
787
1007
|
VOLTER_WORLD_MODE: mode,
|
|
788
1008
|
VOLTER_WORLD_CONFIG: loaded.path,
|
|
789
1009
|
VOLTER_WORLD_INSTANCE: instanceFile,
|
|
1010
|
+
VOLTER_WORLD_DATA: data,
|
|
1011
|
+
// THE WORLD CLOCK: every service stamps from this file via the kernel's worldNow();
|
|
1012
|
+
// absent until `volter-world clock <name> set/advance` writes it (→ real time).
|
|
1013
|
+
TWIN_WORLD_CLOCK_FILE: clockFile(root, name),
|
|
1014
|
+
VOLTER_WORLD_RESOURCE_MEMORY_MIB: String(requestedResources.memoryMiB),
|
|
1015
|
+
VOLTER_WORLD_RESOURCE_WRITABLE_STORAGE_MIB: String(requestedResources.writableStorageMiB),
|
|
790
1016
|
// VOLTER_WORLD_SEALED is intent-only; VOLTER_TWIN_STRICT_EGRESS is what the ENFORCEMENT
|
|
791
1017
|
// points actually gate on (redirect-proxy.ts's CONNECT/plain-proxy handlers and
|
|
792
1018
|
// control-plane/inject.cjs's patched http/https/fetch) — both block any untwinned
|
|
@@ -799,15 +1025,28 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
799
1025
|
env.no_proxy = process.env.no_proxy ? `127.0.0.1,localhost,${process.env.no_proxy}` : '127.0.0.1,localhost';
|
|
800
1026
|
writeProxyEnv(root, name, env);
|
|
801
1027
|
|
|
1028
|
+
// A signal during the boot is the boot's failure, handled by the catch below like any other:
|
|
1029
|
+
// the services that came up are stopped, the child still coming up with them.
|
|
1030
|
+
let interruptBoot: ((error: Error) => void) | undefined;
|
|
1031
|
+
const bootInterrupted = new Promise<never>((_, reject) => { interruptBoot = reject; });
|
|
1032
|
+
bootInterrupted.catch(() => {});
|
|
1033
|
+
const onBootSignal = (signal: NodeJS.Signals) => interruptBoot?.(new Error(`World "${name}": boot interrupted by ${signal}`));
|
|
1034
|
+
process.once('SIGTERM', onBootSignal);
|
|
1035
|
+
process.once('SIGINT', onBootSignal);
|
|
1036
|
+
const detachBootSignals = () => { process.off('SIGTERM', onBootSignal); process.off('SIGINT', onBootSignal); };
|
|
1037
|
+
// The pids file is written as each service comes up, not once at the end: a boot that dies
|
|
1038
|
+
// or hangs leaves `down` (and the next `up`'s "already running" guard) an honest record.
|
|
1039
|
+
const recordPidsSoFar = () => writeTextAtomic(pidsFile, `${[...new Set(Object.values(services).map((service) => service.pid).filter((pid) => Number.isInteger(pid) && pid > 0))].join('\n')}\n`);
|
|
802
1040
|
try {
|
|
803
1041
|
// Non-'process' isolation: boot every colocate-declaring service inside ONE host child
|
|
804
1042
|
// first; anything without `colocate` (and externals) still goes through the spawn path.
|
|
805
1043
|
if (isolation !== 'process') {
|
|
806
1044
|
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 })) {
|
|
1045
|
+
for (const started of await Promise.race([startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }), bootInterrupted])) {
|
|
808
1046
|
services[started.id] = started;
|
|
809
1047
|
Object.assign(env, started.env);
|
|
810
1048
|
}
|
|
1049
|
+
recordPidsSoFar();
|
|
811
1050
|
writeProxyEnv(root, name, env);
|
|
812
1051
|
}
|
|
813
1052
|
let proxyForServices: ReturnType<typeof ensureWorldProxyFromEnv> | null = null;
|
|
@@ -839,19 +1078,22 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
839
1078
|
for (const service of loaded.config.services) {
|
|
840
1079
|
if (services[service.id]) continue; // already up co-located
|
|
841
1080
|
attemptProxy();
|
|
842
|
-
const started = await startService(loaded.config, service, env, { root, instance, logs, data });
|
|
1081
|
+
const started = await Promise.race([startService(loaded.config, service, env, { root, instance, logs, data }), bootInterrupted]);
|
|
843
1082
|
services[started.id] = started;
|
|
1083
|
+
recordPidsSoFar();
|
|
844
1084
|
Object.assign(env, started.env);
|
|
845
1085
|
writeProxyEnv(root, name, env);
|
|
846
1086
|
}
|
|
1087
|
+
detachBootSignals();
|
|
847
1088
|
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
848
1089
|
// only lands in `env` once IT has started, so a check that only runs "before starting the
|
|
849
1090
|
// next service" never fires when there is no next service (TWIN-64) — the single-twin sealed
|
|
850
1091
|
// config test below exercises exactly this.
|
|
851
1092
|
attemptProxy();
|
|
852
1093
|
} catch (error) {
|
|
1094
|
+
detachBootSignals();
|
|
853
1095
|
stopWorldProxy(root, name);
|
|
854
|
-
const pids = [...new Set(Object.values(services).map((service) => service.pid).filter(Boolean))];
|
|
1096
|
+
const pids = [...new Set([...Object.values(services).map((service) => service.pid).filter(Boolean), ...bootingPids])];
|
|
855
1097
|
signalPids(pids, 'SIGTERM');
|
|
856
1098
|
// Stop any self-managed externals that DID come up, so a failed `up` never leaves a
|
|
857
1099
|
// half-running external stack behind. Best-effort: the original error is what we throw —
|
|
@@ -885,6 +1127,14 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
885
1127
|
throw error;
|
|
886
1128
|
}
|
|
887
1129
|
|
|
1130
|
+
// Inert-injectEnv check (LibreChat blind-adoption finding): an injectEnv like AWS_TWIN_URL
|
|
1131
|
+
// lands in the world env, LOOKS wired, and does nothing — the injector reads no such vendor.
|
|
1132
|
+
// Warn loudly at `up`, when the operator is watching, instead of letting the disagreement
|
|
1133
|
+
// surface later as silently-real vendor traffic.
|
|
1134
|
+
for (const warning of inertInjectEnvWarnings(loaded.config.services)) {
|
|
1135
|
+
process.stderr.write(`${warning}\n`);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
888
1138
|
const worldInstance: WorldInstance = {
|
|
889
1139
|
name,
|
|
890
1140
|
config: loaded.config.id,
|
|
@@ -899,12 +1149,25 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
899
1149
|
pidsFile,
|
|
900
1150
|
...(loaded.config.actors ? { actors: loaded.config.actors } : {}),
|
|
901
1151
|
...(loaded.config.fixtures ? { fixtures: loaded.config.fixtures } : {}),
|
|
1152
|
+
resources: { ...requestedResources, log: join(worldBaseDir(root), '.resources', `${name}.log`), holderPid: 0 },
|
|
902
1153
|
};
|
|
903
1154
|
|
|
904
1155
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
905
1156
|
writeFileSync(envFile, envFileContents(env));
|
|
1157
|
+
const resourceOut = openPrivateLog(worldInstance.resources!.log, 'a');
|
|
1158
|
+
const resourceHolder = spawn(process.execPath, [join(import.meta.dir, 'resource-holder.ts')], {
|
|
1159
|
+
cwd: root,
|
|
1160
|
+
env: process.env,
|
|
1161
|
+
detached: true,
|
|
1162
|
+
stdio: ['ignore', resourceOut, resourceOut],
|
|
1163
|
+
});
|
|
1164
|
+
resourceHolder.unref();
|
|
1165
|
+
worldInstance.resources!.holderPid = resourceHolder.pid ?? 0;
|
|
1166
|
+
resourceHolderPid = worldInstance.resources!.holderPid;
|
|
906
1167
|
writePidsFromInstance(worldInstance);
|
|
907
1168
|
saveWorldInstance(worldInstance);
|
|
1169
|
+
const servicePids = [...new Set(Object.values(services).map((service) => service.pid).filter((pid) => pid > 0))];
|
|
1170
|
+
handoffWorldResourceClaim(root, name, [...servicePids, worldInstance.resources!.holderPid]);
|
|
908
1171
|
if (mode === 'share') {
|
|
909
1172
|
try {
|
|
910
1173
|
return await shareWorldServices(name, { root, ...(options.share ?? {}) });
|
|
@@ -914,6 +1177,13 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}): P
|
|
|
914
1177
|
}
|
|
915
1178
|
}
|
|
916
1179
|
return worldInstance;
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (resourcesClaimed) {
|
|
1182
|
+
if (resourceHolderPid > 0) signalPids([resourceHolderPid], 'SIGTERM');
|
|
1183
|
+
recordWorldResourceEvent(root, name, `boot failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1184
|
+
releaseWorldResources(root, name);
|
|
1185
|
+
}
|
|
1186
|
+
throw error;
|
|
917
1187
|
} finally {
|
|
918
1188
|
rmSync(bootingFile(root, name), { force: true });
|
|
919
1189
|
}
|
|
@@ -1070,7 +1340,14 @@ export async function downWorld(
|
|
|
1070
1340
|
// Tear down the ambient-redirect proxy + its session CA FIRST, so the trusted CA never outlives
|
|
1071
1341
|
// the world (it was only ever trusted via per-shell env, never system-wide).
|
|
1072
1342
|
stopWorldProxy(resolvedRoot, name);
|
|
1073
|
-
|
|
1343
|
+
let instance: WorldInstance | undefined;
|
|
1344
|
+
try {
|
|
1345
|
+
instance = readWorldInstance(name, resolvedRoot);
|
|
1346
|
+
} catch {
|
|
1347
|
+
instance = undefined;
|
|
1348
|
+
}
|
|
1349
|
+
const foregroundPid = instance?.lastRun?.state !== 'completed' ? instance?.lastRun?.consumerPid : undefined;
|
|
1350
|
+
const pids = [...new Set([...readPids(pidsFile), ...(foregroundPid ? [foregroundPid] : [])])];
|
|
1074
1351
|
const stopped = signalPids(pids, 'SIGTERM');
|
|
1075
1352
|
|
|
1076
1353
|
// Stop self-managed externals via their declared `down` command. Tolerate a missing
|
|
@@ -1078,7 +1355,7 @@ export async function downWorld(
|
|
|
1078
1355
|
let externalStopped: string[] = [];
|
|
1079
1356
|
let externalErrors: string[] = [];
|
|
1080
1357
|
try {
|
|
1081
|
-
|
|
1358
|
+
if (!instance) throw new Error('World instance unavailable');
|
|
1082
1359
|
const result = teardownExternalServices(instance);
|
|
1083
1360
|
externalStopped = result.stopped;
|
|
1084
1361
|
externalErrors = result.errors;
|
|
@@ -1092,6 +1369,7 @@ export async function downWorld(
|
|
|
1092
1369
|
const escalated = await killSurvivorsAfterGrace(pids, options.graceMs ?? DOWN_GRACE_MS_DEFAULT);
|
|
1093
1370
|
|
|
1094
1371
|
rmSync(pidsFile, { force: true });
|
|
1372
|
+
if (externalErrors.length === 0 && undeadPids(pids).length === 0) releaseWorldResources(resolvedRoot, name);
|
|
1095
1373
|
|
|
1096
1374
|
// Purge LAST, only after every process (including SIGKILL escalations) is confirmed dead —
|
|
1097
1375
|
// removing the instance dir out from under a still-running service would orphan it with no
|
|
@@ -1116,13 +1394,56 @@ export function statusWorld(name: string, root = process.cwd()): WorldInstance &
|
|
|
1116
1394
|
return { ...worldInstance, running: live.length > 0, livePids: live };
|
|
1117
1395
|
}
|
|
1118
1396
|
|
|
1397
|
+
function worldServiceControlRoot(name: string, service: string, root = process.cwd()): string {
|
|
1398
|
+
const instance = readWorldInstance(name, resolve(root));
|
|
1399
|
+
if (!instance.services[service]) throw new Error(`World ${name} has no service "${service}"`);
|
|
1400
|
+
return join(instance.dirs.data, service);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function worldServiceStateService(controlRoot: string, requestedService: string): string {
|
|
1404
|
+
const stateRoot = join(controlRoot, stateDirName(), 'world');
|
|
1405
|
+
if (!existsSync(stateRoot)) return requestedService;
|
|
1406
|
+
const actionServices = readdirSync(stateRoot, { withFileTypes: true })
|
|
1407
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(stateRoot, entry.name, 'actions.jsonl')))
|
|
1408
|
+
.map((entry) => entry.name);
|
|
1409
|
+
if (actionServices.includes(requestedService)) return requestedService;
|
|
1410
|
+
if (actionServices.length === 1) return actionServices[0]!;
|
|
1411
|
+
if (actionServices.length > 1) {
|
|
1412
|
+
throw new Error(`World service "${requestedService}" records actions under multiple state services (${actionServices.join(', ')}); use volter-twin plan with the explicit service data root`);
|
|
1413
|
+
}
|
|
1414
|
+
return requestedService;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Instance-aware plan surface: callers name the world/service, never internal data directories. */
|
|
1418
|
+
export function planWorldActions(name: string, service: string, root = process.cwd()): LocalActionPlan {
|
|
1419
|
+
const controlRoot = worldServiceControlRoot(name, service, root);
|
|
1420
|
+
return buildLocalActionPlan(worldServiceStateService(controlRoot, service), controlRoot);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Durably review the exact pending transaction set owned by one world service. */
|
|
1424
|
+
export function reviewWorldActions(name: string, service: string, opts: {
|
|
1425
|
+
expectedTransactionSetId: string;
|
|
1426
|
+
decision: PlanReviewDecision;
|
|
1427
|
+
actor: { kind: 'agent' | 'human'; id: string };
|
|
1428
|
+
reason?: string;
|
|
1429
|
+
occurredAt?: string;
|
|
1430
|
+
root?: string;
|
|
1431
|
+
}): PlanReviewRecord {
|
|
1432
|
+
const controlRoot = worldServiceControlRoot(name, service, opts.root);
|
|
1433
|
+
const stateService = worldServiceStateService(controlRoot, service);
|
|
1434
|
+
return recordPlanReview({ service: stateService, expectedTransactionSetId: opts.expectedTransactionSetId, decision: opts.decision, actor: opts.actor, ...(opts.reason ? { reason: opts.reason } : {}), ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}), root: controlRoot });
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1119
1437
|
function shareCommand(options: ShareWorldOptions, serviceUrl: string): { provider: 'cloudflare-quick' | 'command'; command: string; args: string[] } {
|
|
1438
|
+
if (options.provider === 'cloudflare-quick' && options.command) {
|
|
1439
|
+
throw new Error('share provider "cloudflare-quick" uses the built-in cloudflared command and cannot be combined with a custom command');
|
|
1440
|
+
}
|
|
1120
1441
|
if (!options.command && options.provider === 'command') {
|
|
1121
1442
|
throw new Error(`share provider "command" requires a tunnel command (share.command / --command)`);
|
|
1122
1443
|
}
|
|
1123
1444
|
if (options.command) {
|
|
1124
1445
|
return {
|
|
1125
|
-
provider:
|
|
1446
|
+
provider: 'command',
|
|
1126
1447
|
command: options.command,
|
|
1127
1448
|
args: (options.args ?? []).map((arg) => arg.replaceAll('{url}', serviceUrl)),
|
|
1128
1449
|
};
|
|
@@ -1134,8 +1455,51 @@ function shareCommand(options: ShareWorldOptions, serviceUrl: string): { provide
|
|
|
1134
1455
|
};
|
|
1135
1456
|
}
|
|
1136
1457
|
|
|
1458
|
+
export function parseCloudflareQuickPublicUrl(candidate: string): string | undefined {
|
|
1459
|
+
// Validate the provider's RAW stdout token before WHATWG normalization. Otherwise `%2e` in the
|
|
1460
|
+
// authority, an explicit default `:443`, or `/.` / `/%2e%2e` paths normalize into an apparently
|
|
1461
|
+
// valid origin and bypass the strict cloudflared-output boundary.
|
|
1462
|
+
if (!/^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.trycloudflare\.com\/?$/.test(candidate)) return undefined;
|
|
1463
|
+
let parsed: URL;
|
|
1464
|
+
try { parsed = new URL(candidate); } catch { return undefined; }
|
|
1465
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port
|
|
1466
|
+
|| !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.trycloudflare\.com$/.test(parsed.hostname)
|
|
1467
|
+
|| parsed.pathname !== '/' || parsed.search || parsed.hash) return undefined;
|
|
1468
|
+
return parsed.origin;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function canonicalDottedIpv4(host: string): boolean {
|
|
1472
|
+
const parts = host.split('.');
|
|
1473
|
+
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9][0-9]{0,2})$/.test(part) && Number(part) <= 255);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function parseCustomShareOrigin(handshake: string): string {
|
|
1477
|
+
const raw = /^https?:\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?\/?$/i.exec(handshake);
|
|
1478
|
+
if (!raw) {
|
|
1479
|
+
throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a strict http(s) origin without path, query, fragment, or backslashes: ${handshake}`);
|
|
1480
|
+
}
|
|
1481
|
+
const rawHost = raw[1]!;
|
|
1482
|
+
const rawPort = raw[2];
|
|
1483
|
+
let parsed: URL;
|
|
1484
|
+
try { parsed = new URL(handshake); }
|
|
1485
|
+
catch { throw new Error(`Custom tunnel emitted an invalid VOLTER_SHARE_URL handshake: ${handshake}`); }
|
|
1486
|
+
const normalizedHostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
1487
|
+
if (!rawHost.startsWith('[') && isIP(normalizedHostname) === 4
|
|
1488
|
+
&& (!canonicalDottedIpv4(rawHost) || rawHost !== normalizedHostname)) {
|
|
1489
|
+
throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical dotted-decimal IPv4 syntax: ${handshake}`);
|
|
1490
|
+
}
|
|
1491
|
+
if (rawPort && rawPort.length > 1 && rawPort.startsWith('0')) {
|
|
1492
|
+
throw new Error(`Custom tunnel VOLTER_SHARE_URL must use canonical decimal port syntax: ${handshake}`);
|
|
1493
|
+
}
|
|
1494
|
+
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password
|
|
1495
|
+
|| parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
1496
|
+
throw new Error(`Custom tunnel VOLTER_SHARE_URL must be a credential-free http(s) origin: ${handshake}`);
|
|
1497
|
+
}
|
|
1498
|
+
return parsed.origin;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1137
1501
|
async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string, timeoutMs: number, requireTryCloudflare: boolean): Promise<string> {
|
|
1138
|
-
const urlPattern = /https
|
|
1502
|
+
const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
|
|
1139
1503
|
let exited = false;
|
|
1140
1504
|
let exitSummary = '';
|
|
1141
1505
|
child.once('exit', (code, signal) => {
|
|
@@ -1150,15 +1514,21 @@ async function waitForPublicUrl(child: ReturnType<typeof spawn>, logPath: string
|
|
|
1150
1514
|
const started = Date.now();
|
|
1151
1515
|
while (Date.now() - started < timeoutMs) {
|
|
1152
1516
|
const text = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1517
|
+
if (requireTryCloudflare) {
|
|
1518
|
+
for (const candidate of text.match(urlPattern) ?? []) {
|
|
1519
|
+
const parsed = parseCloudflareQuickPublicUrl(candidate);
|
|
1520
|
+
if (parsed) return parsed;
|
|
1521
|
+
}
|
|
1522
|
+
} else {
|
|
1523
|
+
const handshake = /^VOLTER_SHARE_URL=(\S+)\s*$/m.exec(text)?.[1];
|
|
1524
|
+
if (handshake) return parseCustomShareOrigin(handshake);
|
|
1525
|
+
}
|
|
1158
1526
|
if (exited) throw new Error(`Tunnel process exited before printing a public URL (${exitSummary}). Log: ${logPath}`);
|
|
1159
1527
|
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
1160
1528
|
}
|
|
1161
|
-
throw new Error(
|
|
1529
|
+
throw new Error(requireTryCloudflare
|
|
1530
|
+
? `Cloudflare tunnel did not print a trycloudflare.com https URL within ${timeoutMs}ms. Log: ${logPath}`
|
|
1531
|
+
: `Custom tunnel did not print VOLTER_SHARE_URL=<http(s)-url> within ${timeoutMs}ms. Log: ${logPath}`);
|
|
1162
1532
|
}
|
|
1163
1533
|
|
|
1164
1534
|
function sleep(ms: number): Promise<void> {
|
|
@@ -1205,9 +1575,23 @@ async function verifyPublicUrl(
|
|
|
1205
1575
|
path: string,
|
|
1206
1576
|
timeoutMs: number,
|
|
1207
1577
|
): Promise<NonNullable<WorldServiceInstance['publicVerification']>> {
|
|
1208
|
-
if (!commandExists('dig')) throw new Error('Public URL verification requires dig');
|
|
1209
1578
|
if (!commandExists('curl')) throw new Error('Public URL verification requires curl');
|
|
1210
1579
|
const hostname = new URL(publicUrl).hostname;
|
|
1580
|
+
const normalizedHostname = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
1581
|
+
const local = normalizedHostname === 'localhost' || normalizedHostname === '::1'
|
|
1582
|
+
|| (isIP(normalizedHostname) === 4 && normalizedHostname.split('.')[0] === '127');
|
|
1583
|
+
if (local) {
|
|
1584
|
+
const target = publicHealthUrl(publicUrl, path);
|
|
1585
|
+
const result = spawnSync('curl', ['--noproxy', '*', '-sS', '-o', '-', '-w', '\n%{http_code}', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), target], { encoding: 'utf8' });
|
|
1586
|
+
const separator = result.stdout.lastIndexOf('\n');
|
|
1587
|
+
const body = separator >= 0 ? result.stdout.slice(0, separator).trim() : result.stdout.trim();
|
|
1588
|
+
const status = Number(separator >= 0 ? result.stdout.slice(separator + 1).trim() : '0');
|
|
1589
|
+
if (result.status !== 0 || status < 200 || status >= 400) {
|
|
1590
|
+
throw new Error(`Local shared URL did not pass health verification at ${target}: ${result.stderr.trim() || `HTTP ${status}`}`);
|
|
1591
|
+
}
|
|
1592
|
+
return { path, checkedAt: new Date().toISOString(), hostname, resolvedIp: normalizedHostname === 'localhost' ? '127.0.0.1' : normalizedHostname, status, body: body.slice(0, 500) };
|
|
1593
|
+
}
|
|
1594
|
+
if (!commandExists('dig')) throw new Error('Public URL verification requires dig');
|
|
1211
1595
|
const started = Date.now();
|
|
1212
1596
|
const resolvedIp = await waitForPublicDns(hostname, Math.min(timeoutMs, 60_000));
|
|
1213
1597
|
if (!resolvedIp) {
|
|
@@ -1259,6 +1643,24 @@ async function verifyPublicUrl(
|
|
|
1259
1643
|
}
|
|
1260
1644
|
|
|
1261
1645
|
export async function shareWorld(name: string, options: ShareWorldOptions = {}): Promise<WorldInstance> {
|
|
1646
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1647
|
+
const serviceId = options.service ?? 'app';
|
|
1648
|
+
assertSafeWorldName(name);
|
|
1649
|
+
const claim = await reserveSharingClaim(root, name, serviceId);
|
|
1650
|
+
const heartbeat = setInterval(() => {
|
|
1651
|
+
try { refreshSharingClaim(root, name, claim.token); }
|
|
1652
|
+
catch { /* retry next tick; same-host process identity still protects the live owner */ }
|
|
1653
|
+
}, SHARE_CLAIM_HEARTBEAT_MS);
|
|
1654
|
+
heartbeat.unref();
|
|
1655
|
+
try {
|
|
1656
|
+
return await shareWorldReserved(name, { ...options, root, service: serviceId });
|
|
1657
|
+
} finally {
|
|
1658
|
+
clearInterval(heartbeat);
|
|
1659
|
+
releaseSharingClaim(root, name, claim.token);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function shareWorldReserved(name: string, options: ShareWorldOptions): Promise<WorldInstance> {
|
|
1262
1664
|
const root = resolve(options.root ?? process.cwd());
|
|
1263
1665
|
const serviceId = options.service ?? 'app';
|
|
1264
1666
|
const instance = readWorldInstance(name, root);
|
|
@@ -1273,7 +1675,9 @@ export async function shareWorld(name: string, options: ShareWorldOptions = {}):
|
|
|
1273
1675
|
|
|
1274
1676
|
const tunnel = shareCommand(options, service.url);
|
|
1275
1677
|
const log = join(instance.dirs.logs, `${serviceId}.tunnel.log`);
|
|
1276
|
-
|
|
1678
|
+
// Each spawned tunnel owns one log generation. Truncating before spawn prevents a repeated
|
|
1679
|
+
// share from accepting the previous process's URL handshake as the new process's endpoint.
|
|
1680
|
+
const out = openPrivateLog(log, 'w');
|
|
1277
1681
|
const child = spawn(tunnel.command, tunnel.args, {
|
|
1278
1682
|
cwd: root,
|
|
1279
1683
|
env: process.env,
|
|
@@ -1284,11 +1688,34 @@ export async function shareWorld(name: string, options: ShareWorldOptions = {}):
|
|
|
1284
1688
|
|
|
1285
1689
|
let publicUrl: string;
|
|
1286
1690
|
let publicVerification: WorldServiceInstance['publicVerification'];
|
|
1691
|
+
let pidsWritten = false;
|
|
1287
1692
|
try {
|
|
1288
1693
|
publicUrl = await waitForPublicUrl(child, log, options.timeoutMs ?? 20_000, tunnel.provider === 'cloudflare-quick');
|
|
1289
1694
|
if (options.verifyPath !== false) {
|
|
1290
1695
|
publicVerification = await verifyPublicUrl(publicUrl, options.verifyPath ?? '/health', options.timeoutMs ?? 90_000);
|
|
1291
1696
|
}
|
|
1697
|
+
|
|
1698
|
+
// Build the prospective instance off to the side. Until BOTH persistence files are written,
|
|
1699
|
+
// callers and the previously loaded instance retain the exact unshared object.
|
|
1700
|
+
const nextInstance = structuredClone(instance);
|
|
1701
|
+
const nextService = nextInstance.services[serviceId]!;
|
|
1702
|
+
nextService.publicUrl = publicUrl;
|
|
1703
|
+
nextService.publicUrlEphemeral = tunnel.provider === 'cloudflare-quick' || options.ephemeral === true;
|
|
1704
|
+
nextService.publicReady = options.verifyPath !== false;
|
|
1705
|
+
if (publicVerification) nextService.publicVerification = publicVerification;
|
|
1706
|
+
else delete nextService.publicVerification;
|
|
1707
|
+
nextService.tunnel = {
|
|
1708
|
+
provider: tunnel.provider,
|
|
1709
|
+
pid: child.pid ?? 0,
|
|
1710
|
+
log,
|
|
1711
|
+
command: [tunnel.command, ...tunnel.args],
|
|
1712
|
+
startedAt: new Date().toISOString(),
|
|
1713
|
+
};
|
|
1714
|
+
writePidsFromInstance(nextInstance);
|
|
1715
|
+
pidsWritten = true;
|
|
1716
|
+
saveWorldInstance(nextInstance);
|
|
1717
|
+
child.unref();
|
|
1718
|
+
return nextInstance;
|
|
1292
1719
|
} catch (error) {
|
|
1293
1720
|
// TWIN-62: never-half-shared means the tunnel must actually be confirmed dead before we
|
|
1294
1721
|
// rethrow, not just best-effort SIGTERM'd — the same SIGTERM→grace→SIGKILL contract `downWorld`
|
|
@@ -1298,25 +1725,20 @@ export async function shareWorld(name: string, options: ShareWorldOptions = {}):
|
|
|
1298
1725
|
signalPids([child.pid], 'SIGTERM');
|
|
1299
1726
|
await killSurvivorsAfterGrace([child.pid], DOWN_GRACE_MS_DEFAULT);
|
|
1300
1727
|
}
|
|
1728
|
+
const cleanupProblems: string[] = [];
|
|
1729
|
+
if (child.pid && undeadPids([child.pid]).length > 0) cleanupProblems.push(`tunnel pid ${child.pid} survived cleanup`);
|
|
1730
|
+
// If the pids write landed but instance.json did not, restore the previous pids projection only
|
|
1731
|
+
// after the child is confirmed dead. Atomic writes ensure a failed write itself leaves the old
|
|
1732
|
+
// file intact; this compensates the one successful write in a two-file persistence attempt.
|
|
1733
|
+
if (pidsWritten) {
|
|
1734
|
+
try { writePidsFromInstance(instance); }
|
|
1735
|
+
catch (restoreError) { cleanupProblems.push(`could not restore pids file: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`); }
|
|
1736
|
+
}
|
|
1737
|
+
if (cleanupProblems.length > 0) {
|
|
1738
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}; share cleanup incomplete: ${cleanupProblems.join('; ')}`, { cause: error });
|
|
1739
|
+
}
|
|
1301
1740
|
throw error;
|
|
1302
1741
|
}
|
|
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
1742
|
}
|
|
1321
1743
|
|
|
1322
1744
|
function configuredShareTargets(
|
|
@@ -1341,12 +1763,22 @@ export async function shareWorldServices(name: string, options: ShareWorldServic
|
|
|
1341
1763
|
const config = loadWorldConfig(instance.configPath, instance.root).config;
|
|
1342
1764
|
const targets = configuredShareTargets(instance, options.service, options.verifyPath);
|
|
1343
1765
|
for (const target of targets) {
|
|
1766
|
+
const provider = options.provider ?? (options.command ? 'command' : config.share?.provider);
|
|
1767
|
+
if (provider === 'cloudflare-quick' && options.command) {
|
|
1768
|
+
throw new Error('share provider "cloudflare-quick" uses the built-in cloudflared command and cannot be combined with a custom command');
|
|
1769
|
+
}
|
|
1770
|
+
// An explicit provider override is authoritative. In particular, selecting the real
|
|
1771
|
+
// Cloudflare provider must clear a config-declared local rehearsal command instead of
|
|
1772
|
+
// launching that command while parsing its output as cloudflared.
|
|
1773
|
+
const command = provider === 'cloudflare-quick' ? undefined : options.command ?? config.share?.command;
|
|
1774
|
+
const args = provider === 'cloudflare-quick' ? undefined : options.args ?? config.share?.args;
|
|
1344
1775
|
instance = await shareWorld(name, {
|
|
1345
1776
|
...options,
|
|
1346
1777
|
root,
|
|
1347
|
-
provider
|
|
1348
|
-
command
|
|
1349
|
-
args
|
|
1778
|
+
provider,
|
|
1779
|
+
command,
|
|
1780
|
+
args,
|
|
1781
|
+
ephemeral: options.ephemeral ?? config.share?.ephemeral,
|
|
1350
1782
|
service: target.id,
|
|
1351
1783
|
verifyPath: target.verifyPath,
|
|
1352
1784
|
});
|
|
@@ -1458,6 +1890,17 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
1458
1890
|
|
|
1459
1891
|
checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
|
|
1460
1892
|
|
|
1893
|
+
if (instance.resources) {
|
|
1894
|
+
const holderAlive = livePids([instance.resources.holderPid]).length === 1;
|
|
1895
|
+
checks.push({
|
|
1896
|
+
id: 'resources',
|
|
1897
|
+
ok: holderAlive && existsSync(instance.resources.log),
|
|
1898
|
+
message: holderAlive
|
|
1899
|
+
? `reserved memory=${instance.resources.memoryMiB}MiB writable-storage=${instance.resources.writableStorageMiB}MiB; log=${instance.resources.log}`
|
|
1900
|
+
: `resource reservation holder is not running; log=${instance.resources.log}`,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1461
1904
|
const live = livePids(readPids(instance.pidsFile));
|
|
1462
1905
|
checks.push({
|
|
1463
1906
|
id: 'pids',
|
|
@@ -1591,13 +2034,41 @@ export function urlsWorld(name: string, root = process.cwd()): WorldUrlInfo {
|
|
|
1591
2034
|
};
|
|
1592
2035
|
}
|
|
1593
2036
|
|
|
1594
|
-
|
|
2037
|
+
/**
|
|
2038
|
+
* The clean (unquoted) base URL(s) of a RUNNING world's services — the `volter-world url`
|
|
2039
|
+
* surface. Resolved from the generated instance, so callers never shell-parse `world.env`
|
|
2040
|
+
* (its values are single-quoted). One `{ id, url }` when `service` is named; every
|
|
2041
|
+
* URL-bearing service otherwise ('external' services have no assigned URL — their endpoints
|
|
2042
|
+
* are discovered env vars, so they are omitted from the list and refused by name).
|
|
2043
|
+
*/
|
|
2044
|
+
export function urlWorld(name: string, options: { service?: string; root?: string } = {}): Array<{ id: string; url: string }> {
|
|
2045
|
+
const status = statusWorld(name, options.root ?? process.cwd());
|
|
2046
|
+
if (!status.running) {
|
|
2047
|
+
throw new Error(`World ${name} is not running; start it with: volter-world up ${status.config} --env-file <path> --name ${name}`);
|
|
2048
|
+
}
|
|
2049
|
+
if (options.service === undefined) {
|
|
2050
|
+
return Object.values(status.services)
|
|
2051
|
+
.filter((service) => service.url)
|
|
2052
|
+
.map((service) => ({ id: service.id, url: service.url! }));
|
|
2053
|
+
}
|
|
2054
|
+
const service = status.services[options.service];
|
|
2055
|
+
if (!service) {
|
|
2056
|
+
throw new Error(`World ${name} has no service "${options.service}"; it has: ${Object.keys(status.services).join(', ')}`);
|
|
2057
|
+
}
|
|
2058
|
+
if (!service.url) {
|
|
2059
|
+
throw new Error(`Service "${options.service}" has no assigned URL (type '${service.type}' endpoints are discovered env vars) — read them with: volter-world env ${name} -- env`);
|
|
2060
|
+
}
|
|
2061
|
+
return [{ id: service.id, url: service.url }];
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
export async function runWorld(configId: string, command: string[], options: RunWorldOptions = {}): Promise<{ instance: WorldInstance; exitCode: number; outcome: WorldRunOutcome }> {
|
|
1595
2065
|
if (command.length === 0) throw new Error('Missing command after --');
|
|
1596
2066
|
const instance = await upWorld(configId, options);
|
|
1597
|
-
let exitCode = 1;
|
|
1598
2067
|
try {
|
|
1599
|
-
|
|
1600
|
-
|
|
2068
|
+
const outcome = await runWithWorldEnvLogged(instance.name, command, instance.root);
|
|
2069
|
+
instance.lastRun = outcome;
|
|
2070
|
+
saveWorldInstance(instance);
|
|
2071
|
+
return { instance, exitCode: outcome.exitCode, outcome };
|
|
1601
2072
|
} finally {
|
|
1602
2073
|
if (!options.keep) await downWorld(instance.name, instance.root);
|
|
1603
2074
|
}
|
|
@@ -1607,7 +2078,10 @@ export function listWorlds(root = process.cwd()): Array<{ name: string; running:
|
|
|
1607
2078
|
const base = worldBaseDir(resolve(root));
|
|
1608
2079
|
if (!existsSync(base)) return [];
|
|
1609
2080
|
return readdirSync(base, { withFileTypes: true })
|
|
1610
|
-
|
|
2081
|
+
// World names must start with an alphanumeric (assertSafeWorldName), which is exactly why
|
|
2082
|
+
// the lock dir is dot-prefixed — so anything not world-NAMED is bookkeeping (`.locks`),
|
|
2083
|
+
// never a world, and must not be listed as one.
|
|
2084
|
+
.filter((entry) => entry.isDirectory() && /^[A-Za-z0-9]/.test(entry.name))
|
|
1611
2085
|
.map((entry) => {
|
|
1612
2086
|
try {
|
|
1613
2087
|
const status = statusWorld(entry.name, root);
|
|
@@ -1619,8 +2093,7 @@ export function listWorlds(root = process.cwd()): Array<{ name: string; running:
|
|
|
1619
2093
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1620
2094
|
}
|
|
1621
2095
|
|
|
1622
|
-
|
|
1623
|
-
if (command.length === 0) throw new Error('Missing command after --');
|
|
2096
|
+
function worldAttachedCommandEnv(name: string, root: string): { instance: ReturnType<typeof statusWorld>; env: NodeJS.ProcessEnv } {
|
|
1624
2097
|
const status = statusWorld(name, root);
|
|
1625
2098
|
const sealed = status.env.VOLTER_WORLD_MODE === 'sealed';
|
|
1626
2099
|
let proxyEnv: Record<string, string> = {};
|
|
@@ -1628,37 +2101,120 @@ export function runWithWorldEnv(name: string, command: string[], root = process.
|
|
|
1628
2101
|
const proxy = ensureWorldProxy(name, root);
|
|
1629
2102
|
if (proxy?.url) {
|
|
1630
2103
|
proxyEnv = proxy.env;
|
|
1631
|
-
} else if (sealed
|
|
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
|
-
}
|
|
2104
|
+
} else if (sealed) throw new Error(`sealed world "${name}": attachment proxy is unavailable; refusing to run the command`);
|
|
1643
2105
|
} 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
|
-
}
|
|
2106
|
+
if (sealed) throw new Error(`sealed world "${name}": attachment proxy failed; refusing to run the command (${error instanceof Error ? error.message : String(error)})`);
|
|
1652
2107
|
// Keep env-only redirect if the ambient proxy cannot start.
|
|
1653
2108
|
}
|
|
2109
|
+
return { instance: status, env: { ...process.env, ...status.env, ...proxyEnv } };
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string } = {}): number {
|
|
2113
|
+
if (command.length === 0) throw new Error('Missing command after --');
|
|
2114
|
+
const attached = worldAttachedCommandEnv(name, root);
|
|
1654
2115
|
const result = spawnSync(command[0]!, command.slice(1), {
|
|
1655
|
-
cwd: resolve(root),
|
|
1656
|
-
env:
|
|
2116
|
+
cwd: resolve(options.cwd ?? root),
|
|
2117
|
+
env: attached.env,
|
|
1657
2118
|
stdio: 'inherit',
|
|
1658
2119
|
});
|
|
1659
2120
|
return result.status ?? 1;
|
|
1660
2121
|
}
|
|
1661
2122
|
|
|
2123
|
+
function signalExitCode(signal: NodeJS.Signals | null): number {
|
|
2124
|
+
if (signal === null) return 1;
|
|
2125
|
+
return 128 + (osConstants.signals[signal] ?? 0);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
/** `world run`'s foreground consumer: stream output to the terminal while retaining one current
|
|
2129
|
+
* World-visible log, then return a structured outcome even when spawning fails or a signal kills
|
|
2130
|
+
* the consumer. The record intentionally contains neither argv nor environment values. */
|
|
2131
|
+
async function runWithWorldEnvLogged(name: string, command: string[], root: string, options: { cwd?: string } = {}): Promise<WorldRunOutcome> {
|
|
2132
|
+
const startedAt = new Date().toISOString();
|
|
2133
|
+
const instance = statusWorld(name, root);
|
|
2134
|
+
const log = join(instance.dirs.logs, 'foreground.log');
|
|
2135
|
+
const running: WorldRunRecord = { state: 'running', runnerPid: process.pid, startedAt, log };
|
|
2136
|
+
writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(running, null, 2)}\n`);
|
|
2137
|
+
const logFd = openPrivateLog(log, 'w');
|
|
2138
|
+
writeSync(logFd, `World ${name}: foreground consumer started at ${startedAt}\n`);
|
|
2139
|
+
|
|
2140
|
+
const finish = (exitCode: number, signal?: NodeJS.Signals, error?: string): WorldRunOutcome => {
|
|
2141
|
+
const finishedAt = new Date().toISOString();
|
|
2142
|
+
const detail = error ? `spawn error: ${error}` : signal ? `terminated by ${signal}` : `exited ${exitCode}`;
|
|
2143
|
+
writeSync(logFd, `\nWorld ${name}: foreground consumer ${detail} at ${finishedAt}\n`);
|
|
2144
|
+
closeSync(logFd);
|
|
2145
|
+
const outcome: WorldRunOutcome = {
|
|
2146
|
+
state: 'completed',
|
|
2147
|
+
runnerPid: process.pid,
|
|
2148
|
+
startedAt,
|
|
2149
|
+
finishedAt,
|
|
2150
|
+
exitCode,
|
|
2151
|
+
log,
|
|
2152
|
+
...(signal ? { signal } : {}),
|
|
2153
|
+
...(error ? { error } : {}),
|
|
2154
|
+
};
|
|
2155
|
+
writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(outcome, null, 2)}\n`);
|
|
2156
|
+
return outcome;
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
let attached: ReturnType<typeof worldAttachedCommandEnv>;
|
|
2160
|
+
try {
|
|
2161
|
+
attached = worldAttachedCommandEnv(name, root);
|
|
2162
|
+
} catch (cause) {
|
|
2163
|
+
return finish(1, undefined, cause instanceof Error ? cause.message : String(cause));
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
return await new Promise<WorldRunOutcome>((resolveOutcome) => {
|
|
2167
|
+
let spawnError: string | undefined;
|
|
2168
|
+
let child;
|
|
2169
|
+
try {
|
|
2170
|
+
child = spawn(command[0]!, command.slice(1), {
|
|
2171
|
+
cwd: resolve(options.cwd ?? root),
|
|
2172
|
+
env: attached.env,
|
|
2173
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
2174
|
+
});
|
|
2175
|
+
} catch (cause) {
|
|
2176
|
+
resolveOutcome(finish(1, undefined, cause instanceof Error ? cause.message : String(cause)));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
2181
|
+
process.stdout.write(chunk);
|
|
2182
|
+
writeSync(logFd, chunk);
|
|
2183
|
+
});
|
|
2184
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
2185
|
+
process.stderr.write(chunk);
|
|
2186
|
+
writeSync(logFd, chunk);
|
|
2187
|
+
});
|
|
2188
|
+
child.once('error', (cause) => {
|
|
2189
|
+
spawnError = cause.message;
|
|
2190
|
+
});
|
|
2191
|
+
const activeRecord: WorldRunRecord = {
|
|
2192
|
+
state: 'running',
|
|
2193
|
+
runnerPid: process.pid,
|
|
2194
|
+
...(child.pid ? { consumerPid: child.pid } : {}),
|
|
2195
|
+
startedAt,
|
|
2196
|
+
log,
|
|
2197
|
+
};
|
|
2198
|
+
writeTextAtomic(foregroundRunFile(instance.root, instance.name), `${JSON.stringify(activeRecord, null, 2)}\n`);
|
|
2199
|
+
let forwardedSignal: NodeJS.Signals | undefined;
|
|
2200
|
+
const forwardSignal = (signal: NodeJS.Signals): void => {
|
|
2201
|
+
forwardedSignal = signal;
|
|
2202
|
+
child.kill(signal);
|
|
2203
|
+
};
|
|
2204
|
+
const onSigint = (): void => forwardSignal('SIGINT');
|
|
2205
|
+
const onSigterm = (): void => forwardSignal('SIGTERM');
|
|
2206
|
+
process.once('SIGINT', onSigint);
|
|
2207
|
+
process.once('SIGTERM', onSigterm);
|
|
2208
|
+
child.once('close', (code, signal) => {
|
|
2209
|
+
process.removeListener('SIGINT', onSigint);
|
|
2210
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
2211
|
+
const effectiveSignal = signal ?? forwardedSignal;
|
|
2212
|
+
const exitCode = effectiveSignal ? signalExitCode(effectiveSignal) : (code ?? 1);
|
|
2213
|
+
resolveOutcome(finish(exitCode, effectiveSignal, spawnError));
|
|
2214
|
+
});
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
1662
2218
|
/** The shell + env + cwd a `volter-world shell` subshell launches with (extracted for testability):
|
|
1663
2219
|
* the world env (twin `*_URL`s + fake keys + the Node injector + cliRedirect endpoint vars) on top
|
|
1664
2220
|
* of the caller's env, plus `VOLTER_WORLD` so a prompt can show which world is active. The ambient
|
|
@@ -1684,15 +2240,18 @@ export async function shellWorld(name: string, root = process.cwd()): Promise<nu
|
|
|
1684
2240
|
const { shell, cwd, env } = worldShellEnv(name, root);
|
|
1685
2241
|
const resolvedRoot = resolve(root);
|
|
1686
2242
|
let proxy: Awaited<ReturnType<typeof startRedirectProxy>> | null = null;
|
|
2243
|
+
const sealed = env.VOLTER_WORLD_MODE === 'sealed';
|
|
1687
2244
|
if (opensslAvailable()) {
|
|
1688
2245
|
try {
|
|
1689
2246
|
proxy = await startRedirectProxy({ env, tlsDir: tlsDir(resolvedRoot, name) });
|
|
1690
2247
|
Object.assign(env, proxy.proxyEnv());
|
|
1691
2248
|
process.stderr.write(`world '${name}' active in a subshell — vendor calls hit the twins (ambient proxy ${proxy.url}). type 'exit' to leave.\n`);
|
|
1692
2249
|
} catch (error) {
|
|
2250
|
+
if (sealed) throw new Error(`sealed world "${name}": attachment proxy failed; refusing to open a shell (${error instanceof Error ? error.message : String(error)})`);
|
|
1693
2251
|
process.stderr.write(`world '${name}': ambient TLS proxy unavailable (${error instanceof Error ? error.message : String(error)}); falling back to env-only redirect.\n`);
|
|
1694
2252
|
}
|
|
1695
2253
|
} else {
|
|
2254
|
+
if (sealed) throw new Error(`sealed world "${name}": openssl is unavailable; refusing to open a shell`);
|
|
1696
2255
|
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
2256
|
}
|
|
1698
2257
|
try {
|
|
@@ -1859,13 +2418,15 @@ export function activateScript(name: string, root = process.cwd()): string {
|
|
|
1859
2418
|
// routes https through the twins too — not just the Node injector / cliRedirect endpoint vars.
|
|
1860
2419
|
let proxyNote = '';
|
|
1861
2420
|
let env: Record<string, string> = { ...baseEnv };
|
|
2421
|
+
const sealed = baseEnv.VOLTER_WORLD_MODE === 'sealed';
|
|
1862
2422
|
try {
|
|
1863
2423
|
const proxy = ensureWorldProxy(name, root);
|
|
1864
2424
|
if (proxy && proxy.url) {
|
|
1865
2425
|
env = { ...baseEnv, ...proxy.env };
|
|
1866
2426
|
proxyNote = ` (ambient proxy ${proxy.url})`;
|
|
1867
|
-
}
|
|
1868
|
-
} catch {
|
|
2427
|
+
} else if (sealed) throw new Error('attachment proxy is unavailable');
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
if (sealed) throw new Error(`sealed world "${name}": attachment proxy failed; refusing activation (${error instanceof Error ? error.message : String(error)})`);
|
|
1869
2430
|
// openssl missing or daemon failed — keep env-only redirect; never fabricate proxy success.
|
|
1870
2431
|
}
|
|
1871
2432
|
const keys = Object.keys(env).sort();
|