diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts new file mode 100644 index 00000000000..2330d5bab2a --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,74 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +import { assert, it } from "@effect/vitest"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + resolveClaudeCapabilitiesProbeCwd, + resolveClaudeCapabilitiesProbeHome, +} from "./ClaudeProvider.ts"; + +it("isolates Claude capability probes from user MCP servers", () => { + const abort = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + binaryPath: "/usr/bin/claude", + abortController: abort, + env: { HOME: "/home/user", PATH: "/usr/bin" }, + cwd: "/tmp/t3-claude-capability-probe", + }); + + assert.deepEqual(options.mcpServers, {}); + assert.equal(options.strictMcpConfig, true); + assert.equal(options.cwd, "/tmp/t3-claude-capability-probe"); + assert.equal(options.persistSession, false); + assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); + assert.equal(options.abortController, abort); + assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); + assert.deepEqual(options.allowedTools, []); + assert.equal(options.env?.HOME, "/home/user"); + assert.equal(options.env?.PATH, "/usr/bin"); + assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); +}); + +it("places the Claude capability probe cwd under an absolute home", () => { + assert.equal( + resolveClaudeCapabilitiesProbeCwd("/Users/example"), + join(resolve("/Users/example"), ".t3", "claude-capability-probe"), + ); +}); + +it("expands a bare ~ home before joining the probe cwd", () => { + assert.equal( + resolveClaudeCapabilitiesProbeCwd("~"), + join(homedir(), ".t3", "claude-capability-probe"), + ); +}); + +it("expands ~/… and resolves relative homes to absolute probe cwds", () => { + assert.equal( + resolveClaudeCapabilitiesProbeCwd("~/.claude-work"), + join(homedir(), ".claude-work", ".t3", "claude-capability-probe"), + ); + assert.equal( + resolveClaudeCapabilitiesProbeCwd("relative-home"), + join(resolve("relative-home"), ".t3", "claude-capability-probe"), + ); +}); + +it("aligns probe env HOME with the resolved probe cwd parent", () => { + const probeHome = resolveClaudeCapabilitiesProbeHome("~"); + const probeCwd = resolveClaudeCapabilitiesProbeCwd(probeHome); + const abort = new AbortController(); + const options = buildClaudeCapabilitiesProbeQueryOptions({ + binaryPath: "/usr/bin/claude", + abortController: abort, + env: { HOME: probeHome, PATH: "/usr/bin" }, + cwd: probeCwd, + }); + + assert.equal(options.env?.HOME, probeHome); + assert.equal(options.cwd, probeCwd); + assert.equal(probeCwd, join(probeHome, ".t3", "claude-capability-probe")); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..4db502c689e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -22,9 +22,14 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, + type SettingSource, } from "@anthropic-ai/claude-agent-sdk"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { buildBooleanOptionDescriptor, @@ -37,6 +42,7 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -483,6 +489,63 @@ function claudeAuthMetadata(input: { const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +/** + * Filesystem setting sources still consulted for account/slash-command init. + * MCP servers from those sources are suppressed via `strictMcpConfig` + empty + * `mcpServers` — capability probes must not boot user MCP (#3909). + */ +export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** Absolute Claude HOME for capability-probe cwd + env alignment. */ +export function resolveClaudeCapabilitiesProbeHome(homeDir = NodeOS.homedir()): string { + const trimmed = homeDir.trim(); + return trimmed.length > 0 ? NodePath.resolve(expandHomePath(trimmed)) : NodeOS.homedir(); +} + +/** Stable empty cwd under ~/.t3 so probes never inherit $HOME / server cwd. */ +export function resolveClaudeCapabilitiesProbeCwd(homeDir = NodeOS.homedir()): string { + return NodePath.join( + resolveClaudeCapabilitiesProbeHome(homeDir), + ".t3", + "claude-capability-probe", + ); +} + +/** + * Build Claude Agent SDK `query()` options for the periodic capability probe. + * + * Isolates the probe from user MCP servers and claude.ai connectors while + * still allowing setting-source slash-command discovery. + */ +export function buildClaudeCapabilitiesProbeQueryOptions(input: { + readonly binaryPath: string; + readonly abortController: AbortController; + readonly env: NodeJS.ProcessEnv; + readonly cwd: string; +}): ClaudeQueryOptions { + return { + persistSession: false, + pathToClaudeCodeExecutable: input.binaryPath, + abortController: input.abortController, + cwd: input.cwd, + settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], + // Only honor the empty map below — ignore user/project/local MCP configs + // and claude.ai connectors that would otherwise spawn on every refresh. + mcpServers: {}, + strictMcpConfig: true, + allowedTools: [], + env: { + ...input.env, + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + stderr: () => {}, + }; +} + function nonEmptyProbeString(value: string): string | undefined { const candidate = value.trim(); return candidate ? candidate : undefined; @@ -579,6 +642,10 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * * This is used as a fallback when `claude auth status` does not include * subscription type information. + * + * The probe runs on a ~5 minute refresh loop and must not boot user MCP + * servers or inherit the server process cwd (#3909). Options come from + * `buildClaudeCapabilitiesProbeQueryOptions`. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, @@ -587,6 +654,17 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + // Normalize once so SDK cwd and env.HOME agree (raw HOME may be `~` / relative). + const probeHome = resolveClaudeCapabilitiesProbeHome(claudeEnvironment.HOME || undefined); + const probeCwd = resolveClaudeCapabilitiesProbeCwd(probeHome); + const probeEnvironment = { + ...claudeEnvironment, + HOME: probeHome, + }; + yield* Effect.tryPromise({ + try: () => NodeFS.promises.mkdir(probeCwd, { recursive: true }), + catch: (cause) => cause, + }); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -595,15 +673,12 @@ const probeClaudeCapabilities = ( prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), - options: { - persistSession: false, - pathToClaudeCodeExecutable: claudeSettings.binaryPath, + options: buildClaudeCapabilitiesProbeQueryOptions({ + binaryPath: claudeSettings.binaryPath, abortController: abort, - settingSources: ["user", "project", "local"], - allowedTools: [], - env: claudeEnvironment, - stderr: () => {}, - }, + env: probeEnvironment, + cwd: probeCwd, + }), }); const init = await q.initializationResult(); const account = init.account as