Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
91 changes: 83 additions & 8 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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<SettingSource>;

/** 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;
Expand Down Expand Up @@ -579,6 +642,10 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
*
* 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,
Expand All @@ -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.
Expand All @@ -595,15 +673,12 @@ const probeClaudeCapabilities = (
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
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
Expand Down
Loading