@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 +1 -1
- package/src/cli.ts +3 -1
- package/src/runtime.ts +24 -5
- package/src/schema.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-world",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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> = { ...
|
|
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: { ...
|
|
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}`);
|