Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.

Commit 4c586a4

Browse files
myobieclaude
andauthored
convoy: NetworkConfig.env — a network-wide agent env map in convoy.toml (#104)
Adds an `env` field to <net>/convoy.toml: a string->string map merged into EVERY agent's derived pty.toml session env at render — BOTH the harness and the ding session — UNDER the derived wiring (ST_AGENT/ST_ROOT/PTY_ROOT always win, so a network key can't repoint the agent at another bus). A per-agent `env` still overrides a network default. Unset -> no network env. This is the fleet-wide runtime-knob seam: e.g. PTY_REAP_ON_EXIT=false to preserve finished sessions network-wide (the pty daemon reads it from its OWN env, so it must go on both sessions). Pairs with the ding selector (#99) so ONE re-render bakes both ding=rust and PTY_REAP_ON_EXIT=false. - network-config.ts: env field + isEnvMap validation (a non-string-map is dropped whole, never a half-valid launch env), read + write. - launch.ts: precedence networkEnv < spec.env < derived wiring, on both sessions. - +2 tests: round-trip/validation; both-sessions + derived-wins + per-agent-override. Claude-Session: https://claude.ai/code/session_01MCzqQKSpPiNX2ketyubByS Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a845a27 commit 4c586a4

4 files changed

Lines changed: 74 additions & 7 deletions

File tree

src/launch.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { bootPrompt, dingCommand, discoverSmalltalkDir, harnessCommand, regenera
77
import type { AgentSpec } from "./agent-spec.ts";
88
import { stRootOf } from "./paths.ts";
99
import { writeNetworkConfig } from "./network-config.ts";
10+
import { parse as tomlParse } from "smol-toml";
1011

1112
describe("native launch command builders (cold-start boot-prompt)", () => {
1213
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)", () => {
152153
}
153154
});
154155

