diff --git a/src/launch.test.ts b/src/launch.test.ts index 571d16e..4eba497 100644 --- a/src/launch.test.ts +++ b/src/launch.test.ts @@ -7,6 +7,7 @@ import { bootPrompt, dingCommand, discoverSmalltalkDir, harnessCommand, regenera import type { AgentSpec } from "./agent-spec.ts"; import { stRootOf } from "./paths.ts"; import { writeNetworkConfig } from "./network-config.ts"; +import { parse as tomlParse } from "smol-toml"; describe("native launch command builders (cold-start boot-prompt)", () => { it("harnessCommand claude: exec claude with the mode + boot prompt, NO poker, NO --resume", () => { @@ -152,6 +153,34 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { } }); + it("network env is merged into BOTH sessions, UNDER the derived wiring; per-agent env overrides it", () => { + const net = mkdtempSync(join(tmpdir(), "convoy-netenv-")); + const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-netenv-")); + try { + // a fleet-wide knob (PTY_REAP_ON_EXIT) + a HIJACK attempt on ST_AGENT + a key the agent overrides. + writeNetworkConfig(net, { name: "ournet", env: { PTY_REAP_ON_EXIT: "false", ST_AGENT: "hijacked", SHARED: "net" } }); + writePtyToml(dir, spec({ networkRoot: net, env: { SHARED: "agent-wins" } })); + const doc = tomlParse(readFileSync(join(dir, ".convoy", "pty.toml"), "utf8")) as { + sessions: { claude: { env: Record }; ding: { env: Record } }; + }; + const harness = doc.sessions.claude.env; + const ding = doc.sessions.ding.env; + + // the fleet knob lands on BOTH sessions — the ding daemon reads PTY_REAP_ON_EXIT from its OWN env. + expect(harness["PTY_REAP_ON_EXIT"]).toBe("false"); + expect(ding["PTY_REAP_ON_EXIT"]).toBe("false"); + // the derived wiring ALWAYS wins — a network env can't repoint ST_AGENT. + expect(harness["ST_AGENT"]).toBe("silber.convoy-claude"); + expect(ding["ST_AGENT"]).toBe("silber.convoy-claude"); + // a per-agent env key overrides the network default (harness); the ding has no per-agent env → network value. + expect(harness["SHARED"]).toBe("agent-wins"); + expect(ding["SHARED"]).toBe("net"); + } finally { + rmSync(net, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); + } + }); + it("--config-dir sets CLAUDE_CONFIG_DIR on the HARNESS session env only, not the ding sidecar", () => { const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-cfg-")); try { diff --git a/src/launch.ts b/src/launch.ts index 08d0234..822abe4 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -186,9 +186,12 @@ export function provisionContext(memberDir: string, identity: string): string | export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void { const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. silber.convoy-claude const root = spec.networkRoot; // the network DIR; ST_ROOT is /smalltalk (the bus), PTY_ROOT is /pty - // The ding SERVICE is a per-network choice, recorded in /convoy.toml (unset → node `st ding`). Read - // it here so the sidecar command baked into the pty.toml is the network's chosen ding (node or rust). - const dingService = root ? readNetworkConfig(root)?.ding : undefined; + // Per-network config from /convoy.toml (unset fields → defaults): the ding SERVICE (node `st ding` + // vs rust `ding`) baked into the sidecar command, and a network-wide agent ENV merged into every session + // below — the fleet-wide knob seam (e.g. PTY_REAP_ON_EXIT), applied UNDER the derived wiring. + const netCfg = root ? readNetworkConfig(root) : null; + const dingService = netCfg?.ding; + const networkEnv = netCfg?.env ?? {}; const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix) const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding const permanent = specPermanent(spec); @@ -210,11 +213,13 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st // from the harness table: CLAUDE_CONFIG_DIR for claude, CODEX_HOME for codex. Before, this was // hardcoded to CLAUDE_CONFIG_DIR, so `--config-dir` on a codex session set a variable codex does not // read — the flag reported success and selected nothing. - // Spec `env` first, derived wiring LAST: ST_AGENT/ST_ROOT/PTY_ROOT are correct-by-construction (AC-1) - // and a hand-written env key must never be able to repoint the agent at another bus. + // Precedence, lowest first: NETWORK env (fleet default) < spec `env` (per-agent) < derived wiring LAST. + // ST_AGENT/ST_ROOT/PTY_ROOT are correct-by-construction (AC-1) and always win — neither a network nor a + // hand-written per-agent key can repoint the agent at another bus; per-agent env still overrides a network + // default. const specEnv = spec.env ?? {}; const configEnv = harnessDescriptor(spec.harness).configEnv; - const harnessEnv = { ...specEnv, ...env, ...(spec.configDir && configEnv ? { [configEnv]: spec.configDir } : {}) }; + const harnessEnv = { ...networkEnv, ...specEnv, ...env, ...(spec.configDir && configEnv ? { [configEnv]: spec.configDir } : {}) }; const doc: Record = { prefix: harnessId, sessions: { @@ -230,7 +235,9 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st id: dingId, command: dingCommand(busId, harnessId, root ? stRootOf(root) : null, dingService), tags: { role: "ding", ...(permanent ? { strategy: "permanent" } : {}), ...stTag }, - env, + // The ding daemon reads PTY_REAP_ON_EXIT from its OWN env, so the network env goes on the ding + // session too (under the derived wiring) — else a preserved-fleet setting would skip the sidecar. + env: { ...networkEnv, ...env }, }, } : {}), diff --git a/src/network-config.test.ts b/src/network-config.test.ts index 7fa23b6..fa143a3 100644 --- a/src/network-config.test.ts +++ b/src/network-config.test.ts @@ -49,6 +49,23 @@ describe("network-config (/convoy.toml)", () => { expect(readNetworkConfig(d)).toEqual({ name: "x" }); }); + it("write + read round-trips a network env map; a non-string-map env is dropped whole", () => { + const d = tmp(); + // unset / empty → absent (callers apply no network env). + writeNetworkConfig(d, { name: "default" }); + expect(readNetworkConfig(d)).toEqual({ name: "default" }); + writeNetworkConfig(d, { name: "default", env: {} }); + expect(readNetworkConfig(d)).toEqual({ name: "default" }); + + // a string→string map round-trips (the fleet-wide knob shape). + writeNetworkConfig(d, { name: "ournet", env: { PTY_REAP_ON_EXIT: "false" } }); + expect(readNetworkConfig(d)).toEqual({ name: "ournet", env: { PTY_REAP_ON_EXIT: "false" } }); + + // a non-string value makes the map invalid → the whole env is dropped (never a half-valid launch env). + writeFileSync(networkConfigPath(d), 'name = "x"\n[env]\nGOOD = "1"\nBAD = 2\n'); + expect(readNetworkConfig(d)).toEqual({ name: "x" }); + }); + it("read is null when the file is missing or nameless", () => { const d = tmp(); expect(readNetworkConfig(d)).toBeNull(); // no file yet diff --git a/src/network-config.ts b/src/network-config.ts index 42ad810..9b62834 100644 --- a/src/network-config.ts +++ b/src/network-config.ts @@ -25,6 +25,12 @@ export interface NetworkConfig { /** The ding sidecar this network runs (see `DingService`). A per-NETWORK choice, not per-agent: the * ding binary is a runtime dependency of the box hosting the net. Unset → "node" (`st ding`). */ ding?: DingService; + /** A network-wide agent env map — merged into EVERY agent's derived session env at render (both the + * harness and the ding session), UNDER the derived wiring so it can never repoint ST_AGENT/ST_ROOT/ + * PTY_ROOT; a per-agent `env` still overrides it. This is where a FLEET-WIDE runtime knob lives — e.g. + * `PTY_REAP_ON_EXIT = "false"` to preserve finished sessions network-wide (the pty daemon reads it from + * its own env). Optional; unset → no network env. */ + env?: Record; } /** True iff `v` is a valid `DingService` spelling. */ @@ -32,6 +38,12 @@ export function isDingService(v: unknown): v is DingService { return v === "node" || v === "rust"; } +/** True iff `v` is a flat string→string map — the shape a network env must have (a mis-typed value, e.g. + * a number or nested table, drops the whole env rather than launching agents with a half-valid env). */ +export function isEnvMap(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string"); +} + /** The config file location for a network dir: `/convoy.toml`. */ export function networkConfigPath(dir: string): string { return join(dir, "convoy.toml"); @@ -51,6 +63,7 @@ export function readNetworkConfig(dir: string): NetworkConfig | null { name: doc.name, ...(typeof doc.megarepo === "string" && doc.megarepo ? { megarepo: doc.megarepo } : {}), ...(isDingService(doc.ding) ? { ding: doc.ding } : {}), + ...(isEnvMap(doc.env) && Object.keys(doc.env).length > 0 ? { env: doc.env } : {}), }; } catch { return null; @@ -62,5 +75,6 @@ export function writeNetworkConfig(dir: string, config: NetworkConfig): void { const doc: Record = { name: config.name }; if (config.megarepo) doc["megarepo"] = config.megarepo; if (config.ding) doc["ding"] = config.ding; + if (config.env && Object.keys(config.env).length > 0) doc["env"] = config.env; writeFileSync(networkConfigPath(dir), tomlStringify(doc)); }