Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<string, string> }; ding: { env: Record<string, string> } };
};
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 {
Expand Down
21 changes: 14 additions & 7 deletions src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root>/smalltalk (the bus), PTY_ROOT is <root>/pty
// The ding SERVICE is a per-network choice, recorded in <net>/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 <net>/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);
Expand All @@ -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<string, unknown> = {
prefix: harnessId,
sessions: {
Expand All @@ -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 },
},
}
: {}),
Expand Down
17 changes: 17 additions & 0 deletions src/network-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ describe("network-config (<net>/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
Expand Down
14 changes: 14 additions & 0 deletions src/network-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,25 @@ 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<string, string>;
}

/** True iff `v` is a valid `DingService` spelling. */
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<string, string> {
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: `<dir>/convoy.toml`. */
export function networkConfigPath(dir: string): string {
return join(dir, "convoy.toml");
Expand All @@ -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;
Expand All @@ -62,5 +75,6 @@ export function writeNetworkConfig(dir: string, config: NetworkConfig): void {
const doc: Record<string, unknown> = { 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));
}
Loading