@volter/twin-world 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { fileURLToPath } from 'node:url';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
5
  import { tmpdir } from 'node:os';
@@ -588,7 +589,8 @@ async function main(): Promise<void> {
588
589
  caFile = join(mkdtempSync(join(tmpdir(), 'volter-attach-')), 'ca.pem');
589
590
  writeFileSync(caFile, manifest.ca);
590
591
  }
591
- const injectPath = resolvePath(import.meta.dir, '../../control-plane/inject.cjs');
592
+ // The injector ships with @volter/twin (installed or workspace-linked); the sibling path is the bare checkout's.
593
+ const injectPath = (() => { try { return fileURLToPath(import.meta.resolve('@volter/twin/inject')); } catch { return resolvePath(import.meta.dir, '../../control-plane/inject.cjs'); } })();
592
594
  const env = remoteAttachEnv(manifest, { injectPath, ...(caFile === undefined ? {} : { caFile }) });
593
595
  const result = spawnSync(command[0]!, command.slice(1), { env: { ...process.env, ...env }, stdio: 'inherit' });
594
596
  process.exit(result.status ?? 1);
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}`);