Skip to content
Merged
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
108 changes: 108 additions & 0 deletions commands/__tests__/gate-fork-check-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { buildForkCheckPayload, forkCheckHookOutput, forkDenyReason, FORK_CHECK_ALLOW } from "../gate.ts";

const agentEnv = {
RT_GATE_SUBJECT: "herd:acme-x/acme-1234-attorney",
HERDR_PANE_ID: "wKW:p2",
CLAUDE_CODE_SESSION_ID: "sess-env",
} as NodeJS.ProcessEnv;

describe("buildForkCheckPayload", () => {
test("sends the hook stdin's session and the env session, and the stdin cwd over the process cwd", () => {
const stdin = JSON.stringify({ session_id: "sess-hook", cwd: "/does/not/exist", hook_event_name: "PreToolUse" });
expect(buildForkCheckPayload(stdin, agentEnv, "/elsewhere")).toEqual({
subject: "herd:acme-x/acme-1234-attorney",
sessionIds: ["sess-hook", "sess-env"],
paneId: "wKW:p2",
worktrees: ["/does/not/exist"],
});
});

test("one session id when stdin and env agree", () => {
const stdin = JSON.stringify({ session_id: "sess-env" });
expect(buildForkCheckPayload(stdin, agentEnv, "/x")?.sessionIds).toEqual(["sess-env"]);
});

test("no stdin payload falls back to the env session and the process cwd", () => {
const p = buildForkCheckPayload("", agentEnv, "/does/not/exist");
expect(p?.sessionIds).toEqual(["sess-env"]);
expect(p?.worktrees).toEqual(["/does/not/exist"]);
});

test("no session anywhere leaves the field absent", () => {
const env = { RT_GATE_SUBJECT: "herd:x/y" } as NodeJS.ProcessEnv;
expect(buildForkCheckPayload("{}", env, "/x")?.sessionIds).toBeUndefined();
});

test("a symlinked cwd sends both the logical and the physical path", () => {
const base = realpathSync(mkdtempSync(join(tmpdir(), "fork-check-")));
try {
const real = join(base, "real");
const link = join(base, "link");
mkdirSync(real);
symlinkSync(real, link);
expect(buildForkCheckPayload(JSON.stringify({ cwd: link }), agentEnv, "/x")?.worktrees).toEqual([link, real]);
} finally {
rmSync(base, { recursive: true, force: true });
}
});

test("no RT_GATE_SUBJECT means no rt agent launch: no payload, nothing to ask", () => {
expect(buildForkCheckPayload("{}", { HERDR_PANE_ID: "wKW:p2" } as NodeJS.ProcessEnv, "/x")).toBeNull();
});
});

describe("forkCheckHookOutput", () => {
test("allow verdict: allow", () => {
expect(forkCheckHookOutput({ ok: true, data: { allow: true, match: "pane", gateId: "g1" } })).toEqual(FORK_CHECK_ALLOW);
});

test("daemon unreachable: allow", () => {
expect(forkCheckHookOutput({ ok: false, error: "rt daemon unreachable at /tmp/rt.sock: ECONNREFUSED" })).toEqual(FORK_CHECK_ALLOW);
});

test("a daemon that predates the verb: allow", () => {
expect(forkCheckHookOutput({ ok: false, error: "unknown command: gate:fork-check" })).toEqual(FORK_CHECK_ALLOW);
});

test("not an rt agent launch (no payload sent): allow", () => {
expect(forkCheckHookOutput(null)).toEqual(FORK_CHECK_ALLOW);
});

test("deny verdict: deny, naming the subject rt gate ask files under", () => {
const out = forkCheckHookOutput({ ok: true, data: { allow: false, subject: "run:20260923-185017-2020-15828" } });
expect(out).toEqual({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: forkDenyReason("run:20260923-185017-2020-15828"),
},
});
});
});

