@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/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 { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync, writeSync, existsSync } from 'node:fs';
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
- return join(root, '.volter', 'worlds');
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
- function instanceDir(root: string, name: string): string {
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
- writeFileSync(instance.pidsFile, `${pids.join('\n')}\n`);
441
+ writeTextAtomic(instance.pidsFile, `${pids.join('\n')}\n`);
304
442
  }
305
443
 
306
444
  function saveWorldInstance(instance: WorldInstance): void {
307
- writeFileSync(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
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
- const out = openSync(logPath, 'a');
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 = { ...process.env, ...worldEnv };
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, paths.root, service.id, env, log);
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, paths.root, service.id, env);
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, paths.root, service.id, 'status', env);
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: paths.root, discoveredEnv: injected },
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 = openSync(log, 'a');
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
- await waitForTcp(port).catch((error) => {
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 = openSync(log, 'a');
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
- await waitForTcp(port).catch((error) => {
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
- const isolation = options.isolation ?? loaded.config.isolation ?? 'process';
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
- 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
- );
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
- 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
- });
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
- const pids = readPids(pidsFile);
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
- const instance = readWorldInstance(name, resolvedRoot);
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: options.provider ?? 'command',
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:\/\/[a-zA-Z0-9.-]+/g;
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
- 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;
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(`Tunnel did not print a public https URL within ${timeoutMs}ms. Log: ${logPath}`);
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
- const out = openSync(log, 'a');
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: options.provider ?? config.share?.provider,
1348
- command: options.command ?? config.share?.command,
1349
- args: options.args ?? config.share?.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
- export async function runWorld(configId: string, command: string[], options: RunWorldOptions = {}): Promise<{ instance: WorldInstance; exitCode: number }> {
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
- exitCode = runWithWorldEnv(instance.name, command, instance.root);
1600
- return { instance, exitCode };
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
- .filter((entry) => entry.isDirectory())
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
- export function runWithWorldEnv(name: string, command: string[], root = process.cwd()): number {
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 && 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
- }
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: { ...process.env, ...status.env, ...proxyEnv },
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();