@volter/twin-world 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-world",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
package/src/resources.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statfsSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { homedir, hostname, totalmem } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
@@ -13,6 +13,7 @@ export const DEFAULT_WORLD_RESOURCES: WorldResourceRequirements = {
13
13
 
14
14
  export type WorldResourceClaim = {
15
15
  world: string;
16
+ root?: string;
16
17
  identity: string;
17
18
  ownerPid: number;
18
19
  holderPids: number[];
@@ -77,7 +78,17 @@ function writePrivate(path: string, contents: string): void {
77
78
  writeFileSync(path, contents, { mode: 0o600 });
78
79
  }
79
80
 
80
- function capacity(root: string, claims: WorldResourceClaim[]): WorldResourceRequirements {
81
+ /** Resolve old claims without rewriting them. Unknown locations stay charged on every
82
+ * filesystem rather than silently allowing a reservation we cannot locate. */
83
+ function sharesStorage(root: string, claim: WorldResourceClaim): boolean {
84
+ try {
85
+ const claimRoot = claim.root ?? resolve(dirname(claim.log), '../../..');
86
+ if (claimIdentity(claimRoot, claim.world) !== claim.identity) return true;
87
+ return statSync(claimRoot).dev === statSync(root).dev;
88
+ } catch { return true; }
89
+ }
90
+
91
+ export function worldResourceCapacity(root: string, claims: WorldResourceClaim[]): WorldResourceRequirements {
81
92
  const fs = statfsSync(resolve(root));
82
93
  const diskFreeMiB = Math.floor((Number(fs.bavail) * Number(fs.bsize)) / MIB);
83
94
  const memoryTotalMiB = Math.floor(totalmem() / MIB);
@@ -85,7 +96,8 @@ function capacity(root: string, claims: WorldResourceClaim[]): WorldResourceRequ
85
96
  const diskTotalMiB = Math.floor((Number(fs.blocks) * Number(fs.bsize)) / MIB);
86
97
  const diskSafetyMiB = Math.max(512, Math.min(2048, Math.floor(diskTotalMiB * 0.05)));
87
98
  const reservedMemoryMiB = claims.reduce((sum, claim) => sum + claim.resources.memoryMiB, 0);
88
- const reservedDiskMiB = claims.reduce((sum, claim) => sum + claim.resources.writableStorageMiB, 0);
99
+ const reservedDiskMiB = claims.filter((claim) => sharesStorage(root, claim))
100
+ .reduce((sum, claim) => sum + claim.resources.writableStorageMiB, 0);
89
101
  return {
90
102
  memoryMiB: Math.max(0, memoryTotalMiB - memorySafetyMiB - reservedMemoryMiB),
91
103
  writableStorageMiB: Math.max(0, diskFreeMiB - diskSafetyMiB - reservedDiskMiB),
@@ -106,7 +118,7 @@ export function claimWorldResources(root: string, world: string, requested: Worl
106
118
  return withFileLock(join(dir, 'claims.lock'), () => {
107
119
  const identity = claimIdentity(root, world);
108
120
  const claims = liveClaims(identity);
109
- const available = capacity(root, claims);
121
+ const available = worldResourceCapacity(root, claims);
110
122
  const shortages: string[] = [];
111
123
  if (requested.memoryMiB > available.memoryMiB) shortages.push(`memory requires ${requested.memoryMiB} MiB, ${available.memoryMiB} MiB available`);
112
124
  if (requested.writableStorageMiB > available.writableStorageMiB) {
@@ -120,6 +132,7 @@ export function claimWorldResources(root: string, world: string, requested: Worl
120
132
  }
121
133
  const claim: WorldResourceClaim = {
122
134
  world,
135
+ root: resolve(root),
123
136
  identity,
124
137
  ownerPid: process.pid,
125
138
  holderPids: [],
package/src/runtime.ts CHANGED
@@ -329,6 +329,24 @@ const CONTROL_PLANE_EGRESS_ENV = [
329
329
  'all_proxy',
330
330
  ];
331
331
 
332
+ /** The process's environment as the world admits it: what `stripEnv` names stays outside. */
333
+ export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
334
+ const out: Record<string, string> = {};
335
+ const rules = (strip ?? []).map((s): { prefix: string } | { name: string } => (s.endsWith('*') ? { prefix: s.slice(0, -1) } : { name: s }));
336
+ for (const [k, v] of Object.entries(env)) {
337
+ if (v === undefined) continue;
338
+ if (rules.some((r) => ('prefix' in r ? k.startsWith(r.prefix) : k === r.name))) continue;
339
+ out[k] = v;
340
+ }
341
+ return out;
342
+ }
343
+ // The strip list of a running world, for `env`/`attach`: its config is the one the instance recorded.
344
+ function stripEnvOf(instanceEnv: Record<string, string>): string[] | undefined {
345
+ const path = instanceEnv.VOLTER_WORLD_CONFIG;
346
+ if (!path) return undefined;
347
+ try { return (JSON.parse(readFileSync(path, 'utf8')) as { stripEnv?: string[] }).stripEnv; } catch { return undefined; }
348
+ }
349
+
332
350
  function serviceProcessEnv(
333
351
  config: WorldConfig,
334
352
  service: WorldServiceConfig,
@@ -336,7 +354,7 @@ function serviceProcessEnv(
336
354
  port: number,
337
355
  ): Record<string, string> {
338
356
  const env: Record<string, string> = {
339
- ...process.env,
357
+ ...admittedEnv(config.stripEnv),
340
358
  ...(config.env ?? {}),
341
359
  ...worldEnv,
342
360
  PORT: String(port),
@@ -645,6 +663,7 @@ async function startExternalService(
645
663
  service: WorldServiceConfig,
646
664
  paths: { root: string; logs: string },
647
665
  worldEnv: Record<string, string>,
666
+ stripEnv?: string[],
648
667
  ): Promise<WorldServiceInstance> {
649
668
  const external = service.external;
650
669
  if (!external) throw new Error(`External service "${service.id}" is missing its external config`);
@@ -653,7 +672,7 @@ async function startExternalService(
653
672
  // External commands see the accumulated world env (process.env + earlier services' discovered/
654
673
  // injected vars), so a later external can consume an earlier service's connection info.
655
674
  const env: NodeJS.ProcessEnv = {
656
- ...process.env,
675
+ ...admittedEnv(stripEnv),
657
676
  ...worldEnv,
658
677
  ...(service.env ?? {}),
659
678
  VOLTER_WORLD_SERVICE_ID: service.id,
@@ -737,7 +756,7 @@ async function startService(
737
756
  worldEnv: Record<string, string>,
738
757
  paths: { root: string; instance: string; logs: string; data: string },
739
758
  ): Promise<WorldServiceInstance> {
740
- if (service.type === 'external') return startExternalService(service, paths, worldEnv);
759
+ if (service.type === 'external') return startExternalService(service, paths, worldEnv, config.stripEnv);
741
760
  if (!service.command) throw new Error(`Service "${service.id}" must define command`);
742
761
  const port = service.port === undefined || service.port === 'auto' ? await allocatePort() : service.port;
743
762
  const serviceDataDir = join(paths.data, service.id);
@@ -862,7 +881,7 @@ async function startColocatedServices(
862
881
  const out = openPrivateLog(log, 'a');
863
882
  // The host is world infrastructure, not an app process — same egress hygiene as controlPlane
864
883
  // services (no injector preload, no ambient proxy): its twins SERVE, they don't call vendors.
865
- const env: Record<string, string> = { ...process.env as Record<string, string>, ...(config.env ?? {}), ...worldEnv };
884
+ const env: Record<string, string> = { ...admittedEnv(config.stripEnv), ...(config.env ?? {}), ...worldEnv };
866
885
  for (const key of CONTROL_PLANE_EGRESS_ENV) delete env[key];
867
886
  const child = spawn(process.execPath, args, {
868
887
  cwd: paths.root,
@@ -2106,7 +2125,7 @@ function worldAttachedCommandEnv(name: string, root: string): { instance: Return
2106
2125
  if (sealed) throw new Error(`sealed world "${name}": attachment proxy failed; refusing to run the command (${error instanceof Error ? error.message : String(error)})`);
2107
2126
  // Keep env-only redirect if the ambient proxy cannot start.
2108
2127
  }
2109
- return { instance: status, env: { ...process.env, ...status.env, ...proxyEnv } };
2128
+ return { instance: status, env: { ...admittedEnv(stripEnvOf(status.env)), ...status.env, ...proxyEnv } };
2110
2129
  }
2111
2130
 
2112
2131
  export function runWithWorldEnv(name: string, command: string[], root = process.cwd(), options: { cwd?: string } = {}): number {
package/src/schema.ts CHANGED
@@ -207,6 +207,11 @@ export type WorldConfig = {
207
207
  * `isolation` option overrides it per boot. */
208
208
  isolation?: WorldIsolation;
209
209
  env?: Record<string, string>;
210
+ /** Variables of the process that runs the world which never enter it — a name, or a prefix ending in `*`
211
+ * (`HERMES_*`). The world seals what leaves over the wire; this seals what comes in through the environment:
212
+ * whatever steered the outer process (an agent's worker pinning its board and run) must not steer what runs
213
+ * inside, whose state is the world's own. Applied to every service the world starts and to `env`/`attach`. */
214
+ stripEnv?: string[];
210
215
  services: WorldServiceConfig[];
211
216
  share?: WorldShareConfig;
212
217
  actors?: Record<string, unknown>;
@@ -431,6 +436,9 @@ export function assertWorldConfig(value: unknown, path: string): WorldConfig {
431
436
  }
432
437
  }
433
438
  }
439
+ if (config.stripEnv !== undefined && (!Array.isArray(config.stripEnv) || config.stripEnv.some((s) => typeof s !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*\*?$/.test(s)))) {
440
+ throw new Error(`World config stripEnv must be a list of variable names or prefixes ending in * in ${path}`);
441
+ }
434
442
  const ids = new Set<string>();
435
443
  for (const service of config.services as Partial<WorldServiceConfig>[]) {
436
444
  if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);