diff --git a/src/down-self-sever.test.ts b/src/down-self-sever.test.ts new file mode 100644 index 0000000..c81c0a7 --- /dev/null +++ b/src/down-self-sever.test.ts @@ -0,0 +1,126 @@ +// SELF-SEVER GUARD (Nathan mandate, convoy incident 2026-07-22) — `convoy down` typed from INSIDE a +// session it would kill severs the caller mid-command. An agent tearing down the network it lives in is +// the anti-pattern that can turn a restart into a fleet-wide outage. The guard: refuse unless --force when +// the caller's identity (ST_AGENT — convoy bakes it into every session's env) names a to-be-killed session. +// +// Two layers of proof: pure unit tests for the decision (`selfSeverSession`), and a process-level test that +// runs the real `bin/convoy down` from within a member's identity and asserts it refuses + leaves the agent +// alive, that --force overrides, and that a non-member identity is never falsely refused. Scoped to a +// throwaway XDG_STATE_HOME. Lives in the vitest gate (test.yml), not the hermetic nix flake check. + +import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { selfSeverSession } from "./up.ts"; +import { PtyHost, processAlive, spawnFromPtyFile, type SupervisedSession } from "./host.ts"; + +// ── pure ──────────────────────────────────────────────────────────────────────────────────────────── +const sess = (name: string, tags: Record): SupervisedSession => ({ name, cwd: null, command: "", args: [], status: "running" as never, pid: null, exitedAt: null, exitCode: null, tags }); +// Test resolver — reads a plain "busId" tag (the real `busIdOf` reads ST_AGENT out of the pty.toml). +const byTag = (s: SupervisedSession): string | null => s.tags["busId"] ?? null; + +describe("selfSeverSession — is `convoy down` about to kill the session it is run from?", () => { + const agents = [sess("s.alpha", { busId: "net.alpha" }), sess("s.beta", { busId: "net.beta" })]; + + it("ACCEPTANCE: the caller's ST_AGENT matches a to-be-killed session → returns THAT session", () => { + expect(selfSeverSession(agents, "net.beta", byTag)?.name).toBe("s.beta"); + }); + + it("a null/empty identity (a plain human terminal) is NEVER a self-sever", () => { + expect(selfSeverSession(agents, null, byTag)).toBeNull(); + expect(selfSeverSession(agents, undefined, byTag)).toBeNull(); + expect(selfSeverSession(agents, "", byTag)).toBeNull(); + }); + + it("an identity that matches NO killed session (running elsewhere) is not a self-sever", () => { + expect(selfSeverSession(agents, "net.gamma", byTag)).toBeNull(); + }); + + it("matches on the EXACT bus id, never a prefix/substring", () => { + expect(selfSeverSession(agents, "net.alph", byTag)).toBeNull(); + expect(selfSeverSession(agents, "net.alpha.ding", byTag)).toBeNull(); + }); +}); + +// ── process-level ───────────────────────────────────────────────────────────────────────────────────── +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const bin = join(repoRoot, "bin", "convoy"); + +let home = ""; +let net = ""; +const savedPtyRoot = process.env["PTY_ROOT"]; + +function baseEnv(): NodeJS.ProcessEnv { + return { ...process.env, XDG_STATE_HOME: home, ST_ROOT: "", PTY_ROOT: "" }; +} + +function freshNet(): void { + home = mkdtempSync(join(tmpdir(), "cvy-sever-")); + net = join(home, "convoy", "default"); + mkdirSync(join(net, "catalog"), { recursive: true }); + mkdirSync(join(net, "smalltalk"), { recursive: true }); +} + +/** Stand up one live agent whose bus id (its manifest ST_AGENT) is `id`, and return its pid. */ +async function spawnAgent(id: string): Promise { + const workspace = join(net, "agents", id); + mkdirSync(join(workspace, ".convoy"), { recursive: true }); + writeFileSync( + join(workspace, ".convoy", "pty.toml"), + `prefix = "${id}"\n\n[sessions.claude]\nid = "${id}"\ncommand = "exec sleep 2000000"\n\n[sessions.claude.tags]\nstrategy = "permanent"\nrole = "agent"\n\n[sessions.claude.env]\nST_AGENT = "${id}"\n`, + ); + const { spawned, failed } = await spawnFromPtyFile(workspace, net); + if (failed.length > 0 || spawned.length === 0) throw new Error(`spawn ${id} failed: ${JSON.stringify({ spawned, failed })}`); + const s = (await new PtyHost(net).sessions()).find((x) => x.name === id); + if (!s?.pid) throw new Error(`no pid for ${id}`); + return s.pid; +} + +/** Run the real `convoy down` with ST_AGENT set to `asIdentity`. */ +function down(asIdentity: string, ...extra: string[]): { status: number | null; stderr: string } { + const r = spawnSync(process.execPath, [bin, "down", net, ...extra], { env: { ...baseEnv(), ST_AGENT: asIdentity }, encoding: "utf8" }); + return { status: r.status, stderr: r.stderr }; +} + +afterEach(() => { + try { + spawnSync(process.execPath, [bin, "down", net, "--force"], { env: baseEnv() }); + } catch { + /* ignore */ + } + if (home) rmSync(home, { recursive: true, force: true }); + if (savedPtyRoot === undefined) delete process.env["PTY_ROOT"]; + else process.env["PTY_ROOT"] = savedPtyRoot; +}); + +describe("convoy down — the self-sever guard (real CLI)", () => { + it("REFUSES when run from INSIDE a session it would kill, and leaves that agent ALIVE", async () => { + freshNet(); + const pid = await spawnAgent("victim-alpha"); + const r = down("victim-alpha"); + expect(r.status, `should refuse (rc=1)\nstderr:\n${r.stderr}`).toBe(1); + expect(r.stderr).toMatch(/refusing|INSIDE/); + expect(processAlive(pid), "the caller's own agent must NOT be killed by a refused down").toBe(true); + }, 45000); + + it("--force overrides the guard — a deliberate 'take me down too' tears it down", async () => { + freshNet(); + const pid = await spawnAgent("victim-beta"); + const r = down("victim-beta", "--force"); + expect(r.status, `--force should succeed\nstderr:\n${r.stderr}`).toBe(0); + await new Promise((res) => setTimeout(res, 400)); + expect(processAlive(pid), "--force must actually tear the agent down").toBe(false); + }, 45000); + + it("does NOT refuse a caller whose identity is not a member (a plain terminal / another host)", async () => { + freshNet(); + const pid = await spawnAgent("victim-gamma"); + const r = down("some-outsider-not-in-this-net"); + expect(r.status, `a non-member caller should proceed (rc=0)\nstderr:\n${r.stderr}`).toBe(0); + await new Promise((res) => setTimeout(res, 400)); + expect(processAlive(pid), "an ordinary down from outside must still tear the agent down").toBe(false); + }, 45000); +}); diff --git a/src/up.ts b/src/up.ts index f73944c..b026940 100644 --- a/src/up.ts +++ b/src/up.ts @@ -667,6 +667,21 @@ export interface DownOptions { force?: boolean; } +/** Would `convoy down` kill the session the CALLER is running in? A `convoy down` typed at an agent's OWN + * REPL (or a script in its shell) tears down the network it lives in — severing the session mid-command, + * the exact anti-pattern behind the 2026-07-22 outage. The caller's identity is its `ST_AGENT` (convoy + * bakes it into every session's env); a self-sever is when that id matches a to-be-killed session's bus + * id. A null/empty `selfId` (a plain human terminal) is never a self-sever. Returns the matched session + * (so the warning can name it) or null. `resolve` is injectable for tests. Pure. */ +export function selfSeverSession( + agents: readonly SupervisedSession[], + selfId: string | null | undefined, + resolve: (s: SupervisedSession) => string | null = busIdOf, +): SupervisedSession | null { + if (!selfId) return null; + return agents.find((s) => resolve(s) === selfId) ?? null; +} + /** `convoy down []` — explicit teardown; the ONLY path that kills sessions. Mirror of the * Nomad model: stopping `convoy up` DETACHES (agents keep running), `convoy down` TEARS DOWN. Scope * is convoy's own agents (sessions spawned from a pty.toml — the `ptyfile.session` tag), so it never @@ -704,6 +719,21 @@ export async function down(opts: DownOptions): Promise { return 0; } + // SELF-SEVER GUARD — `convoy down` typed from INSIDE a session it would kill severs the caller + // mid-command (an agent tearing down the network it lives in — the shape that turned the 2026-07-22 + // restart into a fleet-wide outage). The caller's identity is ST_AGENT (convoy bakes it into every + // session's env); if it names a session in the kill list, refuse unless --force (a deliberate "take me + // down too"). The read-only dry-run above still previews from within — only the real teardown is gated. + const self = selfSeverSession(agents, process.env["ST_AGENT"]); + if (self !== null && opts.force !== true) { + process.stderr.write( + `convoy down: refusing — you are running INSIDE ${logicalId(self)} (${self.name}), a session this would kill.\n` + + `Tearing down your own network from within severs THIS session mid-command. Run \`convoy down\` from OUTSIDE the\n` + + `network (a plain terminal / another host), or \`convoy down --force\` to take yourself down too.\n`, + ); + return 1; + } + // Real teardown — a live `convoy up`/app host would RESPAWN gone permanent sessions (reconcile: // `if (!gone(s)) continue`), fighting the kill. Refuse unless --force, and point at the owner. const owner = lock.liveOwner();