|
| 1 | +import { describe, it, expect, afterAll } from "vitest"; |
| 2 | +import * as fs from "node:fs"; |
| 3 | +import * as os from "node:os"; |
| 4 | +import * as path from "node:path"; |
| 5 | +import { fileURLToPath } from "node:url"; |
| 6 | +import { spawnSync } from "node:child_process"; |
| 7 | + |
| 8 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 9 | +const nodeBin = process.execPath; |
| 10 | +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); |
| 11 | +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-rg-")); |
| 12 | +const bgPids: number[] = []; |
| 13 | +afterAll(() => { |
| 14 | + for (const pid of bgPids) { try { process.kill(pid, "SIGKILL"); } catch {} } |
| 15 | + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); |
| 16 | +}); |
| 17 | + |
| 18 | +// PTY_SESSION set => restart takes its "already inside a session, not attaching" |
| 19 | +// branch and returns instead of hanging on a non-TTY attach. |
| 20 | +function runCli(dir: string, args: string[], timeout = 15000) { |
| 21 | + return spawnSync(nodeBin, [cliPath, ...args], { |
| 22 | + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1", PTY_SESSION: "outer" }, |
| 23 | + encoding: "utf8", timeout, |
| 24 | + }); |
| 25 | +} |
| 26 | + |
| 27 | +function createSession(dir: string, name: string, extra: string[], cmd: string[]): void { |
| 28 | + const r = runCli(dir, ["run", "-d", "--id", name, ...extra, "--", ...cmd]); |
| 29 | + expect(r.status).toBe(0); |
| 30 | + try { |
| 31 | + bgPids.push(Number(fs.readFileSync(path.join(dir, `${name}.pid`), "utf8").trim())); |
| 32 | + } catch {} |
| 33 | +} |
| 34 | + |
| 35 | +describe("pty restart guardrail for stateful agent sessions", () => { |
| 36 | + it("refuses to restart a role=agent session (exit nonzero, points at convoy)", () => { |
| 37 | + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); |
| 38 | + createSession(dir, "ag", ["--tag", "role=agent"], ["sleep", "300"]); |
| 39 | + |
| 40 | + const r = runCli(dir, ["restart", "-y", "ag"]); |
| 41 | + expect(r.status).not.toBe(0); |
| 42 | + expect(r.stderr).toMatch(/stateful agent/); |
| 43 | + expect(r.stderr).toMatch(/role=agent/); |
| 44 | + expect(r.stderr).toMatch(/--force/); |
| 45 | + expect(r.stderr).toMatch(/convoy/); |
| 46 | + }, 20000); |
| 47 | + |
| 48 | + it("refuses to restart a `claude --resume` command session", () => { |
| 49 | + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); |
| 50 | + // No role tag — detection is purely on the stored argv. |
| 51 | + createSession(dir, "cr", ["--no-display-name"], ["claude", "--resume", "ABC-123"]); |
| 52 | + |
| 53 | + const r = runCli(dir, ["restart", "-y", "cr"]); |
| 54 | + expect(r.status).not.toBe(0); |
| 55 | + expect(r.stderr).toMatch(/stateful agent/); |
| 56 | + expect(r.stderr).toMatch(/claude --resume/); |
| 57 | + }, 20000); |
| 58 | + |
| 59 | + it("does NOT block a normal session (no agent tag, no claude --resume)", () => { |
| 60 | + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); |
| 61 | + createSession(dir, "plain", [], ["sleep", "300"]); |
| 62 | + |
| 63 | + const r = runCli(dir, ["restart", "-y", "plain"]); |
| 64 | + expect(r.status).toBe(0); |
| 65 | + expect(r.stdout).toContain("restarted"); |
| 66 | + expect(r.stderr).not.toMatch(/stateful agent/); |
| 67 | + }, 20000); |
| 68 | + |
| 69 | + it("--force overrides the guardrail (restarts anyway)", () => { |
| 70 | + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); |
| 71 | + createSession(dir, "ag2", ["--tag", "role=agent"], ["sleep", "300"]); |
| 72 | + |
| 73 | + // --force bypasses the guard AND the nesting guard, so it proceeds to |
| 74 | + // attach and would hang in this non-TTY test — a short timeout is fine; we |
| 75 | + // only assert it got PAST the guard ("restarted" printed, no refusal). |
| 76 | + const r = runCli(dir, ["restart", "-y", "--force", "ag2"], 4000); |
| 77 | + expect(r.stderr).not.toMatch(/stateful agent/); |
| 78 | + expect(r.stdout).toContain("restarted"); |
| 79 | + }, 20000); |
| 80 | +}); |
0 commit comments