Files
openrig/packages/cli/test/terminal-session-selection.test.ts
Nash Aschenbath d715c1013e feat(terminal): select Herdr sessions per request (#1076)
The daemon chose its Herdr session once, at startup, from its own environment, so someone running two rigs on one host couldn't keep their tiles apart: a per-command `HERDR_SOCKET_PATH` was ignored.

Now `rig terminal open <view> --provider herdr --session <name>` places a view in the named Herdr session on the daemon's host. Preview, status, desktop opening, workspace reuse and layout use the same selection, and each request has its own transport, so concurrent opens don't change another request's socket or the daemon's environment. Leaving out `--session` keeps the configured endpoint, and `--session default` selects it explicitly. A requested session that can't be used stops with an error instead of falling back to the default session.

Behaviour that changed, for direct HTTP requests: a rig-addressed open now checks the preview it carries, so an out-of-date preview stops the open and asks for a refresh, as the main terminal-open address already did. A `session` value is no longer ignored: an invalid name, a value that isn't text, or a provider that can't select a session (cmux) gets an error instead of opening in the old default place. The CLI also stops, rather than opening silently in the wrong place, when an older daemon can't confirm the selected session or when an in-Herdr window open would cross into another endpoint. When the caller's session is known, that error suggests the matching `--session`, named from the same socket the check used.

Limits: the selected session must already exist on the daemon's host. The tests use real sockets with synthetic Herdr peers; the Herdr desktop app wasn't exercised.

Thanks to @Aschenbath for the PR, and to @team-sudarshana for the issue and for checking the shape on their own host.

Cost: source +174/-69 (7 files), docs +105/-3 (4 files), tests +401/-6 (3 files). The two HTTP changes above were approved by the founder; two new error codes (`invalid_terminal_session`, `unsupported_terminal_session`); the rig-addressed open reuses the existing `preview_changed`.
2026-10-22 21:37:34 -07:00

190 lines
12 KiB
TypeScript

import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { terminalCommand, type TerminalDeps } from "../src/commands/terminal.js";
import type { WindowDeps } from "../src/terminal-window.js";
import { shellQuote } from "../src/cross-host-executor.js";
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
}));
function fixture(options: { legacy?: boolean; noHerdr?: boolean; statusMismatch?: boolean; dropOpenSession?: boolean; sessionSockets?: Record<string, string> } = {}) {
const pane = { seat: "worker@prod", label: "worker", paneCommand: "tmux attach -t 'worker@prod'" };
const get = vi.fn(async (url: string) => {
const requested = new URL(url, "http://localhost").searchParams.get("session") ?? "default";
const session = options.legacy ? undefined : options.statusMismatch && url.includes("status") ? "wrong-session" : requested;
const launch = { socketPath: options.sessionSockets?.[session ?? "legacy-default"] ?? `/fixture/${session ?? "legacy-default"}/herdr.sock`, ...(session !== undefined ? { session } : {}) };
return { status: 200, data: url.includes("preview")
? { session, planId: `plan-${requested}`, composed: { id: "prod", opened: [pane], pages: [[pane]], absent: [], degraded: [] }, status: { launch } }
: { session, providers: [{ name: "herdr", status: { launch }, liveness: { alive: true } }] } };
});
const post = vi.fn(async (_url: string, body: Record<string, unknown>) => ({ status: 200,
data: { provider: "herdr", ...(options.dropOpenSession ? {} : { session: body.session }), ok: true, opened: [pane.seat], absent: [], degraded: [], pages: 1 } }));
const exec = vi.fn(async (file: string, args: string[]) => {
if (file === "/usr/bin/osascript") return "window";
if (args.includes("--version")) {
if (options.noHerdr) throw new Error("herdr unavailable");
return "herdr 0.9.1";
}
if (file === "/bin/sh") {
if (options.noHerdr) throw new Error("herdr unavailable");
return "/fixture/bin/herdr";
}
if (file === "/usr/bin/env") return JSON.stringify({ result: { workspaces: [] } });
return "";
});
const windowDeps: WindowDeps = { platform: "darwin", env: { TERM_PROGRAM: "Apple_Terminal", HERDR_SESSION: "unrelated", HERDR_SOCKET_PATH: "/unrelated.sock" },
exists: () => false, exec, launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "fixture", herdrConfig: vi.fn(() => "/fixture/private.toml") };
const deps: TerminalDeps = { lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], windowDeps,
clientFactory: () => ({ baseUrl: "http://localhost:7433", get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]> };
return { get, post, exec, windowDeps,
run: (args: string[]) => new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", ...args], { from: "user" }) };
}
describe("terminal --session", () => {
let logs: string[];
let originalExit: typeof process.exitCode;
beforeEach(() => {
logs = []; originalExit = process.exitCode; process.exitCode = undefined;
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
});
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
it("previews and opens the selected session without a desktop for an explicit provider", async () => {
const f = fixture();
await f.run(["open", "prod", "--provider", "herdr", "--session", "prod", "--json"]);
expect(f.get).toHaveBeenCalledExactlyOnceWith("/api/terminal/preview?view=prod&provider=herdr&session=prod");
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "prod", provider: "herdr", session: "prod", expectedPlan: "plan-prod" }, { timeoutMs: 45_000 });
expect(f.exec).not.toHaveBeenCalled();
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: true, session: "prod" });
});
it.each([false, true])("an older daemon cannot silently redirect a named open (window=%s)", async window => {
const f = fixture({ legacy: true });
await f.run(["open", "prod", "--provider", "herdr", "--session", "prod", "--json", ...(window ? ["--window"] : [])]);
expect(process.exitCode).toBe(1); expect(JSON.parse(logs[0]!).error).toContain("did not confirm");
expect(f.post).not.toHaveBeenCalled();
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript")).toBe(false);
});
it("a desktop request carries the same session through preview, readiness, inventory and open", async () => {
const f = fixture();
await f.run(["open", "prod", "--session", "prod", "--json"]);
expect(process.exitCode).toBeUndefined();
expect(f.get.mock.calls.map(([url]) => url)).toEqual(["/api/terminal/preview?view=prod&provider=herdr&session=prod", "/api/terminal/status?provider=herdr&session=prod"]);
const desktop = f.exec.mock.calls.find(([file]) => file === "/usr/bin/osascript");
expect(desktop?.[1].join(" ")).toContain("HERDR_SOCKET_PATH='/fixture/prod/herdr.sock'");
for (const [, args] of f.exec.mock.calls.filter(([file]) => file === "/usr/bin/env")) {
expect(args).toContain("HERDR_SOCKET_PATH=/fixture/prod/herdr.sock"); expect(args).not.toContain("/unrelated.sock");
}
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "prod", provider: "herdr", session: "prod", expectedPlan: "plan-prod" }, { timeoutMs: 45_000 });
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: true, session: "prod", window: { app: "Terminal" } });
});
it("does not fall back to tmux when Herdr is missing for an explicit session", async () => {
const f = fixture({ noHerdr: true });
await f.run(["open", "prod", "--session", "prod", "--json"]);
expect(process.exitCode).toBe(1); expect(f.post).not.toHaveBeenCalled(); expect(f.get).not.toHaveBeenCalled();
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript")).toBe(false);
});
it.each([
{ env: { HERDR_SESSION: "work", HERDR_SOCKET_PATH: "/fixture/.config/herdr/sessions/other/herdr.sock" }, session: "other" },
{ env: { HERDR_SESSION: "work", HERDR_SOCKET_PATH: "/fixture/.config/herdr/herdr.sock" }, session: "default" },
{ env: { HERDR_SESSION: "other", HERDR_SOCKET_PATH: "/fixture/.config/herdr/sessions/other/../work/herdr.sock" }, session: "work" },
{ env: { HERDR_SESSION: "work" }, session: "work" },
{ env: { HERDR_SOCKET_PATH: "/fixture/.config/herdr/sessions/work/herdr.sock" }, session: "work" },
{ env: { HERDR_SOCKET_PATH: "/fixture/.config/herdr/herdr.sock" }, session: "default" },
{ env: {}, session: "default" },
{ env: { HERDR_SESSION: "team's work" }, session: "team's work" },
])("caller-session guidance names the current session on an endpoint mismatch: $env", async ({ env, session }) => {
const f = fixture();
f.windowDeps.env = { HOME: "/fixture", TERM_PROGRAM: "herdr", ...env };
const originalEnv = { ...f.windowDeps.env };
await f.run(["open", "prod", "--json"]);
const result = JSON.parse(logs[0]!);
expect(result).toMatchObject({ ok: false, windowAttempted: false, opened: [] });
expect(result.error).toContain(`You're in Herdr session ${JSON.stringify(session)}.`);
expect(result.error).toContain(`rig terminal open 'prod' --session ${shellQuote(session)}`);
expect(result.error).toContain("Use --provider herdr without --window to place a view in the selected session.");
expect(process.exitCode).toBe(1);
expect(f.post).not.toHaveBeenCalled();
expect(f.windowDeps.launch).not.toHaveBeenCalled();
expect(f.windowDeps.herdrConfig).not.toHaveBeenCalled();
expect(f.windowDeps.env).toEqual(originalEnv);
});
it.each([
{ socket: "/custom/herdr.sock" },
{ socket: "/custom/herdr.sock", session: "work" },
{ socket: "/other-home/.config/herdr/sessions/work/herdr.sock" },
{ socket: "/other-home/.config/herdr/sessions/work/herdr.sock", session: "work" },
])("caller-session guidance keeps a custom socket unnamed: $socket ($session)", async ({ socket, session }) => {
const f = fixture();
f.windowDeps.env = { HOME: "/fixture", TERM_PROGRAM: "herdr", HERDR_SOCKET_PATH: socket, HERDR_SESSION: session };
await f.run(["open", "prod", "--json"]);
const result = JSON.parse(logs[0]!);
expect(result).toMatchObject({ ok: false, windowAttempted: false, opened: [] });
expect(result.error).toContain("Use --provider herdr without --window to place a view in the selected session.");
expect(result.error).not.toContain("--session");
expect(f.post).not.toHaveBeenCalled();
expect(f.windowDeps.launch).not.toHaveBeenCalled();
});
it("the suggested session can be retried when the caller's session variable disagrees with its socket", async () => {
const socket = "/fixture/.config/herdr/sessions/work/herdr.sock";
const f = fixture({ sessionSockets: { work: socket } });
f.windowDeps.env = { HOME: "/fixture", TERM_PROGRAM: "herdr", HERDR_SESSION: "other", HERDR_SOCKET_PATH: socket };
const originalEnv = { ...f.windowDeps.env };
await f.run(["open", "prod", "--json"]);
expect(JSON.parse(logs[0]!).error).toContain("rig terminal open 'prod' --session 'work'");
expect(f.post).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
logs.length = 0; process.exitCode = undefined;
await f.run(["open", "prod", "--session", "work", "--json"]);
expect(process.exitCode).toBeUndefined();
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: true, session: "work", opened: ["worker@prod"] });
expect(f.post).toHaveBeenCalledExactlyOnceWith("/api/terminal/open", { view: "prod", provider: "herdr", session: "work", expectedPlan: "plan-work" }, { timeoutMs: 45_000 });
for (const [, args] of f.exec.mock.calls.filter(([file]) => file === "/usr/bin/env")) {
expect(args).toContain(`HERDR_SOCKET_PATH=${socket}`);
}
expect(f.exec.mock.calls.some(([file]) => file === "/usr/bin/osascript")).toBe(false);
expect(f.windowDeps.launch).not.toHaveBeenCalled();
expect(f.windowDeps.env).toEqual(originalEnv);
});
it("a changed endpoint after window launch is unconfirmed and never drives another session", async () => {
const f = fixture({ statusMismatch: true });
await f.run(["open", "prod", "--session", "prod", "--json"]);
expect(process.exitCode).toBe(1); expect(f.post).not.toHaveBeenCalled();
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: false, windowAttempted: true, window: { app: "Terminal" } });
expect(JSON.parse(logs[0]!).error).toContain("outcome could not be confirmed");
expect(f.exec.mock.calls.filter(([file]) => file === "/usr/bin/env")).toEqual([]);
});
it("rejects a preview for another session before a headless open", async () => {
const f = fixture();
await f.run(["open", "prod", "--provider", "herdr", "--session", "prod", "--expected-plan", "plan-other", "--json"]);
expect(process.exitCode).toBe(1); expect(f.post).not.toHaveBeenCalled(); expect(JSON.parse(logs[0]!).code).toBe("preview_changed");
});
it("does not report success when an open response omits the selected session", async () => {
const f = fixture({ dropOpenSession: true });
await f.run(["open", "prod", "--provider", "herdr", "--session", "prod", "--json"]);
expect(f.post).toHaveBeenCalledTimes(1); expect(process.exitCode).toBe(1);
expect(JSON.parse(logs[0]!)).toMatchObject({ ok: false, code: "terminal_session_unconfirmed" });
});
it("status selects a named session and rejects an older daemon's unscoped reply", async () => {
const f = fixture(); await f.run(["status", "--session", "prod", "--json"]);
expect(f.get).toHaveBeenCalledExactlyOnceWith("/api/terminal/status?session=prod");
expect(JSON.parse(logs[0]!)).toMatchObject({ session: "prod" });
logs.length = 0; const old = fixture({ legacy: true }); await old.run(["status", "--session", "prod", "--json"]);
expect(process.exitCode).toBe(1); expect(JSON.parse(logs[0]!).code).toBe("terminal_session_unconfirmed");
});
});