describe("forkDenyReason", () => {
const reason = forkDenyReason("run:r1");

test("sends the agent to rt gate ask first", () => {
expect(reason).toContain("rt gate ask --questions <json>");
});

test("a form presentation comes back to AskUserQuestion, which is then allowed", () => {
expect(reason).toContain("form: ask it here with AskUserQuestion, which this hook then allows");
expect(reason).toContain("rt gate answer <id> --answers <json> --by pane");
});

test("only a wait presentation backgrounds rt gate wait", () => {
expect(reason).toContain("wait: background `rt gate wait <id>` and end the turn");
expect(reason.indexOf("rt gate wait")).toBeGreaterThan(reason.indexOf("wait:"));
});

test("names the subject JSON-quoted, and omits the clause when unresolved", () => {
expect(forkDenyReason('run:"quoted"\tx')).toContain('This pane\'s gates file under "run:\\"quoted\\"\\tx".');
expect(forkDenyReason(undefined)).not.toContain("gates file under");
});
});
76 changes: 76 additions & 0 deletions commands/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* rt gate open --subject <s> --kind <k> --questions <json> [--meta <json>] [--agent <id>] [--pane <id>] [--nudge <json>]
* rt gate ask --questions <json> [--context <text>] [--kind <k>] [--subject <s>] [--json]
* rt gate fork-check # AskUserQuestion hook endpoint: stdin JSON in, hook decision out
* rt gate answer <id> --answers <json> --by <surface> [--session <id>] [--override]
* rt gate wait <id> [--timeout <duration>] # default: wait forever
* rt gate list [--open] [--subject-prefix <p>] [--kind <k>] [--limit <n>] [--cursor <n>]
Expand All @@ -15,10 +16,12 @@
* rt gate subscriptions [--session <addr>] [--live]
*/

import { realpathSync } from "fs";
import {
gateOpen as clientOpen,
gateAnswer as clientAnswer,
gateAsk as clientAsk,
gateForkCheck as clientForkCheck,
gateWait as clientWait,
gateList as clientList,
gatePark as clientPark,
Expand All @@ -29,6 +32,7 @@ import {
} from "../packages/rt-client/src/index.ts";
import type { Commands, GateRow, RtResponse } from "../packages/rt-client/src/index.ts";
import { parseDuration, nextWaitMs } from "./events.ts";
import { GATE_FORK_HOOK_TIMEOUT_SECONDS } from "../lib/agent-hooks.ts";

function fail(msg: string): never {
console.error(`rt gate: ${msg}`);
Expand Down Expand Up @@ -228,6 +232,78 @@ export async function gateAsk(args: string[]): Promise<void> {
console.log(JSON.stringify(gateAskOutput(data)));
}

// ─── fork-check ──────────────────────────────────────────────────────────────

const FORK_CHECK_TIMEOUT_MS = (GATE_FORK_HOOK_TIMEOUT_SECONDS * 1000) / 2;

export const FORK_CHECK_ALLOW = {
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
} as const;

/** Claude Code's PreToolUse stdin carries `session_id` and `cwd`; the
process cwd is the fallback for a caller that pipes none. Both session
ids ride along because `rt gate ask` stamps its gate from
CLAUDE_CODE_SESSION_ID, which need not equal the hook's `session_id`.
Null means this pane is not an `rt agent` launch (no RT_GATE_SUBJECT):
such a pane is allowed without asking the daemon. */
export function buildForkCheckPayload(
stdin: string,
env: NodeJS.ProcessEnv,
cwd: string,
): Commands["gate:fork-check"]["payload"] | null {
const subject = env.RT_GATE_SUBJECT;
if (!subject) return null;
let hook: { session_id?: unknown; cwd?: unknown } = {};
try {
const parsed: unknown = JSON.parse(stdin);
if (parsed && typeof parsed === "object") hook = parsed as typeof hook;
} catch { /* no payload: fall back to env and process cwd */ }

const payload: Commands["gate:fork-check"]["payload"] = { subject };
const sessionIds = [...new Set([hook.session_id, env.CLAUDE_CODE_SESSION_ID])]
.filter((s): s is string => typeof s === "string" && s.length > 0);
if (sessionIds.length > 0) payload.sessionIds = sessionIds;
if (env.HERDR_PANE_ID) payload.paneId = env.HERDR_PANE_ID;
// Both spellings: a run records whichever path its pipeline saw, and a
// symlinked tree differs between the logical and the physical one.
const dir = typeof hook.cwd === "string" && hook.cwd ? hook.cwd : cwd;
const worktrees = [dir];
try {
const physical = realpathSync(dir);
if (physical !== dir) worktrees.push(physical);
} catch { /* a vanished cwd still matches by its given spelling */ }
payload.worktrees = worktrees;
return payload;
}

export function forkDenyReason(subject: string | undefined): string {
return "Blocking forks go through the gate protocol first: run `rt gate ask --questions <json>` "
+ "(with --context quoting the decision material), then act on the presentation it returns. "
+ "form: ask it here with AskUserQuestion, which this hook then allows, and submit the pick with `rt gate answer <id> --answers <json> --by pane`. "
+ "wait: background `rt gate wait <id>` and end the turn."
+ (subject ? ` This pane's gates file under ${JSON.stringify(subject)}.` : "");
}

/** Any failure to get a verdict (daemon down, a daemon that predates the
verb) allows: degraded mode stays legal. */
export function forkCheckHookOutput(res: RtResponse<Commands["gate:fork-check"]["data"]> | null): Record<string, unknown> {
if (!res || !res.ok || !res.data || res.data.allow) return FORK_CHECK_ALLOW;
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: forkDenyReason(res.data.subject),
},
};
}

