diff --git a/packages/agent/src/adapters/claude/git-command.ts b/packages/agent/src/adapters/claude/git-command.ts index ade8fdf735..58e3b2dd24 100644 --- a/packages/agent/src/adapters/claude/git-command.ts +++ b/packages/agent/src/adapters/claude/git-command.ts @@ -1,6 +1,5 @@ -// Pure git command-line parsing, shared by the signed-commit guard (hooks.ts) -// and the RTK rewrite (session/rtk.ts). Kept dependency-free so importers don't -// drag in the hooks module's heavier import chain. +// Pure git command-line parsing used by the signed-commit guard. Kept +// dependency-free so importers don't drag in the hooks module's heavier chain. // git global options that consume the following token as their value, so the // subcommand detector must skip both (mirrors the sandbox `git` PATH shim). diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 9698920c27..f120d05205 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -210,7 +210,7 @@ describe("buildSessionOptions", () => { expect(healSpy).not.toHaveBeenCalled(); }); - describe("rtk and signed-commit guard ordering", () => { + describe("rtk hook registration", () => { const originalRtk = process.env.POSTHOG_RTK; let dir: string; let binary: string; @@ -218,7 +218,8 @@ describe("buildSessionOptions", () => { beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-order-")); binary = path.join(dir, "rtk"); - fs.writeFileSync(binary, "#!/bin/sh\n"); + fs.writeFileSync(binary, "#!/bin/sh\necho 'rtk 0.43.0'\n"); + fs.chmodSync(binary, 0o755); process.env.POSTHOG_RTK = binary; }); @@ -244,7 +245,7 @@ describe("buildSessionOptions", () => { }; }; - it("registers the signed-commit guard before the rtk rewrite so the guard evaluates raw commands (cloud)", async () => { + it("keeps the signed-commit guard and pnpm test rewrite active in cloud mode", async () => { const options = buildSessionOptions({ ...makeParams(), cloudMode: true, @@ -254,9 +255,6 @@ describe("buildSessionOptions", () => { ); const opts = { signal: new AbortController().signal }; - // Identify each hook behaviorally: the guard denies `git commit`, the - // rtk hook rewrites `git status`. Their registration order is the - // defense-in-depth guarantee that the guard always sees the raw command. let guardIndex = -1; let rtkIndex = -1; for (const [index, hook] of hooks.entries()) { @@ -273,14 +271,14 @@ describe("buildSessionOptions", () => { } const rewriteResult = (await hook( - bashInput("git status"), + bashInput("pnpm test"), undefined, opts, )) as PreToolUseOutput; if ( rtkIndex === -1 && rewriteResult.hookSpecificOutput?.updatedInput?.command === - `${binary} git status` + `${binary} test pnpm test` ) { rtkIndex = index; } @@ -288,7 +286,31 @@ describe("buildSessionOptions", () => { expect(guardIndex).toBeGreaterThanOrEqual(0); expect(rtkIndex).toBeGreaterThanOrEqual(0); - expect(guardIndex).toBeLessThan(rtkIndex); + }); + + it("applies the shared pnpm test policy through the Claude hook", async () => { + const options = buildSessionOptions({ + ...makeParams(), + cloudMode: false, + }); + const hooks = (options.hooks?.PreToolUse ?? []).flatMap( + (entry) => entry.hooks ?? [], + ); + const opts = { signal: new AbortController().signal }; + + const results = await Promise.all( + hooks.map((hook) => hook(bashInput("pnpm test"), undefined, opts)), + ); + + expect(results).toContainEqual( + expect.objectContaining({ + hookSpecificOutput: expect.objectContaining({ + updatedInput: { + command: `${binary} test pnpm test`, + }, + }), + }), + ); }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 5b8808e807..c3e69495b9 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -14,6 +14,7 @@ import type { FileEnrichmentDeps } from "../../../enrichment/file-enricher"; import { IS_ROOT } from "../../../utils/common"; import { buildGatewayPropertyHeaders } from "../../../utils/gateway"; import type { Logger } from "../../../utils/logger"; +import { resolveRtkPrefix } from "../../rtk"; import type { TaskState } from "../conversion/task-state"; import { createPostToolUseHook, @@ -31,7 +32,7 @@ import type { EffortLevel } from "../types"; import { buildAppendedInstructions } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models"; -import { createRtkRewriteHook, resolveRtkPrefix } from "./rtk"; +import { createRtkRewriteHook } from "./rtk"; import type { SettingsManager } from "./settings"; export interface ProcessSpawnedInfo { diff --git a/packages/agent/src/adapters/claude/session/rtk.test.ts b/packages/agent/src/adapters/claude/session/rtk.test.ts deleted file mode 100644 index 8c7f89ba46..0000000000 --- a/packages/agent/src/adapters/claude/session/rtk.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import type { HookInput } from "@anthropic-ai/claude-agent-sdk"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import type { Logger } from "../../../utils/logger"; -import { - createRtkRewriteHook, - detectRtkBinary, - resolveRtkPrefix, - rewriteBashForRtk, -} from "./rtk"; - -describe("rewriteBashForRtk", () => { - test.each([ - // Read-only git subcommands are wrapped. - ["git status", "rtk git status"], - ["git diff --stat", "rtk git diff --stat"], - ["git log --oneline -10", "rtk git log --oneline -10"], - ["git show HEAD", "rtk git show HEAD"], - // Plain read-only commands are wrapped. - ["grep -rn foo src", "rtk grep -rn foo src"], - ["find . -name '*.ts'", "rtk find . -name '*.ts'"], - ["ls -la", "rtk ls -la"], - ])("wraps %j", (input, expected) => { - expect(rewriteBashForRtk(input, "rtk")).toBe(expected); - }); - - test.each([ - // Side-effecting git subcommands are left alone (also protects the - // cloud signed-commit guard, which keys on a leading `git`). - ["git commit -m wip"], - ["git push origin main"], - ["git checkout -b feature"], - // The cloud signed-commit flow instructs the model to run these raw: - // staging before git_signed_commit, and the stale-checkout / rebase - // recovery sequence. They must never enter the compressible allowlist. - ["git add -A"], - ["git stash --include-untracked"], - ["git stash pop"], - ["git fetch origin main"], - ["git reset --hard origin/main"], - ["git rebase --continue"], - ["git merge origin/master"], - ["git cherry-pick abc123"], - // Commands RTK isn't wrapping in this cut. - ["npm test"], - ["cat file.ts"], - ["echo hello"], - // Shell operators mean more than one invocation — never rewrite. - ["git status | grep foo"], - ["git status && ls"], - ["grep foo src > out.txt"], - ["ls; pwd"], - ["echo $(git status)"], - // A leading env assignment or explicit path is not a bare allowlisted head. - ["FOO=bar git status"], - ["/usr/bin/git status"], - // Empty / whitespace. - [""], - [" "], - ])("leaves %j unchanged", (input) => { - expect(rewriteBashForRtk(input, "rtk")).toBeNull(); - }); - - test("is idempotent — does not double-wrap", () => { - expect(rewriteBashForRtk("rtk git status", "rtk")).toBeNull(); - }); - - test("shell-quotes a binary path containing spaces", () => { - expect(rewriteBashForRtk("git status", "/Apps/My Tools/rtk")).toBe( - "'/Apps/My Tools/rtk' git status", - ); - }); - - test("is idempotent for a space-containing prefix (quoted round-trip)", () => { - const prefix = "/Apps/My Tools/rtk"; - const wrapped = rewriteBashForRtk("git status", prefix); - expect(wrapped).toBe("'/Apps/My Tools/rtk' git status"); - // Feeding our own quoted output back through must not double-wrap, even - // though the quoted first token never equals the bare prefix. - expect(rewriteBashForRtk(wrapped as string, prefix)).toBeNull(); - }); -}); - -describe("resolveRtkPrefix", () => { - let dir: string; - let binary: string; - - beforeAll(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-test-")); - binary = path.join(dir, "rtk"); - fs.writeFileSync(binary, "#!/bin/sh\n"); - }); - - afterAll(() => { - fs.rmSync(dir, { recursive: true, force: true }); - }); - - test.each([ - ["unset", undefined], - ["empty", ""], - ["1", "1"], - ["true", "true"], - ])("auto-detects rtk on PATH when POSTHOG_RTK is %s", (_label, value) => { - expect(resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir })).toBe(binary); - }); - - test("returns undefined when rtk is not on PATH", () => { - expect(resolveRtkPrefix({ PATH: "/nonexistent" })).toBeUndefined(); - }); - - test.each([ - ["zero", "0"], - ["false", "false"], - ["FALSE", "FALSE"], - ])( - "opts out when POSTHOG_RTK is %s, even with rtk on PATH", - (_label, value) => { - expect( - resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir }), - ).toBeUndefined(); - }, - ); - - test("uses an explicit path that exists", () => { - expect(resolveRtkPrefix({ POSTHOG_RTK: binary })).toBe(binary); - }); - - test("is disabled for an explicit path that does not exist", () => { - expect( - resolveRtkPrefix({ POSTHOG_RTK: path.join(dir, "missing") }), - ).toBeUndefined(); - }); -}); - -describe("detectRtkBinary", () => { - let dir: string; - let binary: string; - - beforeAll(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-detect-")); - binary = path.join(dir, "rtk"); - fs.writeFileSync(binary, "#!/bin/sh\n"); - }); - - afterAll(() => { - fs.rmSync(dir, { recursive: true, force: true }); - }); - - // The per-session toggle must not hide an installed binary from the - // status probe — a prior session leaving POSTHOG_RTK=0 in the process - // env would otherwise flap the settings hint. - test.each([ - ["unset", undefined], - ["0", "0"], - ["false", "false"], - ["1", "1"], - ["true", "true"], - ])("finds the PATH binary when POSTHOG_RTK is %s", (_label, value) => { - expect(detectRtkBinary({ POSTHOG_RTK: value, PATH: dir })).toBe(binary); - }); - - test("reports no binary when rtk is not on PATH", () => { - expect(detectRtkBinary({ PATH: "/nonexistent" })).toBeUndefined(); - }); - - test("honors an explicit path override that exists", () => { - expect(detectRtkBinary({ POSTHOG_RTK: binary, PATH: "/nonexistent" })).toBe( - binary, - ); - }); - - test("reports no binary for a broken explicit path, matching the resolver", () => { - expect( - detectRtkBinary({ POSTHOG_RTK: path.join(dir, "missing"), PATH: dir }), - ).toBeUndefined(); - }); -}); - -describe("createRtkRewriteHook", () => { - const logger = { - info() {}, - warn() {}, - error() {}, - debug() {}, - } as unknown as Logger; - - const bashInput = (command: string): HookInput => - ({ - session_id: "s", - transcript_path: "/tmp/t", - cwd: "/tmp", - hook_event_name: "PreToolUse", - tool_name: "Bash", - tool_input: { command }, - }) as unknown as HookInput; - - test("rewrites an eligible Bash command to updatedInput", async () => { - const hook = createRtkRewriteHook("rtk", logger); - const result = await hook(bashInput("git status"), "tool-1", { - signal: new AbortController().signal, - }); - expect(result).toMatchObject({ - continue: true, - hookSpecificOutput: { - hookEventName: "PreToolUse", - updatedInput: { command: "rtk git status" }, - }, - }); - }); - - test("passes ineligible commands through untouched", async () => { - const hook = createRtkRewriteHook("rtk", logger); - const result = await hook(bashInput("npm test"), "tool-1", { - signal: new AbortController().signal, - }); - expect(result).toEqual({ continue: true }); - }); - - test("ignores non-Bash tools", async () => { - const hook = createRtkRewriteHook("rtk", logger); - const input = { - session_id: "s", - transcript_path: "/tmp/t", - cwd: "/tmp", - hook_event_name: "PreToolUse", - tool_name: "Read", - tool_input: { file_path: "/x" }, - } as unknown as HookInput; - const result = await hook(input, "tool-1", { - signal: new AbortController().signal, - }); - expect(result).toEqual({ continue: true }); - }); -}); diff --git a/packages/agent/src/adapters/claude/session/rtk.ts b/packages/agent/src/adapters/claude/session/rtk.ts index 0b7d256826..9da6de8d6e 100644 --- a/packages/agent/src/adapters/claude/session/rtk.ts +++ b/packages/agent/src/adapters/claude/session/rtk.ts @@ -1,159 +1,6 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; import type { HookCallback, HookInput } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "../../../utils/logger"; -import { gitSubcommand } from "../git-command"; - -/** - * RTK (https://github.com/rtk-ai/rtk) is a CLI proxy that compresses the output - * of common dev commands by 60-90% before it reaches the model. When RTK is - * available we rewrite eligible `Bash` calls to run through it, so the savings - * happen at the source — the verbose output is never generated into context. - * - * Used automatically when `rtk` is on PATH; set `POSTHOG_RTK=0` to opt out. - */ - -// Commands RTK compresses faithfully and that have no side effects, so wrapping -// them changes only how much output reaches the model, never what runs. -// Exported so the instruction-level Codex guidance advertises the same set. -export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); - -// Git subcommands whose output is worth compressing and that RTK handles -// faithfully. The criterion is compressible output, NOT read-only: RTK never -// changes what runs, so a mutating form (`git tag -d`, `git remote add`, -// `git reflog expire`) still executes its write — its output is just shorter. -// Excludes commit/push: negligible output to compress, and the cloud -// signed-commit guard keys on a leading `git` token that `rtk git …` would hide. -// Exported so the instruction-level Codex guidance advertises the same set. -export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ - "status", - "diff", - "log", - "show", - "branch", - "blame", - "shortlog", - "ls-files", - "describe", - "tag", - "remote", - "reflog", - "whatchanged", - "grep", -]); - -// Any shell control operator means the line is more than one simple invocation; -// wrapping only its head would change the meaning of the rest. -const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; - -// Exported so the instruction-level Codex guidance quotes the prefix the same way. -export function shQuote(value: string): string { - if (/^[\w./-]+$/.test(value)) return value; - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -/** - * Returns `command` rewritten to run through the RTK binary at `rtkPrefix`, or - * null when it isn't safe or worthwhile to rewrite. Pure and side-effect free. - */ -export function rewriteBashForRtk( - command: string, - rtkPrefix: string, -): string | null { - const trimmed = command.trim(); - if (!trimmed || SHELL_OPERATORS.test(trimmed)) return null; - - // Already routed through rtk — keep the rewrite idempotent. - const quotedPrefix = shQuote(rtkPrefix); - if ( - trimmed === quotedPrefix || - trimmed.startsWith(`${quotedPrefix} `) || - trimmed.startsWith("rtk ") - ) { - return null; - } - - const head = trimmed.split(/\s+/, 1)[0]; - if (head === "git") { - const sub = gitSubcommand(trimmed); - if (!sub || !GIT_COMPRESSIBLE_SUBCOMMANDS.has(sub)) return null; - } else if (!RTK_PLAIN_COMMANDS.has(head)) { - return null; - } - - return `${quotedPrefix} ${trimmed}`; -} - -function findOnPath(bin: string, env: NodeJS.ProcessEnv): string | undefined { - const pathVar = env.PATH ?? env.Path ?? ""; - const exts = - process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]; - for (const dir of pathVar.split(path.delimiter)) { - if (!dir) continue; - for (const ext of exts) { - const full = path.join(dir, bin + ext); - try { - if (fs.statSync(full).isFile()) return full; - } catch { - // Not in this dir; keep looking. - } - } - } - return undefined; -} - -/** - * Resolves the RTK binary to route shell output through. Auto-detects `rtk` on - * PATH by default, so an installed `rtk` is used automatically. `POSTHOG_RTK` - * overrides: - * unset / "" / "1" / "true" → auto-detect `rtk` on PATH - * "0" / "false" → disabled (opt out) - * any other value → an explicit path to the binary - */ -export function resolveRtkPrefix(env: NodeJS.ProcessEnv): string | undefined { - const raw = env.POSTHOG_RTK?.trim(); - const lowered = raw?.toLowerCase(); - - // Explicit opt-out, even when rtk is installed. - if (lowered === "0" || lowered === "false") return undefined; - - // An explicit binary-path override (anything other than a bare enable flag). - if (raw && lowered !== "1" && lowered !== "true") { - try { - if (fs.statSync(raw).isFile()) return raw; - } catch { - // Explicit path doesn't exist — treat as disabled rather than emit a - // command that would fail with "rtk: not found". - } - return undefined; - } - - // Default (unset) or explicit enable: use rtk if it is on PATH. - return findOnPath("rtk", env); -} - -/** - * Detects the rtk binary a session on this host could use. The on/off flag - * values of POSTHOG_RTK ("0"/"false"/"1"/"true"/unset) all mean auto-detect - * here, so the answer reflects installation, not the per-session toggle a - * previous session may have left in the environment. An explicit binary-path - * override mirrors the resolver: honored when it exists, otherwise no binary. - */ -export function detectRtkBinary(env: NodeJS.ProcessEnv): string | undefined { - const raw = env.POSTHOG_RTK?.trim(); - const lowered = raw?.toLowerCase(); - const isFlagValue = - !raw || ["0", "false", "1", "true"].includes(lowered ?? ""); - if (!isFlagValue && raw) { - try { - if (fs.statSync(raw).isFile()) return raw; - } catch { - // Explicit path doesn't exist — sessions would get no rtk either. - } - return undefined; - } - return findOnPath("rtk", env); -} +import { rewriteBashForRtk } from "../../rtk"; export const createRtkRewriteHook = (rtkPrefix: string, logger: Logger): HookCallback => diff --git a/packages/agent/src/adapters/rtk-guidance.test.ts b/packages/agent/src/adapters/rtk-guidance.test.ts index 334f36053f..3469f09681 100644 --- a/packages/agent/src/adapters/rtk-guidance.test.ts +++ b/packages/agent/src/adapters/rtk-guidance.test.ts @@ -2,10 +2,6 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { - GIT_COMPRESSIBLE_SUBCOMMANDS, - RTK_PLAIN_COMMANDS, -} from "./claude/session/rtk"; import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance"; describe("rtk guidance for codex", () => { @@ -15,7 +11,8 @@ describe("rtk guidance for codex", () => { beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-")); binary = path.join(dir, "rtk"); - fs.writeFileSync(binary, "#!/bin/sh\n"); + fs.writeFileSync(binary, "#!/bin/sh\necho 'rtk 0.43.0'\n"); + fs.chmodSync(binary, 0o755); }); afterAll(() => { @@ -23,36 +20,22 @@ describe("rtk guidance for codex", () => { }); describe("buildRtkGuidance", () => { - // The guidance must advertise exactly the Claude hook's eligibility sets, - // so the token-usage cohorts stay comparable across adapters. - test("advertises every command the Claude hook rewrites", () => { + test("advertises only dedicated test modes", () => { const guidance = buildRtkGuidance("/usr/local/bin/rtk"); - for (const command of RTK_PLAIN_COMMANDS) { - expect(guidance).toContain(command); - } - for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) { - expect(guidance).toContain(sub); - } - }); - - test("uses the resolved binary path in the examples", () => { - const guidance = buildRtkGuidance("/usr/local/bin/rtk"); - expect(guidance).toContain("`/usr/local/bin/rtk git status`"); + expect(guidance).toContain("`/usr/local/bin/rtk test pnpm test`"); + expect(guidance).toContain("`/usr/local/bin/rtk test python -m pytest`"); + expect(guidance).not.toContain("cargo test"); + expect(guidance).toContain("Do not use RTK for machine-readable"); + expect(guidance).not.toContain("git status"); + expect(guidance).not.toContain("rg --heading"); + expect(guidance).not.toContain("jq -c"); }); // A desktop install can resolve a path with spaces; unquoted it would // split into multiple shell tokens and every guided command would fail. test("shell-quotes a binary path containing spaces", () => { const guidance = buildRtkGuidance("/Apps/My Tools/rtk"); - expect(guidance).toContain("`'/Apps/My Tools/rtk' git status`"); - expect(guidance).not.toContain("`/Apps/My Tools/rtk git status`"); - }); - - // Parity with the Claude hook's exclusion: prefixing commit/push would - // hide the leading `git` token from the cloud signed-commit guard. - test("forbids prefixing git commit and git push", () => { - const guidance = buildRtkGuidance("rtk"); - expect(guidance).toContain("Never prefix `git commit`, `git push`"); + expect(guidance).toContain("`'/Apps/My Tools/rtk' test pnpm test`"); }); }); @@ -62,7 +45,7 @@ describe("rtk guidance for codex", () => { PATH: dir, }); expect(result.startsWith("base instructions\n\n")).toBe(true); - expect(result).toContain("rtk command-output compression"); + expect(result).toContain("rtk output compression"); expect(result).toContain(binary); }); diff --git a/packages/agent/src/adapters/rtk-guidance.ts b/packages/agent/src/adapters/rtk-guidance.ts index 962913a32c..c2eda3dc53 100644 --- a/packages/agent/src/adapters/rtk-guidance.ts +++ b/packages/agent/src/adapters/rtk-guidance.ts @@ -1,9 +1,4 @@ -import { - GIT_COMPRESSIBLE_SUBCOMMANDS, - RTK_PLAIN_COMMANDS, - resolveRtkPrefix, - shQuote, -} from "./claude/session/rtk"; +import { resolveRtkPrefix, shQuote } from "./rtk"; /** * Instruction-level RTK integration for Codex sessions. @@ -15,30 +10,19 @@ import { * only integration point is the developer instructions: tell the model to * prefix eligible commands itself. * - * The advertised command set and rules mirror the Claude hook exactly - * (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only, - * never commit/push), so token-usage cohorts stay comparable across adapters. + * The advertised rule mirrors the Claude hook: only recognized bare test + * commands use RTK's dedicated failure-focused test mode. */ export function buildRtkGuidance(rtkPrefix: string): string { - // Same quoting as the Claude rewrite hook: a resolved path containing - // spaces must stay one shell token in the commands the model copies. const prefix = shQuote(rtkPrefix); - const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `"); - const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", "); - return `## rtk command-output compression + return `## rtk output compression -\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`: +For recognized bare test commands, prefix the original command with \`${prefix} test\` so passing test noise is removed while failures remain. Supported commands are pnpm and npm tests, plus Python pytest and unittest forms. -- \`${plainCommands}\` -- these git subcommands: ${gitSubcommands} +Examples: \`${prefix} test pnpm test\`, \`${prefix} test python -m pytest\`. -Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`. - -Rules: -- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads. -- Never prefix \`git commit\`, \`git push\`, or any other command not listed above. -- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`; +Do not use RTK for machine-readable test output, other commands, or commands containing pipes, shell chains, or redirection.`; } /** diff --git a/packages/agent/src/adapters/rtk.test.ts b/packages/agent/src/adapters/rtk.test.ts new file mode 100644 index 0000000000..805243dd25 --- /dev/null +++ b/packages/agent/src/adapters/rtk.test.ts @@ -0,0 +1,251 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { HookInput } from "@anthropic-ai/claude-agent-sdk"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import type { Logger } from "../utils/logger"; +import { createRtkRewriteHook } from "./claude/session/rtk"; +import { detectRtkBinary, resolveRtkPrefix, rewriteBashForRtk } from "./rtk"; + +describe("rewriteBashForRtk", () => { + test.each([ + ["pnpm test", "rtk test pnpm test"], + [ + "pnpm --filter @acme/app test --runInBand", + "rtk test pnpm --filter @acme/app test --runInBand", + ], + ["CI=1 pnpm test", "CI=1 rtk test pnpm test"], + [ + 'CI=1 MODE="full suite" pnpm test', + 'CI=1 MODE="full suite" rtk test pnpm test', + ], + ["npm test", "rtk test npm test"], + ["npm run test -- --watch=false", "rtk test npm run test -- --watch=false"], + [ + "npm --workspace @acme/app test", + "rtk test npm --workspace @acme/app test", + ], + ["python -m pytest -q", "rtk test python -m pytest -q"], + ["python3 -m unittest tests", "rtk test python3 -m unittest tests"], + ["pytest tests/unit", "rtk test pytest tests/unit"], + ["uv run pytest", "rtk test uv run pytest"], + ["poetry run pytest", "rtk test poetry run pytest"], + ])("rewrites the dedicated test mode for %j", (input, expected) => { + expect(rewriteBashForRtk(input, "rtk")).toBe(expected); + }); + + test.each([ + ["git status"], + ["git status --porcelain -z"], + ["git log --oneline -10"], + ["ls -la"], + ["ls -1A"], + ["rg -n foo src"], + ["rg -n --null foo src"], + ["rg --json -n foo src"], + ["npm test -- --json"], + ["yarn test --reporter=json"], + ["bun test --reporter junit"], + ["yarn test"], + ["bun test"], + ["cargo test --workspace"], + ["pytest --junitxml=report.xml"], + ["cargo test --message-format=json"], + ["go test -json ./..."], + ["dotnet test --logger trx"], + ["go test ./..."], + ["dotnet test"], + ["mvn test"], + ["./gradlew test"], + ["bundle exec rspec"], + ["rake test"], + ["mix test"], + ["swift test"], + ["zig test src/main.zig"], + ["./vendor/bin/phpunit"], + ["deno test"], + ["npx tsc --noEmit"], + ["docker ps -a"], + ["kubectl get pods -o yaml"], + ["aws s3api list-buckets --output yaml"], + ["psql -c 'select * from events'"], + ["pip install -r requirements.txt"], + ["gh api /repos/acme/app"], + ["find . -type f"], + ["curl https://example.com/api"], + ["pnpm test && git status"], + ["pnpm test || true"], + ["CI=1 pnpm test | tail -20"], + ["pnpm test > output.txt"], + ["pnpm test; git status"], + ["echo $(pnpm test)"], + ["1INVALID=value pnpm test"], + ["CI =1 pnpm test"], + ["CI=1"], + ["/usr/bin/pnpm test"], + [""], + [" "], + ])("leaves %j unchanged", (input) => { + expect(rewriteBashForRtk(input, "rtk")).toBeNull(); + }); + + test("does not double-wrap an RTK command", () => { + expect(rewriteBashForRtk("rtk test pnpm test", "rtk")).toBeNull(); + }); + + test("shell-quotes an RTK path containing spaces", () => { + expect(rewriteBashForRtk("pnpm test", "/Apps/My Tools/rtk")).toBe( + "'/Apps/My Tools/rtk' test pnpm test", + ); + }); +}); + +function writeRtkBinary( + binary: string, + version = "0.43.0", + executable = true, +): void { + fs.writeFileSync( + binary, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "rtk ${version}"; exit 0; fi\nexit 0\n`, + ); + if (executable) fs.chmodSync(binary, 0o755); +} + +describe("resolveRtkPrefix", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-test-")); + binary = path.join(dir, "rtk"); + writeRtkBinary(binary); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test.each([ + ["unset", undefined], + ["empty", ""], + ["1", "1"], + ["true", "true"], + ])("auto-detects compatible RTK when POSTHOG_RTK is %s", (_label, value) => { + expect(resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir })).toBe(binary); + }); + + test.each([ + ["zero", "0"], + ["false", "false"], + ["FALSE", "FALSE"], + ])("honors the %s opt-out", (_label, value) => { + expect(resolveRtkPrefix({ POSTHOG_RTK: value, PATH: dir })).toBeUndefined(); + }); + + test("uses a compatible explicit binary", () => { + expect(resolveRtkPrefix({ POSTHOG_RTK: binary })).toBe(binary); + }); + + test.each([ + ["missing", "missing", undefined, true], + ["non-executable", "not-executable", "0.43.0", false], + ["too old", "old", "0.42.9", true], + ["wrong identity", "wrong", "not-rtk", true], + ])("rejects a %s binary", (_label, name, version, executable) => { + const candidate = path.join(dir, name); + if (version === "not-rtk") { + fs.writeFileSync(candidate, "#!/bin/sh\necho 'other 0.43.0'\n"); + fs.chmodSync(candidate, 0o755); + } else if (version) { + writeRtkBinary(candidate, version, executable); + } + expect(resolveRtkPrefix({ POSTHOG_RTK: candidate })).toBeUndefined(); + }); +}); + +describe("detectRtkBinary", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-detect-")); + binary = path.join(dir, "rtk"); + writeRtkBinary(binary); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test.each([ + ["unset", undefined], + ["0", "0"], + ["false", "false"], + ["1", "1"], + ["true", "true"], + ])("finds compatible RTK when POSTHOG_RTK is %s", (_label, value) => { + expect(detectRtkBinary({ POSTHOG_RTK: value, PATH: dir })).toBe(binary); + }); + + test("reports no binary when RTK is absent", () => { + expect(detectRtkBinary({ PATH: "/nonexistent" })).toBeUndefined(); + }); +}); + +describe("createRtkRewriteHook", () => { + const logger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + } as unknown as Logger; + + const bashInput = (command: string): HookInput => + ({ + session_id: "s", + transcript_path: "/tmp/t", + cwd: "/tmp", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + }) as unknown as HookInput; + + test("rewrites pnpm test through the Claude hook", async () => { + const hook = createRtkRewriteHook("rtk", logger); + const result = await hook(bashInput("pnpm test"), "tool-1", { + signal: new AbortController().signal, + }); + expect(result).toMatchObject({ + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + updatedInput: { command: "rtk test pnpm test" }, + }, + }); + }); + + test("passes generic commands through untouched", async () => { + const hook = createRtkRewriteHook("rtk", logger); + const result = await hook(bashInput("git status"), "tool-1", { + signal: new AbortController().signal, + }); + expect(result).toEqual({ continue: true }); + }); + + test("ignores non-Bash tools", async () => { + const hook = createRtkRewriteHook("rtk", logger); + const input = { + session_id: "s", + transcript_path: "/tmp/t", + cwd: "/tmp", + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: "/x" }, + } as unknown as HookInput; + const result = await hook(input, "tool-1", { + signal: new AbortController().signal, + }); + expect(result).toEqual({ continue: true }); + }); +}); diff --git a/packages/agent/src/adapters/rtk.ts b/packages/agent/src/adapters/rtk.ts new file mode 100644 index 0000000000..e10853acc4 --- /dev/null +++ b/packages/agent/src/adapters/rtk.ts @@ -0,0 +1,158 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * RTK (https://github.com/rtk-ai/rtk) is a CLI proxy that compresses the output + * of common dev commands before they reach the model. Automatic rewriting is + * deliberately limited to dedicated RTK modes with tested output contracts. + * + * Used automatically when `rtk` is on PATH; set `POSTHOG_RTK=0` to opt out. + */ + +const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; + +const LEADING_ASSIGNMENTS = + /^((?:[A-Za-z_]\w*=(?:[^\s'"]+|'[^']*'|"[^"]*")\s+)+)(.+)$/; + +function splitLeadingAssignments(segment: string): { + assignments: string; + command: string; +} { + const match = segment.match(LEADING_ASSIGNMENTS); + if (!match) return { assignments: "", command: segment }; + return { assignments: match[1], command: match[2] }; +} + +const TEST_COMMAND_PATTERNS = [ + /^pnpm(?:\s+--filter\s+\S+)?\s+test(?:\s|$)/, + /^npm(?:\s+--workspace(?:=\S+|\s+\S+))?\s+(?:run\s+)?test(?:\s|$)/, + /^(?:python|python3)\s+-m\s+(?:pytest|unittest)(?:\s|$)/, + /^pytest(?:\s|$)/, + /^uv\s+run\s+pytest(?:\s|$)/, + /^poetry\s+run\s+pytest(?:\s|$)/, +]; + +const MACHINE_READABLE_TEST_FLAGS = + /(?:^|\s)(?:-json|--(?:json|json-output|junit-xml|junitxml|log-format|logger|message-format|output-file|outputFile|reporter))(?:[=\s]|$)/; + +function isSupportedTestCommand(command: string): boolean { + return ( + !MACHINE_READABLE_TEST_FLAGS.test(command) && + TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(command)) + ); +} + +export function shQuote(value: string): string { + if (/^[\w./-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Returns `command` rewritten to run through the RTK binary at `rtkPrefix`, or + * null when it isn't safe or worthwhile to rewrite. Pure and side-effect free. + */ +export function rewriteBashForRtk( + command: string, + rtkPrefix: string, +): string | null { + const trimmed = command.trim(); + if (!trimmed || SHELL_OPERATORS.test(trimmed)) return null; + + // Already routed through rtk — keep the rewrite idempotent. + const quotedPrefix = shQuote(rtkPrefix); + if ( + trimmed === quotedPrefix || + trimmed.startsWith(`${quotedPrefix} `) || + trimmed.startsWith("rtk ") + ) { + return null; + } + + const { assignments, command: segmentCommand } = + splitLeadingAssignments(trimmed); + if (!isSupportedTestCommand(segmentCommand)) return null; + return `${assignments}${quotedPrefix} test ${segmentCommand}`; +} + +const MINIMUM_RTK_VERSION = [0, 43, 0] as const; + +function isUsableRtkBinary(binary: string): boolean { + try { + if (!fs.statSync(binary).isFile()) return false; + if (process.platform !== "win32") fs.accessSync(binary, fs.constants.X_OK); + const result = spawnSync(binary, ["--version"], { + encoding: "utf8", + timeout: 1000, + }); + if (result.status !== 0 || result.error) return false; + const match = `${result.stdout ?? ""}${result.stderr ?? ""}`.match( + /\brtk\s+(\d+)\.(\d+)\.(\d+)\b/i, + ); + if (!match) return false; + const version = match.slice(1).map(Number); + for (let index = 0; index < MINIMUM_RTK_VERSION.length; index++) { + if (version[index] > MINIMUM_RTK_VERSION[index]) return true; + if (version[index] < MINIMUM_RTK_VERSION[index]) return false; + } + return true; + } catch { + return false; + } +} + +function findOnPath(bin: string, env: NodeJS.ProcessEnv): string | undefined { + const pathVar = env.PATH ?? env.Path ?? ""; + const exts = + process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]; + for (const dir of pathVar.split(path.delimiter)) { + if (!dir) continue; + for (const ext of exts) { + const full = path.join(dir, bin + ext); + if (isUsableRtkBinary(full)) return full; + } + } + return undefined; +} + +/** + * Resolves the RTK binary to route shell output through. Auto-detects `rtk` on + * PATH by default, so an installed `rtk` is used automatically. `POSTHOG_RTK` + * overrides: + * unset / "" / "1" / "true" → auto-detect `rtk` on PATH + * "0" / "false" → disabled (opt out) + * any other value → an explicit path to the binary + */ +export function resolveRtkPrefix(env: NodeJS.ProcessEnv): string | undefined { + const raw = env.POSTHOG_RTK?.trim(); + const lowered = raw?.toLowerCase(); + + // Explicit opt-out, even when rtk is installed. + if (lowered === "0" || lowered === "false") return undefined; + + // An explicit binary-path override (anything other than a bare enable flag). + if (raw && lowered !== "1" && lowered !== "true") { + return isUsableRtkBinary(raw) ? raw : undefined; + } + + // Default (unset) or explicit enable: use rtk if it is on PATH. + return findOnPath("rtk", env); +} + +/** + * Detects the rtk binary a session on this host could use. The on/off flag + * values of POSTHOG_RTK ("0"/"false"/"1"/"true"/unset) all mean auto-detect + * here, so the answer reflects installation, not the per-session toggle a + * previous session may have left in the environment. An explicit binary-path + * override mirrors the resolver: honored when it exists, otherwise no binary. + */ +export function detectRtkBinary(env: NodeJS.ProcessEnv): string | undefined { + const raw = env.POSTHOG_RTK?.trim(); + const lowered = raw?.toLowerCase(); + const isFlagValue = + !raw || ["0", "false", "1", "true"].includes(lowered ?? ""); + if (!isFlagValue && raw) { + return isUsableRtkBinary(raw) ? raw : undefined; + } + return findOnPath("rtk", env); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index dd4e608178..ef3def313f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,5 +4,5 @@ export { isMcpToolReadOnly, type McpToolMetadata, } from "./adapters/claude/mcp/tool-metadata"; -export { detectRtkBinary } from "./adapters/claude/session/rtk"; +export { detectRtkBinary } from "./adapters/rtk"; export type { PostHogProductId } from "./posthog-products"; diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 99e0c67a9d..ddc3cf8625 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -3206,6 +3206,65 @@ describe("AgentServer HTTP Mode", () => { (s as unknown as TestableServer).buildCodexInstructions(sessionPrompt), ).toContain("Cloud Task Execution"); }); + + describe("RTK provider integration", () => { + const originalRtk = process.env.POSTHOG_RTK; + let rtkDir: string; + + beforeEach(async () => { + rtkDir = await mkdtemp(join(tmpdir(), "codex-rtk-guidance-")); + const binary = join(rtkDir, "rtk"); + await writeFile(binary, "#!/bin/sh\necho 'rtk 0.43.0'\n", { + mode: 0o755, + }); + process.env.POSTHOG_RTK = binary; + }); + + afterEach(async () => { + if (originalRtk === undefined) { + delete process.env.POSTHOG_RTK; + } else { + process.env.POSTHOG_RTK = originalRtk; + } + await rm(rtkDir, { recursive: true, force: true }); + }); + + it("adds shared RTK policy to Codex instructions without changing the Claude prompt", () => { + const s = createServer(); + const sessionPrompt = ( + s as unknown as TestableServer + ).buildSessionSystemPrompt(); + const claudePrompt = + typeof sessionPrompt === "string" + ? sessionPrompt + : sessionPrompt.append; + + expect(claudePrompt).not.toContain("rtk output compression"); + + const codexInstructions = ( + s as unknown as TestableServer + ).buildCodexInstructions(sessionPrompt); + expect(codexInstructions).toContain("rtk output compression"); + expect(codexInstructions).toContain("test pnpm"); + expect(codexInstructions).toContain( + "Do not use RTK for machine-readable", + ); + }); + + it("omits RTK guidance from Codex instructions when explicitly disabled", () => { + process.env.POSTHOG_RTK = "0"; + const s = createServer(); + const sessionPrompt = ( + s as unknown as TestableServer + ).buildSessionSystemPrompt(); + + expect( + (s as unknown as TestableServer).buildCodexInstructions( + sessionPrompt, + ), + ).not.toContain("rtk output compression"); + }); + }); }); describe("buildClaudeCodeSessionMeta", () => { diff --git a/packages/agent/src/server/rtk-savings.ts b/packages/agent/src/server/rtk-savings.ts index 1373ae5341..5da7e0329d 100644 --- a/packages/agent/src/server/rtk-savings.ts +++ b/packages/agent/src/server/rtk-savings.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { resolveRtkPrefix } from "../adapters/claude/session/rtk"; +import { resolveRtkPrefix } from "../adapters/rtk"; const execFileAsync = promisify(execFile);