Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Draft
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
126 changes: 126 additions & 0 deletions src/down-self-sever.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<number> {
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);
});
30 changes: 30 additions & 0 deletions src/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<network>]` — 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
Expand Down Expand Up @@ -704,6 +719,21 @@ export async function down(opts: DownOptions): Promise<number> {
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();
Expand Down
Loading