export async function gateForkCheck(_args: string[]): Promise<void> {
const stdin = process.stdin.isTTY ? "" : await Bun.stdin.text();
const payload = buildForkCheckPayload(stdin, process.env, process.cwd());
const res = payload ? await clientForkCheck(payload, { timeoutMs: FORK_CHECK_TIMEOUT_MS }) : null;
console.log(JSON.stringify(forkCheckHookOutput(res)));
}

// ─── wait ────────────────────────────────────────────────────────────────────

const WAIT_USAGE = "usage: rt gate wait <id> [--timeout <duration>]";
Expand Down
29 changes: 21 additions & 8 deletions docs/superpowers/specs/2026-09-11-executor-reconciler-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,27 @@ Every `rt agent` launch injects a PreToolUse hook (matcher
script ships with rt (`scripts/hooks/gate-fork.sh` in the bundle). Env
stamped at launch: `RT_AGENT_ID`, `RT_GATE_SUBJECT`, `RT_DAEMON_SOCK`.

Hook logic, in order:

1. Daemon socket probe fails: **allow** (degraded mode stays legal).
2. An open or parked gate exists for `RT_GATE_SUBJECT`: **allow** (the
wrapper's `presentation: "form"` branch legitimately renders gates as
native forms).
3. Otherwise: **deny**, with a denial message instructing the agent to open
a gate per the gate protocol (subject and status-bin from env).
Hook logic, in order (updated 2026-09-23: the script now hands the hook
payload to `rt gate fork-check`, and the daemon's `gate:fork-check` verb
decides; the list below supersedes the original three steps). No step
counts a `pane-attention` gate, which the reconciler files under
`RT_GATE_SUBJECT` to report on the pane. The caller's sessions are the
hook payload's `session_id` and `CLAUDE_CODE_SESSION_ID`, which
`rt gate ask` stamps as the nudge session and which can differ.

1. No `rt`, daemon unreachable, or no verdict: **allow** (degraded mode
stays legal). A 10s hook timeout makes a wedged `rt` fail open too.
2. An open or parked gate exists for `RT_GATE_SUBJECT`: **allow**.
3. An open form gate on any subject was asked from this pane
(`origin.paneId`, else the `pane` column), its executor is not
`gone`, and its nudge session is one of the caller's when both sides
carry one (the real guard, since herdr reuses pane ids): **allow**.
4. An open `run:` gate's `origin.worktree` is this pane's cwd: **allow**.
5. An open gate exists on the subject `rt gate ask` would resolve for
either of the caller's sessions (the same resolver): **allow**.
6. Otherwise: **deny**, telling the agent to run `rt gate ask` and act on
the returned presentation (form: ask it in the pane, which step 3 then
allows; wait: background `rt gate wait <id>`).

Net effect: a native form may only be the face of a real gate; improvised
forks become gates and route by owner. Blocked-pane detection remains the
Expand Down
Loading
Loading