156+
it("network env is merged into BOTH sessions, UNDER the derived wiring; per-agent env overrides it", () => {
157+
const net = mkdtempSync(join(tmpdir(), "convoy-netenv-"));
158+
const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-netenv-"));
159+
try {
160+
// a fleet-wide knob (PTY_REAP_ON_EXIT) + a HIJACK attempt on ST_AGENT + a key the agent overrides.
161+
writeNetworkConfig(net, { name: "ournet", env: { PTY_REAP_ON_EXIT: "false", ST_AGENT: "hijacked", SHARED: "net" } });
162+
writePtyToml(dir, spec({ networkRoot: net, env: { SHARED: "agent-wins" } }));
163+
const doc = tomlParse(readFileSync(join(dir, ".convoy", "pty.toml"), "utf8")) as {
164+
sessions: { claude: { env: Record<string, string> }; ding: { env: Record<string, string> } };
165+
};
166+
const harness = doc.sessions.claude.env;
167+
const ding = doc.sessions.ding.env;
168+
169+
// the fleet knob lands on BOTH sessions — the ding daemon reads PTY_REAP_ON_EXIT from its OWN env.
170+
expect(harness["PTY_REAP_ON_EXIT"]).toBe("false");
171+
expect(ding["PTY_REAP_ON_EXIT"]).toBe("false");
172+
// the derived wiring ALWAYS wins — a network env can't repoint ST_AGENT.
173+
expect(harness["ST_AGENT"]).toBe("silber.convoy-claude");
174+
expect(ding["ST_AGENT"]).toBe("silber.convoy-claude");
175+
// a per-agent env key overrides the network default (harness); the ding has no per-agent env → network value.
176+
expect(harness["SHARED"]).toBe("agent-wins");
177+
expect(ding["SHARED"]).toBe("net");
178+
} finally {
179+
rmSync(net, { recursive: true, force: true });
180+
rmSync(dir, { recursive: true, force: true });
181+
}
182+
});
183+
155184
it("--config-dir sets CLAUDE_CONFIG_DIR on the HARNESS session env only, not the ding sidecar", () => {
156185
const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-cfg-"));
157186
try {

src/launch.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,12 @@ export function provisionContext(memberDir: string, identity: string): string |
186186
export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void {
187187
const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. silber.convoy-claude
188188
const root = spec.networkRoot; // the network DIR; ST_ROOT is <root>/smalltalk (the bus), PTY_ROOT is <root>/pty
189-
// The ding SERVICE is a per-network choice, recorded in <net>/convoy.toml (unset → node `st ding`). Read
190-
// it here so the sidecar command baked into the pty.toml is the network's chosen ding (node or rust).
191-
const dingService = root ? readNetworkConfig(root)?.ding : undefined;
189+
// Per-network config from <net>/convoy.toml (unset fields → defaults): the ding SERVICE (node `st ding`
190+
// vs rust `ding`) baked into the sidecar command, and a network-wide agent ENV merged into every session
191+
// below — the fleet-wide knob seam (e.g. PTY_REAP_ON_EXIT), applied UNDER the derived wiring.
192+
const netCfg = root ? readNetworkConfig(root) : null;
193+
const dingService = netCfg?.ding;
194+
const networkEnv = netCfg?.env ?? {};
192195
const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix)
193196
const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding
194197
const permanent = specPermanent(spec);
@@ -210,11 +213,13 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st
210213
// from the harness table: CLAUDE_CONFIG_DIR for claude, CODEX_HOME for codex. Before, this was
211214
// hardcoded to CLAUDE_CONFIG_DIR, so `--config-dir` on a codex session set a variable codex does not
212215
// read — the flag reported success and selected nothing.
213-
// Spec `env` first, derived wiring LAST: ST_AGENT/ST_ROOT/PTY_ROOT are correct-by-construction (AC-1)
214-
// and a hand-written env key must never be able to repoint the agent at another bus.
216+
// Precedence, lowest first: NETWORK env (fleet default) < spec `env` (per-agent) < derived wiring LAST.
217+
// ST_AGENT/ST_ROOT/PTY_ROOT are correct-by-construction (AC-1) and always win — neither a network nor a
218+
// hand-written per-agent key can repoint the agent at another bus; per-agent env still overrides a network
219+
// default.
215220
const specEnv = spec.env ?? {};
216221
const configEnv = harnessDescriptor(spec.harness).configEnv;
217-
const harnessEnv = { ...specEnv, ...env, ...(spec.configDir && configEnv ? { [configEnv]: spec.configDir } : {}) };
222+
const harnessEnv = { ...networkEnv, ...specEnv, ...env, ...(spec.configDir && configEnv ? { [configEnv]: spec.configDir } : {}) };
218223
const doc: Record<string, unknown> = {
219224
prefix: harnessId,
220225
sessions: {
@@ -230,7 +235,9 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st
230235
id: dingId,
231236
command: dingCommand(busId, harnessId, root ? stRootOf(root) : null, dingService),
232237
tags: { role: "ding", ...(permanent ? { strategy: "permanent" } : {}), ...stTag },
233-
env,
238+
// The ding daemon reads PTY_REAP_ON_EXIT from its OWN env, so the network env goes on the ding
239+
// session too (under the derived wiring) — else a preserved-fleet setting would skip the sidecar.
240+
env: { ...networkEnv, ...env },
234241
},
235242
}
236243
: {}),

src/network-config.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ describe("network-config (<net>/convoy.toml)", () => {
4949
expect(readNetworkConfig(d)).toEqual({ name: "x" });
5050
});
5151

52+
it("write + read round-trips a network env map; a non-string-map env is dropped whole", () => {
53+
const d = tmp();
54+
// unset / empty → absent (callers apply no network env).
55+
writeNetworkConfig(d, { name: "default" });
56+
expect(readNetworkConfig(d)).toEqual({ name: "default" });
57+
writeNetworkConfig(d, { name: "default", env: {} });
58+
expect(readNetworkConfig(d)).toEqual({ name: "default" });
59+
60+
// a string→string map round-trips (the fleet-wide knob shape).
61+
writeNetworkConfig(d, { name: "ournet", env: { PTY_REAP_ON_EXIT: "false" } });
62+
expect(readNetworkConfig(d)).toEqual({ name: "ournet", env: { PTY_REAP_ON_EXIT: "false" } });
63+
64+
// a non-string value makes the map invalid → the whole env is dropped (never a half-valid launch env).
65+
writeFileSync(networkConfigPath(d), 'name = "x"\n[env]\nGOOD = "1"\nBAD = 2\n');
66+
expect(readNetworkConfig(d)).toEqual({ name: "x" });
67+
});
68+
5269
it("read is null when the file is missing or nameless", () => {
5370
const d = tmp();
5471
expect(readNetworkConfig(d)).toBeNull(); // no file yet

src/network-config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,25 @@ export interface NetworkConfig {
2525
/** The ding sidecar this network runs (see `DingService`). A per-NETWORK choice, not per-agent: the
2626
* ding binary is a runtime dependency of the box hosting the net. Unset → "node" (`st ding`). */
2727
ding?: DingService;
28+
/** A network-wide agent env map — merged into EVERY agent's derived session env at render (both the
29+
* harness and the ding session), UNDER the derived wiring so it can never repoint ST_AGENT/ST_ROOT/
30+
* PTY_ROOT; a per-agent `env` still overrides it. This is where a FLEET-WIDE runtime knob lives — e.g.
31+
* `PTY_REAP_ON_EXIT = "false"` to preserve finished sessions network-wide (the pty daemon reads it from
32+
* its own env). Optional; unset → no network env. */
33+
env?: Record<string, string>;
2834
}
2935

3036
/** True iff `v` is a valid `DingService` spelling. */
3137
export function isDingService(v: unknown): v is DingService {
3238
return v === "node" || v === "rust";
3339
}
3440

41+
/** True iff `v` is a flat string→string map — the shape a network env must have (a mis-typed value, e.g.
42+
* a number or nested table, drops the whole env rather than launching agents with a half-valid env). */
43+
export function isEnvMap(v: unknown): v is Record<string, string> {
44+
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
45+
}
46+
3547
/** The config file location for a network dir: `<dir>/convoy.toml`. */
3648
export function networkConfigPath(dir: string): string {
3749
return join(dir, "convoy.toml");
@@ -51,6 +63,7 @@ export function readNetworkConfig(dir: string): NetworkConfig | null {
5163
name: doc.name,
5264
...(typeof doc.megarepo === "string" && doc.megarepo ? { megarepo: doc.megarepo } : {}),
5365
...(isDingService(doc.ding) ? { ding: doc.ding } : {}),
66+
...(isEnvMap(doc.env) && Object.keys(doc.env).length > 0 ? { env: doc.env } : {}),
5467
};
5568
} catch {
5669
return null;
@@ -62,5 +75,6 @@ export function writeNetworkConfig(dir: string, config: NetworkConfig): void {
6275
const doc: Record<string, unknown> = { name: config.name };
6376
if (config.megarepo) doc["megarepo"] = config.megarepo;
6477
if (config.ding) doc["ding"] = config.ding;
78+
if (config.env && Object.keys(config.env).length > 0) doc["env"] = config.env;
6579
writeFileSync(networkConfigPath(dir), tomlStringify(doc));
6680
}

0 commit comments

Comments
 (0)