Skip to content

Commit 823e1df

Browse files
OpenSource03codex
andcommitted
fix: harden Claude SDK CLI path resolution
Co-authored-by: Codex <codex@openai.com>
1 parent 56093dc commit 823e1df

4 files changed

Lines changed: 230 additions & 13 deletions

File tree

electron/src/ipc/claude-sessions.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ function buildThinkingConfig(thinkingEnabled?: boolean): { type: "adaptive" } |
214214
: { type: "adaptive" };
215215
}
216216

217+
function logSdkCliPath(context: string, cliPath?: string): void {
218+
if (cliPath) {
219+
log("SDK_CLI_PATH", `${context} path=${cliPath}`);
220+
return;
221+
}
222+
log("SDK_CLI_PATH", `${context} unresolved; relying on SDK fallback`);
223+
}
224+
217225
let modelsRevalidationPromise: Promise<{ models: Array<Record<string, unknown>>; updatedAt?: number; error?: string }> | null = null;
218226

219227
async function revalidateClaudeModelsCache(cwd?: string): Promise<{ models: Array<Record<string, unknown>>; updatedAt?: number; error?: string }> {
@@ -226,12 +234,14 @@ async function revalidateClaudeModelsCache(cwd?: string): Promise<{ models: Arra
226234

227235
try {
228236
const query = await getSDK();
237+
const cliPath = getCliPath();
238+
logSdkCliPath("models-revalidate", cliPath);
229239
const queryOptions: Record<string, unknown> = {
230240
cwd: cwd?.trim() || os.homedir(),
231241
includePartialMessages: true,
232242
thinking: buildThinkingConfig(true),
233243
settingSources: ["user", "project"],
234-
pathToClaudeCodeExecutable: getCliPath(),
244+
pathToClaudeCodeExecutable: cliPath,
235245
...fileCheckpointOptions(),
236246
};
237247

@@ -319,6 +329,8 @@ async function restartSession(
319329
const mcpServers = mcpServersOverride ?? opts.mcpServers;
320330
const query = await getSDK();
321331
const newChannel = new AsyncChannel<unknown>();
332+
const cliPath = getCliPath();
333+
logSdkCliPath(`restart session=${sessionId.slice(0, 8)}`, cliPath);
322334

323335
const newSession: SessionEntry = {
324336
channel: newChannel,
@@ -350,7 +362,7 @@ async function restartSession(
350362
thinking: buildThinkingConfig(opts.thinkingEnabled),
351363
canUseTool,
352364
settingSources: ["user", "project"],
353-
pathToClaudeCodeExecutable: getCliPath(),
365+
pathToClaudeCodeExecutable: cliPath,
354366
...fileCheckpointOptions(),
355367
resume: sessionId,
356368
stderr: (data: string) => {
@@ -439,13 +451,15 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
439451
});
440452
};
441453

454+
const cliPath = getCliPath();
455+
logSdkCliPath(`start session=${sessionId.slice(0, 8)}`, cliPath);
442456
const queryOptions: Record<string, unknown> = {
443457
cwd: options.cwd || process.cwd(),
444458
includePartialMessages: true,
445459
thinking: buildThinkingConfig(options.thinkingEnabled),
446460
canUseTool,
447461
settingSources: ["user", "project"],
448-
pathToClaudeCodeExecutable: getCliPath(),
462+
pathToClaudeCodeExecutable: cliPath,
449463
...fileCheckpointOptions(),
450464
stderr: (data: string) => {
451465
const trimmed = data.trim();

electron/src/ipc/title-gen.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ async function oneShotSdkQuery(
3232

3333
try {
3434
const query = await getSDK();
35+
const cliPath = getCliPath();
36+
if (cliPath) {
37+
log("SDK_CLI_PATH", `${logLabel} path=${cliPath}`);
38+
} else {
39+
log("SDK_CLI_PATH", `${logLabel} unresolved; relying on SDK fallback`);
40+
}
3541
let eventCount = 0;
3642
let lastEventType = "none";
3743
let lastResultSubtype = "none";
@@ -49,7 +55,7 @@ async function oneShotSdkQuery(
4955
permissionMode: "bypassPermissions",
5056
allowDangerouslySkipPermissions: true,
5157
persistSession: false,
52-
pathToClaudeCodeExecutable: getCliPath(),
58+
pathToClaudeCodeExecutable: cliPath,
5359
env: { ...process.env, ...clientAppEnv() },
5460
stderr: (data: string) => {
5561
const trimmed = data.trim();
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import path from "path";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const {
5+
mockApp,
6+
mockGetAppSetting,
7+
mockExistsSync,
8+
mockLog,
9+
} = vi.hoisted(() => ({
10+
mockApp: {
11+
isPackaged: false,
12+
getVersion: vi.fn(() => "0.16.0"),
13+
getAppPath: vi.fn(() => "/Applications/Harnss.app/Contents/Resources/app.asar"),
14+
},
15+
mockGetAppSetting: vi.fn(() => "Harnss"),
16+
mockExistsSync: vi.fn(() => false),
17+
mockLog: vi.fn(),
18+
}));
19+
20+
vi.mock("electron", () => ({
21+
app: mockApp,
22+
}));
23+
24+
vi.mock("fs", () => ({
25+
default: {
26+
existsSync: mockExistsSync,
27+
},
28+
}));
29+
30+
vi.mock("../app-settings", () => ({
31+
getAppSetting: mockGetAppSetting,
32+
}));
33+
34+
vi.mock("../logger", () => ({
35+
log: mockLog,
36+
}));
37+
38+
async function loadSdkModule() {
39+
vi.resetModules();
40+
return import("../sdk");
41+
}
42+
43+
describe("sdk path resolution", () => {
44+
beforeEach(() => {
45+
mockApp.isPackaged = false;
46+
mockApp.getAppPath.mockReset();
47+
mockApp.getAppPath.mockReturnValue("/Applications/Harnss.app/Contents/Resources/app.asar");
48+
mockGetAppSetting.mockReset();
49+
mockGetAppSetting.mockReturnValue("Harnss");
50+
mockExistsSync.mockReset();
51+
mockExistsSync.mockReturnValue(false);
52+
mockLog.mockReset();
53+
});
54+
55+
it("derives cli.js from an exported SDK entrypoint in dev", async () => {
56+
const mod = await loadSdkModule();
57+
58+
expect(
59+
mod.resolveCliPathFromEntry("/x/node_modules/@anthropic-ai/claude-agent-sdk/embed.js", false),
60+
).toBe("/x/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
61+
});
62+
63+
it("maps packaged app paths to app.asar.unpacked", async () => {
64+
const mod = await loadSdkModule();
65+
66+
expect(
67+
mod.resolveCliPathFromEntry(
68+
"/Applications/Harnss.app/Contents/Resources/app.asar/node_modules/@anthropic-ai/claude-agent-sdk/embed.js",
69+
true,
70+
),
71+
).toBe(
72+
"/Applications/Harnss.app/Contents/Resources/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
73+
);
74+
});
75+
76+
it("prefers embed resolution when the candidate exists", async () => {
77+
const embedEntry = require.resolve("@anthropic-ai/claude-agent-sdk/embed");
78+
const embedCliPath = path.join(path.dirname(embedEntry), "cli.js");
79+
mockExistsSync.mockImplementation((candidate) => candidate === embedCliPath);
80+
81+
const mod = await loadSdkModule();
82+
83+
expect(mod.getCliPath()).toBe(embedCliPath);
84+
expect(mockLog).toHaveBeenCalledWith("CLI_PATH_SELECTED", `strategy=embed path=${embedCliPath}`);
85+
});
86+
87+
it("falls back to package entry resolution when embed resolution is unavailable", async () => {
88+
const packageEntry = require.resolve("@anthropic-ai/claude-agent-sdk");
89+
const packageCliPath = path.join(path.dirname(packageEntry), "cli.js");
90+
mockExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(true);
91+
92+
const mod = await loadSdkModule();
93+
94+
expect(mod.getCliPath()).toBe(packageCliPath);
95+
expect(mockLog).toHaveBeenCalledWith("CLI_PATH_SELECTED", `strategy=package path=${packageCliPath}`);
96+
});
97+
98+
it("falls back to the packaged app path only after SDK-based strategies fail", async () => {
99+
mockApp.isPackaged = true;
100+
const packagedCliPath = "/Applications/Harnss.app/Contents/Resources/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js";
101+
mockExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValueOnce(true);
102+
103+
const mod = await loadSdkModule();
104+
105+
expect(mod.getCliPath()).toBe(packagedCliPath);
106+
expect(mockLog).toHaveBeenCalledWith("CLI_PATH_SELECTED", `strategy=app-path path=${packagedCliPath}`);
107+
});
108+
109+
it("returns undefined when no candidate exists", async () => {
110+
mockApp.isPackaged = true;
111+
mockExistsSync.mockReturnValue(false);
112+
113+
const mod = await loadSdkModule();
114+
115+
expect(mod.getCliPath()).toBeUndefined();
116+
expect(mockLog).toHaveBeenCalledWith(
117+
"CLI_PATH_MISSING",
118+
"No valid Claude CLI path resolved; SDK fallback may fail in packaged apps",
119+
);
120+
});
121+
122+
it("formats the Claude client app header from settings and app version", async () => {
123+
mockGetAppSetting.mockReturnValue("Codex Desktop");
124+
const mod = await loadSdkModule();
125+
126+
expect(mod.clientAppEnv()).toEqual({
127+
CLAUDE_AGENT_SDK_CLIENT_APP: "Codex Desktop/0.16.0",
128+
});
129+
});
130+
});

electron/src/lib/sdk.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import path from "path";
2+
import fs from "fs";
13
import { app } from "electron";
24
import { getAppSetting } from "./app-settings";
5+
import { log } from "./logger";
36

47
// Import the SDK's own types — Query is the return type of sdk.query()
58
import type { Query, query as sdkQueryFn } from "@anthropic-ai/claude-agent-sdk";
@@ -40,20 +43,84 @@ export function clientAppEnv(): Record<string, string> {
4043
return { CLAUDE_AGENT_SDK_CLIENT_APP: `${clientName}/${app.getVersion()}` };
4144
}
4245

46+
const SDK_PACKAGE = ["@anthropic-ai", "claude-agent-sdk"].join("/");
47+
const SDK_EMBED_EXPORT = [SDK_PACKAGE, "embed"].join("/");
48+
4349
/**
4450
* Resolve the SDK's cli.js path for child process spawning.
45-
* In production ASAR builds, the SDK resolves cli.js inside app.asar via import.meta.url,
46-
* but the spawned Node child process has no ASAR patching and can't read it.
47-
* We translate app.asar → app.asar.unpacked so the child process finds the real file.
51+
* In production ASAR builds, the SDK may resolve cli.js inside app.asar, but the
52+
* spawned Node child process has no ASAR patching and can't read it. We derive
53+
* cli.js from the package entrypoint and prefer app.asar.unpacked when packaged.
4854
*/
49-
export function getCliPath(): string | undefined {
55+
export function resolveCliPathFromEntry(entryPath: string, isPackaged: boolean): string {
56+
const cliPath = path.join(path.dirname(entryPath), "cli.js");
57+
if (!isPackaged) return cliPath;
58+
59+
const unpackedCliPath = cliPath.replace(/app\.asar([/\\])/, "app.asar.unpacked$1");
60+
return unpackedCliPath;
61+
}
62+
63+
/** Cached CLI path — resolved once then reused. */
64+
let _cachedCliPath: string | undefined;
65+
66+
interface CliPathResolution {
67+
strategy: "embed" | "package" | "app-path";
68+
path: string;
69+
}
70+
71+
function candidateExists(filePath: string): boolean {
5072
try {
51-
// eslint-disable-next-line @typescript-eslint/no-require-imports
52-
const cliPath = require.resolve("@anthropic-ai/claude-agent-sdk/cli.js");
53-
if (!app.isPackaged) return cliPath;
54-
// asarUnpack puts cli.js in app.asar.unpacked/ — translate for child processes
55-
return cliPath.replace(/app\.asar([/\\])/, "app.asar.unpacked$1");
73+
return fs.existsSync(filePath);
5674
} catch {
75+
return false;
76+
}
77+
}
78+
79+
function tryResolveFromEntry(specifier: string, strategy: CliPathResolution["strategy"]): CliPathResolution | undefined {
80+
try {
81+
// eslint-disable-next-line @typescript-eslint/no-require-imports
82+
const entryPath = require.resolve(specifier);
83+
const candidatePath = resolveCliPathFromEntry(entryPath, app.isPackaged);
84+
const exists = candidateExists(candidatePath);
85+
log("CLI_PATH_RESOLVE", `strategy=${strategy} entry=${entryPath} candidate=${candidatePath} exists=${exists}`);
86+
if (!exists) return undefined;
87+
return { strategy, path: candidatePath };
88+
} catch (err) {
89+
const message = err instanceof Error ? err.message : String(err);
90+
log("CLI_PATH_RESOLVE_ERR", `strategy=${strategy} specifier=${specifier} ${message}`);
5791
return undefined;
5892
}
5993
}
94+
95+
function resolveFromEmbedEntry(): CliPathResolution | undefined {
96+
return tryResolveFromEntry(SDK_EMBED_EXPORT, "embed");
97+
}
98+
99+
function resolveFromPackageEntry(): CliPathResolution | undefined {
100+
return tryResolveFromEntry(SDK_PACKAGE, "package");
101+
}
102+
103+
function resolveFromAppPath(): CliPathResolution | undefined {
104+
if (!app.isPackaged) return undefined;
105+
106+
const unpackedBase = app.getAppPath().replace(/app\.asar$/, "app.asar.unpacked");
107+
const candidatePath = path.join(unpackedBase, "node_modules", "@anthropic-ai", "claude-agent-sdk", "cli.js");
108+
const exists = candidateExists(candidatePath);
109+
log("CLI_PATH_RESOLVE", `strategy=app-path candidate=${candidatePath} exists=${exists}`);
110+
if (!exists) return undefined;
111+
return { strategy: "app-path", path: candidatePath };
112+
}
113+
114+
export function getCliPath(): string | undefined {
115+
if (_cachedCliPath) return _cachedCliPath;
116+
117+
const resolution = resolveFromEmbedEntry() ?? resolveFromPackageEntry() ?? resolveFromAppPath();
118+
if (resolution) {
119+
_cachedCliPath = resolution.path;
120+
log("CLI_PATH_SELECTED", `strategy=${resolution.strategy} path=${resolution.path}`);
121+
return resolution.path;
122+
}
123+
124+
log("CLI_PATH_MISSING", "No valid Claude CLI path resolved; SDK fallback may fail in packaged apps");
125+
return undefined;
126+
}

0 commit comments

Comments
 (0)