Skip to content

Commit 8c95491

Browse files
authored
fix: Claude catalog terminals bypass failed npm shims (#108627)
* fix(anthropic): prefer native Claude catalog terminals * chore: keep release notes in PR body
1 parent 6bbb99b commit 8c95491

2 files changed

Lines changed: 245 additions & 7 deletions

File tree

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,153 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
14
import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host";
25

3-
// Claude's npm shim can remain executable after its postinstall failed. Prefer
4-
// the operator's login-shell command, then carry that PATH into the PTY.
6+
const BROKEN_NPM_SHIM_MARKER = "Error: claude native binary not installed.";
7+
const BROKEN_NPM_INSTALL_HINT = "node_modules/@anthropic-ai/claude-code/install.cjs";
8+
const CLAUDE_PACKAGE_SHIM_TARGET =
9+
/(?:\$basedir|%dp0%)\/([^"'\r\n]*?node_modules\/@anthropic-ai\/claude-code\/bin\/claude\.exe)/giu;
10+
const MAX_SHIM_PROBE_BYTES = 4096;
11+
12+
let cachedNativeReplacement: { key: string; executable: string | null } | undefined;
13+
14+
function currentHomeDir(env: NodeJS.ProcessEnv): string {
15+
return env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
16+
}
17+
18+
function readSmallExecutableSource(executable: string):
19+
| {
20+
realPath: string;
21+
source: string;
22+
}
23+
| undefined {
24+
try {
25+
const realPath = fs.realpathSync(executable);
26+
if (fs.statSync(realPath).size > MAX_SHIM_PROBE_BYTES) {
27+
return undefined;
28+
}
29+
return { realPath, source: fs.readFileSync(realPath, "utf8") };
30+
} catch {
31+
return undefined;
32+
}
33+
}
34+
35+
function isFailedClaudeNpmPlaceholder(source: string): boolean {
36+
return source.includes(BROKEN_NPM_SHIM_MARKER) && source.includes(BROKEN_NPM_INSTALL_HINT);
37+
}
38+
39+
function isBrokenClaudeNpmShim(executable: string): boolean {
40+
const shim = readSmallExecutableSource(executable);
41+
if (!shim) {
42+
return false;
43+
}
44+
if (isFailedClaudeNpmPlaceholder(shim.source)) {
45+
return true;
46+
}
47+
// npm's cross-platform command shims call the package binary through
48+
// $basedir or %dp0%; inspect that bounded target without executing the shim.
49+
const normalizedSource = shim.source.replaceAll("\\", "/");
50+
const baseDirectories = new Set([path.dirname(executable), path.dirname(shim.realPath)]);
51+
for (const match of normalizedSource.matchAll(CLAUDE_PACKAGE_SHIM_TARGET)) {
52+
const relativeTarget = match[1];
53+
if (!relativeTarget) {
54+
continue;
55+
}
56+
for (const baseDirectory of baseDirectories) {
57+
const target = readSmallExecutableSource(path.resolve(baseDirectory, relativeTarget));
58+
if (target && isFailedClaudeNpmPlaceholder(target.source)) {
59+
return true;
60+
}
61+
}
62+
}
63+
return false;
64+
}
65+
66+
function resolveExecutableFromDirectory(
67+
directory: string,
68+
env: NodeJS.ProcessEnv,
69+
): string | undefined {
70+
const resolution = resolveNodeHostExecutable("claude", {
71+
env,
72+
pathEnv: directory,
73+
strategy: "direct",
74+
});
75+
return resolution && !isBrokenClaudeNpmShim(resolution.executable)
76+
? resolution.executable
77+
: undefined;
78+
}
79+
80+
function resolveClaudeDesktopExecutable(
81+
homeDir: string,
82+
env: NodeJS.ProcessEnv,
83+
): string | undefined {
84+
if (process.platform !== "darwin") {
85+
return undefined;
86+
}
87+
const versionsRoot = path.join(
88+
homeDir,
89+
"Library",
90+
"Application Support",
91+
"Claude",
92+
"claude-code",
93+
);
94+
let versions: fs.Dirent[];
95+
try {
96+
versions = fs.readdirSync(versionsRoot, { withFileTypes: true });
97+
} catch {
98+
return undefined;
99+
}
100+
versions.sort((left, right) =>
101+
right.name.localeCompare(left.name, undefined, { numeric: true, sensitivity: "base" }),
102+
);
103+
for (const version of versions) {
104+
if (!version.isDirectory()) {
105+
continue;
106+
}
107+
const executable = resolveExecutableFromDirectory(
108+
path.join(versionsRoot, version.name, "claude.app", "Contents", "MacOS"),
109+
env,
110+
);
111+
if (executable) {
112+
return executable;
113+
}
114+
}
115+
return undefined;
116+
}
117+
118+
function resolveNativeReplacement(env: NodeJS.ProcessEnv): string | undefined {
119+
const homeDir = currentHomeDir(env);
120+
const cacheKey = `${process.platform}\0${homeDir}`;
121+
if (cachedNativeReplacement?.key === cacheKey) {
122+
return cachedNativeReplacement.executable ?? undefined;
123+
}
124+
// Claude installations are process-stable metadata. Cache the lookup so
125+
// catalog requests do not poll the filesystem; installs take effect on restart.
126+
const executable =
127+
resolveExecutableFromDirectory(path.join(homeDir, ".local", "bin"), env) ??
128+
resolveClaudeDesktopExecutable(homeDir, env);
129+
cachedNativeReplacement = { key: cacheKey, executable: executable ?? null };
130+
return executable;
131+
}
132+
133+
// Anthropic's failed npm postinstall leaves an executable placeholder that can
134+
// never launch. Keep normal shell wrappers, but replace that known failure with
135+
// a native installer or Claude Desktop binary and retain the user's shell PATH.
5136
export function resolveClaudeTerminalExecutable(env: NodeJS.ProcessEnv = process.env) {
6-
return resolveNodeHostExecutable("claude", {
137+
const shellResolution = resolveNodeHostExecutable("claude", {
7138
env,
8139
pathEnv: env.PATH ?? env.Path ?? "",
9140
strategy: "prefer",
10141
});
142+
if (shellResolution && !isBrokenClaudeNpmShim(shellResolution.executable)) {
143+
return shellResolution;
144+
}
145+
const nativeExecutable = resolveNativeReplacement(env);
146+
if (!nativeExecutable) {
147+
return undefined;
148+
}
149+
return {
150+
executable: nativeExecutable,
151+
...(shellResolution?.pathEnv ? { pathEnv: shellResolution.pathEnv } : {}),
152+
};
11153
}

extensions/anthropic/session-catalog.test.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,40 @@ async function writeDesktopMetadata(
138138
await fs.writeFile(path.join(dir, `local_${name}.json`), JSON.stringify(metadata));
139139
}
140140

141+
async function writeBrokenClaudeNpmShim(binDir: string): Promise<string> {
142+
await fs.mkdir(binDir, { recursive: true });
143+
const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude");
144+
const packageExecutable = path.join(
145+
binDir,
146+
"node_modules",
147+
"@anthropic-ai",
148+
"claude-code",
149+
"bin",
150+
"claude.exe",
151+
);
152+
await fs.mkdir(path.dirname(packageExecutable), { recursive: true });
153+
await fs.writeFile(
154+
packageExecutable,
155+
[
156+
'echo "Error: claude native binary not installed." >&2',
157+
'echo "node node_modules/@anthropic-ai/claude-code/install.cjs" >&2',
158+
"exit 1",
159+
"",
160+
].join("\n"),
161+
);
162+
await fs.writeFile(
163+
executable,
164+
process.platform === "win32"
165+
? '@ECHO off\r\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n'
166+
: '#!/bin/sh\nexec "$basedir/node_modules/@anthropic-ai/claude-code/bin/claude.exe" "$@"\n',
167+
);
168+
if (process.platform !== "win32") {
169+
await fs.chmod(executable, 0o755);
170+
await fs.chmod(packageExecutable, 0o755);
171+
}
172+
return executable;
173+
}
174+
141175
function message(
142176
sessionId: string,
143177
type: "user" | "assistant",
@@ -159,6 +193,7 @@ function message(
159193
}
160194

161195
afterEach(async () => {
196+
vi.restoreAllMocks();
162197
nodeHostMocks.runNodePtyCommand.mockClear();
163198
nodeHostMocks.userShellPaths.clear();
164199
process.env.HOME = originalHome;
@@ -1089,7 +1124,8 @@ describe("Claude session catalog", () => {
10891124
).rejects.toThrow("Claude session cannot be resumed in a terminal");
10901125
});
10911126

1092-
it("prefers the login-shell Claude binary for local terminal capability and plans", async () => {
1127+
it("replaces a broken npm shim with Claude Desktop's newest native binary", async () => {
1128+
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
10931129
const home = await createHome();
10941130
process.env.HOME = home;
10951131
const sessionId = "claude-session-1";
@@ -1135,17 +1171,43 @@ describe("Claude session catalog", () => {
11351171
},
11361172
} as unknown as OpenClawPluginApi);
11371173

1138-
await fs.writeFile(path.join(shellBinDir, "claude"), "#!/bin/sh\n");
1139-
await fs.chmod(path.join(shellBinDir, "claude"), 0o755);
1174+
await writeBrokenClaudeNpmShim(shellBinDir);
11401175
nodeHostMocks.userShellPaths.set("claude", shellBinDir);
1176+
const desktopVersionRoot = path.join(
1177+
home,
1178+
"Library",
1179+
"Application Support",
1180+
"Claude",
1181+
"claude-code",
1182+
);
1183+
for (const version of ["2.1.9", "2.1.10"]) {
1184+
const desktopBinDir = path.join(
1185+
desktopVersionRoot,
1186+
version,
1187+
"claude.app",
1188+
"Contents",
1189+
"MacOS",
1190+
);
1191+
await fs.mkdir(desktopBinDir, { recursive: true });
1192+
await fs.writeFile(path.join(desktopBinDir, "claude"), "#!/bin/sh\nexit 0\n");
1193+
await fs.chmod(path.join(desktopBinDir, "claude"), 0o755);
1194+
}
1195+
const desktopExecutable = path.join(
1196+
desktopVersionRoot,
1197+
"2.1.10",
1198+
"claude.app",
1199+
"Contents",
1200+
"MacOS",
1201+
"claude",
1202+
);
11411203
await expect(provider?.list({})).resolves.toMatchObject([
11421204
{ sessions: [{ threadId: sessionId, canOpenTerminal: true }] },
11431205
]);
11441206
await expect(
11451207
provider?.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }),
11461208
).resolves.toMatchObject({
11471209
kind: "local",
1148-
argv: [path.join(shellBinDir, "claude"), "--resume", sessionId],
1210+
argv: [desktopExecutable, "--resume", sessionId],
11491211
cwd: home,
11501212
pathEnv: shellBinDir,
11511213
});
@@ -1154,6 +1216,40 @@ describe("Claude session catalog", () => {
11541216
).rejects.toThrow("Claude session is unavailable");
11551217
});
11561218

1219+
it("hides terminal capability when the failed npm shim has no native replacement", async () => {
1220+
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
1221+
const home = await createHome();
1222+
process.env.HOME = home;
1223+
const sessionId = "claude-session-broken-shim";
1224+
await writeProject({
1225+
home,
1226+
entries: [
1227+
{
1228+
sessionId,
1229+
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
1230+
projectPath: home,
1231+
summary: "Broken shim session",
1232+
},
1233+
],
1234+
transcripts: { [sessionId]: [message(sessionId, "user", "hello", 1)] },
1235+
});
1236+
const shellBinDir = path.join(home, "shell-bin");
1237+
await writeBrokenClaudeNpmShim(shellBinDir);
1238+
process.env.PATH = shellBinDir;
1239+
nodeHostMocks.userShellPaths.set("claude", shellBinDir);
1240+
const provider = captureCatalogProvider({
1241+
config: { current: () => ({}) },
1242+
nodes: { list: async () => ({ nodes: [] }) },
1243+
} as unknown as PluginRuntime);
1244+
1245+
await expect(provider.list({})).resolves.toMatchObject([
1246+
{ sessions: [{ threadId: sessionId, canOpenTerminal: false }] },
1247+
]);
1248+
await expect(
1249+
provider.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }),
1250+
).rejects.toThrow("Claude CLI is unavailable");
1251+
});
1252+
11571253
it("keeps one failed node isolated from healthy hosts", async () => {
11581254
const runtime = {
11591255
nodes: {

0 commit comments

Comments
 (0)