Skip to content

Commit bc53fab

Browse files
committed
fix(agent): probe rtk before rewriting commands through it
A bundled rtk can exist but be unexecutable — wrong arch/libc for the image, or a mac build whose ad-hoc signing failed — and rewriting every eligible command through it would fail them all, strictly worse than having no rtk. The rewrite hook now probes `rtk --version` lazily on the first command it would rewrite, memoized per session: a failed probe logs once and downgrades the session to no-rewrite. Both hosts flow through this one seam, so neither adoption site (bin.ts, auth-adapter) needs its own check, and `rtk gain` already tolerates a broken binary. Generated-By: PostHog Code Task-Id: b86391cc-4634-4a2b-ae34-fe1f9c0626a0
1 parent b9c14cc commit bc53fab

2 files changed

Lines changed: 74 additions & 7 deletions

File tree

packages/agent/src/adapters/claude/session/rtk.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
22
import * as os from "node:os";
33
import * as path from "node:path";
44
import type { HookInput } from "@anthropic-ai/claude-agent-sdk";
5-
import { afterAll, beforeAll, describe, expect, test } from "vitest";
5+
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
66
import type { Logger } from "../../../utils/logger";
77
import {
88
createRtkRewriteHook,
@@ -140,8 +140,10 @@ describe("createRtkRewriteHook", () => {
140140
tool_input: { command },
141141
}) as unknown as HookInput;
142142

143+
const probeYes = () => vi.fn(async () => true);
144+
143145
test("rewrites an eligible Bash command to updatedInput", async () => {
144-
const hook = createRtkRewriteHook("rtk", logger);
146+
const hook = createRtkRewriteHook("rtk", logger, probeYes());
145147
const result = await hook(bashInput("git status"), "tool-1", {
146148
signal: new AbortController().signal,
147149
});
@@ -154,16 +156,45 @@ describe("createRtkRewriteHook", () => {
154156
});
155157
});
156158

159+
test("passes eligible commands through untouched when the binary fails its probe", async () => {
160+
const probe = vi.fn(async () => false);
161+
const hook = createRtkRewriteHook("rtk", logger, probe);
162+
163+
expect(
164+
await hook(bashInput("git status"), "tool-1", {
165+
signal: new AbortController().signal,
166+
}),
167+
).toEqual({ continue: true });
168+
expect(
169+
await hook(bashInput("ls -la"), "tool-2", {
170+
signal: new AbortController().signal,
171+
}),
172+
).toEqual({ continue: true });
173+
});
174+
175+
test("probes at most once per session, and only for eligible commands", async () => {
176+
const probe = probeYes();
177+
const hook = createRtkRewriteHook("rtk", logger, probe);
178+
const signal = new AbortController().signal;
179+
180+
await hook(bashInput("npm test"), "tool-1", { signal });
181+
expect(probe).not.toHaveBeenCalled();
182+
183+
await hook(bashInput("git status"), "tool-2", { signal });
184+
await hook(bashInput("ls -la"), "tool-3", { signal });
185+
expect(probe).toHaveBeenCalledExactlyOnceWith("rtk");
186+
});
187+
157188
test("passes ineligible commands through untouched", async () => {
158-
const hook = createRtkRewriteHook("rtk", logger);
189+
const hook = createRtkRewriteHook("rtk", logger, probeYes());
159190
const result = await hook(bashInput("npm test"), "tool-1", {
160191
signal: new AbortController().signal,
161192
});
162193
expect(result).toEqual({ continue: true });
163194
});
164195

165196
test("ignores non-Bash tools", async () => {
166-
const hook = createRtkRewriteHook("rtk", logger);
197+
const hook = createRtkRewriteHook("rtk", logger, probeYes());
167198
const input = {
168199
session_id: "s",
169200
transcript_path: "/tmp/t",

packages/agent/src/adapters/claude/session/rtk.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { execFile } from "node:child_process";
12
import * as fs from "node:fs";
23
import * as path from "node:path";
4+
import { promisify } from "node:util";
35
import type { HookCallback, HookInput } from "@anthropic-ai/claude-agent-sdk";
46
import type { Logger } from "../../../utils/logger";
57
import { gitSubcommand } from "../git-command";
@@ -129,9 +131,32 @@ export function resolveRtkPrefix(env: NodeJS.ProcessEnv): string | undefined {
129131
return findOnPath("rtk", env);
130132
}
131133

132-
export const createRtkRewriteHook =
133-
(rtkPrefix: string, logger: Logger): HookCallback =>
134-
async (input: HookInput, _toolUseID: string | undefined) => {
134+
/**
135+
* Confirms the resolved rtk binary actually runs on this host. A binary can
136+
* exist but be unexecutable — wrong arch/libc for the image, or a mac build
137+
* whose ad-hoc signing failed — and rewriting every eligible command through
138+
* it would fail them all, strictly worse than having no rtk.
139+
*/
140+
async function defaultProbeRtk(binary: string): Promise<boolean> {
141+
try {
142+
await promisify(execFile)(binary, ["--version"], { timeout: 1_500 });
143+
return true;
144+
} catch {
145+
return false;
146+
}
147+
}
148+
149+
export const createRtkRewriteHook = (
150+
rtkPrefix: string,
151+
logger: Logger,
152+
probeRtk: (binary: string) => Promise<boolean> = defaultProbeRtk,
153+
): HookCallback => {
154+
// Probed lazily on the first command that would be rewritten, then memoized
155+
// for the session: an unusable binary downgrades to no-rewrite instead of
156+
// breaking every eligible command.
157+
let usable: Promise<boolean> | null = null;
158+
159+
return async (input: HookInput, _toolUseID: string | undefined) => {
135160
if (input.hook_event_name !== "PreToolUse") return { continue: true };
136161
if (input.tool_name !== "Bash") return { continue: true };
137162

@@ -142,6 +167,16 @@ export const createRtkRewriteHook =
142167
const rewritten = rewriteBashForRtk(command, rtkPrefix);
143168
if (!rewritten) return { continue: true };
144169

170+
usable ??= probeRtk(rtkPrefix).then((ok) => {
171+
if (!ok) {
172+
logger.warn(
173+
`[RtkRewriteHook] ${rtkPrefix} failed its --version probe; disabling command rewriting for this session`,
174+
);
175+
}
176+
return ok;
177+
});
178+
if (!(await usable)) return { continue: true };
179+
145180
logger.info(`[RtkRewriteHook] Rewriting: ${command}${rewritten}`);
146181
return {
147182
continue: true,
@@ -151,3 +186,4 @@ export const createRtkRewriteHook =
151186
},
152187
};
153188
};
189+
};

0 commit comments

Comments
 (0)