From 1baa467510f10680b66c0b409da2b75754190c33 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:57:53 +0000 Subject: [PATCH 1/4] fix(claude): isolate capability probe from user MCP servers The ~5m Claude capability probe loaded settingSources user/project/local without strictMcpConfig, so every refresh booted configured stdio MCP servers from the server cwd (often $HOME). That turned MCP-side hangs into host freezes (#3909). - Pass empty mcpServers + strictMcpConfig on the probe query - Disable claude.ai MCP connectors via ENABLE_CLAUDEAI_MCP_SERVERS=false - Run the probe from ~/.t3/claude-capability-probe instead of inheriting cwd - Add unit coverage for the probe option builder Fixes #3909 --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 38 ++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 72 +++++++++++++++++-- 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts 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..eb656afba47 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -0,0 +1,38 @@ +import { join } from "node:path"; + +import { assert, it } from "@effect/vitest"; + +import { + buildClaudeCapabilitiesProbeQueryOptions, + CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + resolveClaudeCapabilitiesProbeCwd, +} 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 ~/.t3", () => { + assert.equal( + resolveClaudeCapabilitiesProbeCwd("/Users/example"), + join("/Users/example", ".t3", "claude-capability-probe"), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..8d58cfa5cf4 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, @@ -483,6 +488,53 @@ 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; + +/** Stable empty cwd under ~/.t3 so probes never inherit $HOME / server cwd. */ +export function resolveClaudeCapabilitiesProbeCwd(homeDir = NodeOS.homedir()): string { + return NodePath.join(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 +631,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 +643,11 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const probeCwd = resolveClaudeCapabilitiesProbeCwd(); + yield* Effect.tryPromise({ + try: () => NodeFS.promises.mkdir(probeCwd, { recursive: true }), + catch: (cause) => cause, + }).pipe(Effect.orDie); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -595,15 +656,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: () => {}, - }, + cwd: probeCwd, + }), }); const init = await q.initializationResult(); const account = init.account as From f78ba931bf031293a8b9ee8aecc707d58d9cddee Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:24:52 -0500 Subject: [PATCH 2/4] fix(claude): soft-fail probe mkdir and honor env HOME Keep capability-probe directory creation in the Effect error channel so mkdir failures map to undefined capabilities instead of fiber defects. Resolve the probe cwd from claudeEnvironment.HOME when set. Co-authored-by: Cursor --- apps/server/src/provider/Layers/ClaudeProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 8d58cfa5cf4..d4c50135096 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -643,11 +643,11 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const probeCwd = resolveClaudeCapabilitiesProbeCwd(); + const probeCwd = resolveClaudeCapabilitiesProbeCwd(claudeEnvironment.HOME || undefined); yield* Effect.tryPromise({ try: () => NodeFS.promises.mkdir(probeCwd, { recursive: true }), catch: (cause) => cause, - }).pipe(Effect.orDie); + }); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. From fe6c6b4568d85dbac096702413f754ec5ba66970 Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:34:14 -0500 Subject: [PATCH 3/4] fix(claude): expand and resolve probe HOME before mkdir Unexpanded ~ or relative HOME must not land the capability probe under the server CWD. Co-authored-by: Cursor --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 25 ++++++++++++++++--- .../src/provider/Layers/ClaudeProvider.ts | 6 ++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index eb656afba47..a72d0cf923c 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -1,4 +1,5 @@ -import { join } from "node:path"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import { assert, it } from "@effect/vitest"; @@ -30,9 +31,27 @@ it("isolates Claude capability probes from user MCP servers", () => { assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); -it("places the Claude capability probe cwd under ~/.t3", () => { +it("places the Claude capability probe cwd under an absolute home", () => { assert.equal( resolveClaudeCapabilitiesProbeCwd("/Users/example"), - join("/Users/example", ".t3", "claude-capability-probe"), + 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"), ); }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d4c50135096..55a08e7efd7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -42,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({ @@ -501,7 +502,10 @@ export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ /** Stable empty cwd under ~/.t3 so probes never inherit $HOME / server cwd. */ export function resolveClaudeCapabilitiesProbeCwd(homeDir = NodeOS.homedir()): string { - return NodePath.join(homeDir, ".t3", "claude-capability-probe"); + const trimmed = homeDir.trim(); + const resolvedHome = + trimmed.length > 0 ? NodePath.resolve(expandHomePath(trimmed)) : NodeOS.homedir(); + return NodePath.join(resolvedHome, ".t3", "claude-capability-probe"); } /** From 8a7844727788c9199c0f58c5684aecc181c5b23f Mon Sep 17 00:00:00 2001 From: Jaret Bottoms <25535214+jbbottoms@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:39:51 -0500 Subject: [PATCH 4/4] fix(claude): align probe env HOME with resolved probe cwd Normalize Claude HOME once for capability probes so SDK cwd and env.HOME share the same absolute home for setting/account discovery. Co-authored-by: Cursor --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 17 +++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 25 ++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index a72d0cf923c..2330d5bab2a 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -7,6 +7,7 @@ import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, resolveClaudeCapabilitiesProbeCwd, + resolveClaudeCapabilitiesProbeHome, } from "./ClaudeProvider.ts"; it("isolates Claude capability probes from user MCP servers", () => { @@ -55,3 +56,19 @@ it("expands ~/… and resolves relative homes to absolute probe cwds", () => { 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 55a08e7efd7..4db502c689e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -500,12 +500,19 @@ export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ "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 { - const trimmed = homeDir.trim(); - const resolvedHome = - trimmed.length > 0 ? NodePath.resolve(expandHomePath(trimmed)) : NodeOS.homedir(); - return NodePath.join(resolvedHome, ".t3", "claude-capability-probe"); + return NodePath.join( + resolveClaudeCapabilitiesProbeHome(homeDir), + ".t3", + "claude-capability-probe", + ); } /** @@ -647,7 +654,13 @@ const probeClaudeCapabilities = ( const abort = new AbortController(); return Effect.gen(function* () { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const probeCwd = resolveClaudeCapabilitiesProbeCwd(claudeEnvironment.HOME || undefined); + // 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, @@ -663,7 +676,7 @@ const probeClaudeCapabilities = ( options: buildClaudeCapabilitiesProbeQueryOptions({ binaryPath: claudeSettings.binaryPath, abortController: abort, - env: claudeEnvironment, + env: probeEnvironment, cwd: probeCwd, }), });