From 7a6b0a5f01dc3d483f179cef90696c419b8bd844 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Wed, 19 Aug 2026 23:24:11 -0400 Subject: [PATCH] Refuse to replace a live agent; clean a stale socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentDaemon.start() guards both cases and neither was tested. Without the live check the second daemon deletes the socket and listens in its place — the first keeps running, holding unlocked seeds, with an expiry sweep nobody can reach and an audit log nobody is writing to. Silently orphaning a process that holds seeds is worse than failing to start, which is what makes it a refusal rather than a convenience. The other half matters too: a crashed agent leaves its socket behind, and treating that as "already running" would make the agent unstartable until someone deleted a file by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- js/packages/cli/test/agentSecurity.test.ts | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/js/packages/cli/test/agentSecurity.test.ts b/js/packages/cli/test/agentSecurity.test.ts index 1614775a..861311fc 100644 --- a/js/packages/cli/test/agentSecurity.test.ts +++ b/js/packages/cli/test/agentSecurity.test.ts @@ -662,6 +662,47 @@ describe("token-issue validates before it mints", () => { }); }); +describe("starting a daemon over an existing socket", () => { + it("refuses to replace a LIVE agent", async () => { + // Without this check the second daemon deletes the socket and listens in + // its place. The first keeps running — holding unlocked seeds, with an + // expiry sweep nobody can reach and an audit log nobody is writing to. + // Silently orphaning a process that holds seeds is worse than failing to + // start, which is what makes this a refusal rather than a convenience. + const { d, sock } = await daemonAt(() => FROZEN); + try { + const second = new AgentDaemon(sock, 900, join(sock, ".."), () => FROZEN); + await expect(second.start()).rejects.toThrow(/already running/); + + // The original is still the one serving that socket. + expect(await AgentClient.ping(sock)).toBe(true); + const client = new AgentClient(sock); + await client.put(FINGERPRINT, MNEMONIC, 900); + expect(await client.ownerMnemonic(FINGERPRINT)).toBe(MNEMONIC); + } finally { + await d.stop(); + } + }); + + it("cleans up a stale socket file rather than refusing forever", async () => { + // The other half: a crashed agent leaves its socket behind, and treating + // that as "already running" would make the agent unstartable until + // someone deleted a file by hand. + const dir = await mkdtemp(join(tmpdir(), "seedpass-stale-")); + const sock = join(dir, "agent.sock"); + await writeFile(sock, "not a live socket"); + expect(await AgentClient.ping(sock)).toBe(false); + + const d = new AgentDaemon(sock, 900, dir, () => FROZEN); + await d.start(); + try { + expect(await AgentClient.ping(sock)).toBe(true); + } finally { + await d.stop(); + } + }); +}); + describe("put refuses a half-supplied identity", () => { // `put` is the only door a seed comes through, and everything after it // trusts that both halves arrived. Mutation testing turned the `||` in its