From 193216bd1853cdb6d4c694f2d6e5334445a96558 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Wed, 2 Sep 2026 19:44:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(claude):=20=E9=9A=94=E7=A6=BB=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=8E=A2=E6=B5=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 能力探测固定使用中性临时目录,并将目标配置目录显式传递给 Claude CLI。\n- 相对配置路径按工作区解析;HOME 或 USERPROFILE 覆盖会形成独立缓存键。\n- 保留最新的维护能力缓存、二进制路径展开和跨平台临时目录清理。\n- 验证:Claude 定向测试 30/30、格式检查和服务端类型检查通过。 --- .../src/provider/Drivers/ClaudeDriver.ts | 26 +++++-- .../src/provider/Drivers/ClaudeHome.test.ts | 71 +++++++++++++++++-- .../server/src/provider/Drivers/ClaudeHome.ts | 67 ++++++++++++++++- .../src/provider/Drivers/ClaudeSkills.ts | 32 +-------- .../Layers/ClaudeCapabilitiesProbe.test.ts | 34 +++++++-- .../src/provider/Layers/ClaudeProvider.ts | 6 ++ 6 files changed, 186 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..b33e143a6606 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -59,7 +59,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeCapabilitiesProbeContext, + makeClaudeContinuationGroupKey, +} from "./ClaudeHome.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -125,6 +129,11 @@ export const ClaudeDriver: ProviderDriver = { enabled, binaryPath: expandHomePath(config.binaryPath), } satisfies ClaudeSettings; + const capabilitiesProbeContext = yield* makeClaudeCapabilitiesProbeContext( + effectiveConfig, + processEnv, + cwd, + ); const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, @@ -167,11 +176,18 @@ export const ClaudeDriver: ProviderDriver = { capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( - Effect.provideService(Path.Path, path), - ), + probeClaudeCapabilities( + effectiveConfig, + capabilitiesProbeContext.environment, + capabilitiesProbeContext.cwd, + cwd, + ).pipe(Effect.provideService(Path.Path, path)), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( + effectiveConfig, + processEnv, + cwd, + ); // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 33b11237547e..d31aab6f79b2 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -8,8 +8,10 @@ import * as Path from "effect/Path"; import { claudeSignedOutMessage, makeClaudeCapabilitiesCacheKey, + makeClaudeCapabilitiesProbeContext, makeClaudeContinuationGroupKey, makeClaudeEnvironment, + resolveClaudeConfigDirPath, resolveClaudeHomePath, } from "./ClaudeHome.ts"; @@ -21,10 +23,68 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { const resolved = path.resolve(NodeOS.homedir()); expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); + expect(yield* resolveClaudeConfigDirPath({ homePath: "" })).toBe( + path.join(resolved, ".claude"), + ); + expect((yield* makeClaudeCapabilitiesProbeContext({ homePath: "" })).cwd).toBe( + path.resolve(NodeOS.tmpdir()), + ); expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); }), ); + it.effect("resolves an environment config directory against the workspace cwd", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const workspaceCwd = path.join(NodeOS.tmpdir(), "t3-claude-workspace"); + const environment = { CLAUDE_CONFIG_DIR: "profile" }; + + expect(yield* resolveClaudeConfigDirPath({ homePath: "" }, environment, workspaceCwd)).toBe( + path.join(workspaceCwd, "profile"), + ); + const context = yield* makeClaudeCapabilitiesProbeContext( + { homePath: "" }, + environment, + workspaceCwd, + ); + expect(context.cwd).toBe(path.resolve(NodeOS.tmpdir())); + expect(context.environment.CLAUDE_CONFIG_DIR).toBe(path.join(workspaceCwd, "profile")); + expect( + yield* makeClaudeCapabilitiesCacheKey( + { binaryPath: "claude", homePath: "" }, + environment, + workspaceCwd, + ), + ).toBe(`claude\0${path.join(workspaceCwd, "profile")}`); + }), + ); + + it.effect("uses inherited HOME or USERPROFILE for the default config directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homeEnvironment = { HOME: path.join(NodeOS.tmpdir(), "claude-home-a") }; + const userProfileEnvironment = { USERPROFILE: path.join(NodeOS.tmpdir(), "claude-home-b") }; + + expect(yield* resolveClaudeConfigDirPath({ homePath: "" }, homeEnvironment)).toBe( + path.join(homeEnvironment.HOME, ".claude"), + ); + expect(yield* resolveClaudeConfigDirPath({ homePath: "" }, userProfileEnvironment)).toBe( + path.join(userProfileEnvironment.USERPROFILE, ".claude"), + ); + expect( + yield* makeClaudeCapabilitiesCacheKey( + { binaryPath: "claude", homePath: "" }, + homeEnvironment, + ), + ).not.toBe( + yield* makeClaudeCapabilitiesCacheKey( + { binaryPath: "claude", homePath: "" }, + userProfileEnvironment, + ), + ); + }), + ); + it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -35,7 +95,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}\0`, + `claude\0${resolved}`, ); }), ); @@ -51,11 +111,12 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(message).toContain("then start a new thread"); }); - it.effect("separates capability probes by cwd", () => + it.effect("separates capability probes by their resolved configuration directories", () => Effect.gen(function* () { - const config = { binaryPath: "claude", homePath: "" }; - const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); - const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + const firstConfig = { binaryPath: "claude", homePath: "~/.claude-first" }; + const secondConfig = { binaryPath: "claude", homePath: "~/.claude-second" }; + const first = yield* makeClaudeCapabilitiesCacheKey(firstConfig); + const second = yield* makeClaudeCapabilitiesCacheKey(secondConfig); expect(first).not.toBe(second); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index bbd005a1e00b..5046c71055b8 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -17,6 +17,66 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function return path.resolve(homePath.length > 0 ? expandHomePath(homePath) : NodeOS.homedir()); }); +/** + * Resolve the Claude config directory the spawned CLI uses. An explicit + * provider override wins, followed by the instance environment, then + * Claude's default `$HOME/.claude` location. + */ +export const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return yield* resolveClaudeHomePath(config); + } + + const resolvedEnvironment = environment ?? process.env; + // No tilde expansion here: the spawned CLI receives this env var verbatim + // (env vars are never shell-expanded), so a literal `~` must stay literal + // for discovery to scan the same directory the runtime would. A relative + // value is resolved against the workspace cwd — the subprocess's own cwd — + // for the same reason. + const environmentConfigDir = resolvedEnvironment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); + } + const inheritedHome = + resolvedEnvironment.HOME?.trim() || resolvedEnvironment.USERPROFILE?.trim() || NodeOS.homedir(); + return path.join(path.resolve(inheritedHome), ".claude"); +}); + +/** + * Capability probes run from an existing neutral cwd because Claude treats + * `/.claude/settings.json` as project settings. The intended config dir + * remains available through CLAUDE_CONFIG_DIR, including when it does not yet + * exist or arrived as a relative inherited environment variable. + */ +export const makeClaudeCapabilitiesProbeContext = Effect.fn("makeClaudeCapabilitiesProbeContext")( + function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + workspaceCwd?: string, + ): Effect.fn.Return< + { readonly cwd: string; readonly environment: NodeJS.ProcessEnv }, + never, + Path.Path + > { + const path = yield* Path.Path; + const resolvedEnvironment = environment ?? process.env; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, workspaceCwd); + return { + cwd: path.resolve(NodeOS.tmpdir()), + environment: { + ...resolvedEnvironment, + CLAUDE_CONFIG_DIR: configDirPath, + }, + }; + }, +); + export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( config: Pick, baseEnv?: NodeJS.ProcessEnv, @@ -47,10 +107,11 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( function* ( config: Pick, - cwd?: string, + environment?: NodeJS.ProcessEnv, + workspaceCwd?: string, ): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config); - return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; + const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, workspaceCwd); + return `${config.binaryPath}\0${configDirPath}`; }, ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 236fe79f518c..7428b578cd91 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -13,8 +13,6 @@ * * @module provider/Drivers/ClaudeSkills */ -import * as NodeOS from "node:os"; - import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -24,7 +22,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { resolveClaudeConfigDirPath } from "./ClaudeHome.ts"; type ClaudeSkillScope = "user" | "project"; @@ -267,34 +265,6 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( return overridesByName; }); -/** - * Resolve the Claude config directory the CLI would use, matching the - * precedence the spawned CLI sees: the instance's `homePath` (exported as - * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` - * already present in the process environment, then `~/.claude`. - */ -const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( - config: Pick, - environment: NodeJS.ProcessEnv, - cwd?: string, -): Effect.fn.Return { - const path = yield* Path.Path; - const homePath = config.homePath.trim(); - if (homePath.length > 0) { - return path.resolve(expandHomePath(homePath)); - } - // No tilde expansion here: the spawned CLI receives this env var verbatim - // (env vars are never shell-expanded), so a literal `~` must stay literal - // for discovery to scan the same directory the runtime would. A relative - // value is resolved against the workspace cwd — the subprocess's own cwd — - // for the same reason. - const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; - if (environmentConfigDir.length > 0) { - return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); - } - return path.join(NodeOS.homedir(), ".claude"); -}); - /** * Enumerate Claude Code skills from the user config dir and the workspace * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..fa4e6b534b2e 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -4,6 +4,7 @@ import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; +import * as NodeOS from "node:os"; import { ClaudeSettings } from "@t3tools/contracts"; import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -13,6 +14,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { makeClaudeCapabilitiesProbeContext } from "../Drivers/ClaudeHome.ts"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, @@ -33,12 +35,14 @@ it("isolates Claude capability probes without dropping workspace setting sources ENABLE_CLAUDEAI_MCP_SERVERS: "true", FORCE_CODE_TERMINAL: "1", }, - cwd: "/workspace/project", + cwd: "/config/claude", + workspaceCwd: "/workspace/project", }); assert.deepEqual(options.mcpServers, {}); assert.equal(options.strictMcpConfig, true); - assert.equal(options.cwd, "/workspace/project"); + assert.equal(options.cwd, "/config/claude"); + assert.deepEqual(options.additionalDirectories, ["/workspace/project"]); assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); assert.deepEqual(options.settings, { disableAllHooks: true }); assert.deepEqual(options.allowedTools, []); @@ -53,13 +57,14 @@ it("isolates Claude capability probes without dropping workspace setting sources }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + it.effect("runs from an existing neutral cwd when the Claude config dir is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); const executablePath = path.join(tempDir, "fake-claude.mjs"); const invocationPath = path.join(tempDir, "invocation.json"); + const claudeConfigDir = path.join(tempDir, "missing-claude-config"); // The probe aborts the SDK without awaiting the child's exit, and on // Windows a directory that is still some process's cwd cannot be // removed. Keep the workspace outside the scoped directory and let it @@ -97,6 +102,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", " args,", " cwd: process.cwd(),", + " configDir: process.env.CLAUDE_CONFIG_DIR,", " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", " mcpConfig,", "}));", @@ -135,8 +141,12 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ); yield* fs.chmod(executablePath, 0o755); - const capabilities = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: executablePath }), + const settings = decodeClaudeSettings({ + binaryPath: executablePath, + homePath: claudeConfigDir, + }); + const context = yield* makeClaudeCapabilitiesProbeContext( + settings, { ...process.env, T3_PROBE_INVOCATION_PATH: invocationPath, @@ -144,6 +154,12 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }, workspaceCwd, ); + const capabilities = yield* probeClaudeCapabilities( + settings, + context.environment, + context.cwd, + workspaceCwd, + ); assert.deepEqual(capabilities, { email: "dev@example.com", @@ -167,16 +183,22 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { readonly args: ReadonlyArray; readonly cwd: string; + readonly configDir: string; readonly connectorEnv: string; readonly mcpConfig: unknown; }; - assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.cwd, yield* fs.realPath(NodeOS.tmpdir())); + assert.notEqual(invocation.cwd, yield* fs.realPath(workspaceCwd)); + assert.equal(invocation.configDir, path.resolve(claudeConfigDir)); assert.equal(invocation.connectorEnv, "false"); assert.equal(invocation.args.includes("--strict-mcp-config"), true); assert.equal(invocation.args.includes("--mcp-config"), false); assert.equal(invocation.mcpConfig, undefined); assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); + const addDirectoryFlagIndex = invocation.args.indexOf("--add-dir"); + assert.notEqual(addDirectoryFlagIndex, -1); + assert.equal(invocation.args[addDirectoryFlagIndex + 1], workspaceCwd); const settingsFlagIndex = invocation.args.indexOf("--settings"); assert.notEqual(settingsFlagIndex, -1); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..4d4d1bdb4053 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -186,6 +186,7 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { readonly abortController: AbortController; readonly environment: NodeJS.ProcessEnv; readonly cwd: string | undefined; + readonly workspaceCwd: string | undefined; }): ClaudeQueryOptions { return { persistSession: false, @@ -214,6 +215,9 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.workspaceCwd && input.workspaceCwd !== input.cwd + ? { additionalDirectories: [input.workspaceCwd] } + : {}), stderr: () => {}, }; } @@ -331,6 +335,7 @@ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, + workspaceCwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -352,6 +357,7 @@ const probeClaudeCapabilities = ( abortController: abort, environment: claudeEnvironment, cwd, + workspaceCwd, }), }); const init = await q.initializationResult();