Files
openclaw/extensions/anthropic/session-catalog-executable.ts
Peter Steinberger 8c95491039 fix: Claude catalog terminals bypass failed npm shims (#108627)
* fix(anthropic): prefer native Claude catalog terminals

* chore: keep release notes in PR body
2026-07-15 22:17:02 -07:00

154 lines
4.9 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host";
const BROKEN_NPM_SHIM_MARKER = "Error: claude native binary not installed.";
const BROKEN_NPM_INSTALL_HINT = "node_modules/@anthropic-ai/claude-code/install.cjs";
const CLAUDE_PACKAGE_SHIM_TARGET =
/(?:\$basedir|%dp0%)\/([^"'\r\n]*?node_modules\/@anthropic-ai\/claude-code\/bin\/claude\.exe)/giu;
const MAX_SHIM_PROBE_BYTES = 4096;
let cachedNativeReplacement: { key: string; executable: string | null } | undefined;
function currentHomeDir(env: NodeJS.ProcessEnv): string {
return env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
}
function readSmallExecutableSource(executable: string):
| {
realPath: string;
source: string;
}
| undefined {
try {
const realPath = fs.realpathSync(executable);
if (fs.statSync(realPath).size > MAX_SHIM_PROBE_BYTES) {
return undefined;
}
return { realPath, source: fs.readFileSync(realPath, "utf8") };
} catch {
return undefined;
}
}
function isFailedClaudeNpmPlaceholder(source: string): boolean {
return source.includes(BROKEN_NPM_SHIM_MARKER) && source.includes(BROKEN_NPM_INSTALL_HINT);
}
function isBrokenClaudeNpmShim(executable: string): boolean {
const shim = readSmallExecutableSource(executable);
if (!shim) {
return false;
}
if (isFailedClaudeNpmPlaceholder(shim.source)) {
return true;
}
// npm's cross-platform command shims call the package binary through
// $basedir or %dp0%; inspect that bounded target without executing the shim.
const normalizedSource = shim.source.replaceAll("\\", "/");
const baseDirectories = new Set([path.dirname(executable), path.dirname(shim.realPath)]);
for (const match of normalizedSource.matchAll(CLAUDE_PACKAGE_SHIM_TARGET)) {
const relativeTarget = match[1];
if (!relativeTarget) {
continue;
}
for (const baseDirectory of baseDirectories) {
const target = readSmallExecutableSource(path.resolve(baseDirectory, relativeTarget));
if (target && isFailedClaudeNpmPlaceholder(target.source)) {
return true;
}
}
}
return false;
}
function resolveExecutableFromDirectory(
directory: string,
env: NodeJS.ProcessEnv,
): string | undefined {
const resolution = resolveNodeHostExecutable("claude", {
env,
pathEnv: directory,
strategy: "direct",
});
return resolution && !isBrokenClaudeNpmShim(resolution.executable)
? resolution.executable
: undefined;
}
function resolveClaudeDesktopExecutable(
homeDir: string,
env: NodeJS.ProcessEnv,
): string | undefined {
if (process.platform !== "darwin") {
return undefined;
}
const versionsRoot = path.join(
homeDir,
"Library",
"Application Support",
"Claude",
"claude-code",
);
let versions: fs.Dirent[];
try {
versions = fs.readdirSync(versionsRoot, { withFileTypes: true });
} catch {
return undefined;
}
versions.sort((left, right) =>
right.name.localeCompare(left.name, undefined, { numeric: true, sensitivity: "base" }),
);
for (const version of versions) {
if (!version.isDirectory()) {
continue;
}
const executable = resolveExecutableFromDirectory(
path.join(versionsRoot, version.name, "claude.app", "Contents", "MacOS"),
env,
);
if (executable) {
return executable;
}
}
return undefined;
}
function resolveNativeReplacement(env: NodeJS.ProcessEnv): string | undefined {
const homeDir = currentHomeDir(env);
const cacheKey = `${process.platform}\0${homeDir}`;
if (cachedNativeReplacement?.key === cacheKey) {
return cachedNativeReplacement.executable ?? undefined;
}
// Claude installations are process-stable metadata. Cache the lookup so
// catalog requests do not poll the filesystem; installs take effect on restart.
const executable =
resolveExecutableFromDirectory(path.join(homeDir, ".local", "bin"), env) ??
resolveClaudeDesktopExecutable(homeDir, env);
cachedNativeReplacement = { key: cacheKey, executable: executable ?? null };
return executable;
}
// Anthropic's failed npm postinstall leaves an executable placeholder that can
// never launch. Keep normal shell wrappers, but replace that known failure with
// a native installer or Claude Desktop binary and retain the user's shell PATH.
export function resolveClaudeTerminalExecutable(env: NodeJS.ProcessEnv = process.env) {
const shellResolution = resolveNodeHostExecutable("claude", {
env,
pathEnv: env.PATH ?? env.Path ?? "",
strategy: "prefer",
});
if (shellResolution && !isBrokenClaudeNpmShim(shellResolution.executable)) {
return shellResolution;
}
const nativeExecutable = resolveNativeReplacement(env);
if (!nativeExecutable) {
return undefined;
}
return {
executable: nativeExecutable,
...(shellResolution?.pathEnv ? { pathEnv: shellResolution.pathEnv } : {}),
};
}