diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7beb47a5..df68e337 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -332,6 +332,52 @@ describe("ClaudeAdapterLive", () => { harness.getLastCreateQueryInput()?.options.pathToClaudeCodeExecutable, "/managed/claude", ); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.settingSources, [ + "user", + "project", + "local", + ]); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.mcpServers, {}); + assert.equal(harness.getLastCreateQueryInput()?.options.strictMcpConfig, true); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + "false", + ); + assert.equal(harness.getLastCreateQueryInput()?.options.permissionMode, "plan"); + assert.equal(harness.getLastCreateQueryInput()?.options.persistSession, false); + assert.equal(harness.query.closeCalls, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("closes an isolated temporary command query after discovery failure", () => { + const harness = makeHarness(); + ( + harness.query as { + supportedCommands: () => Promise< + Array<{ name: string; description: string; argumentHint: string }> + >; + } + ).supportedCommands = async () => { + throw new Error("simulated command discovery failure"); + }; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + if (!adapter.listCommands) { + return assert.fail("Claude adapter should support command discovery."); + } + + const result = yield* Effect.exit( + adapter.listCommands({ + provider: "claudeAgent", + cwd: "/tmp/claude-command-discovery-failure", + }), + ); + + assert.ok(Exit.isFailure(result)); + assert.equal(harness.query.closeCalls, 1); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -370,6 +416,14 @@ describe("ClaudeAdapterLive", () => { harness.getLastCreateQueryInput()?.options.pathToClaudeCodeExecutable, "/managed/claude-models", ); + assert.deepEqual(harness.getLastCreateQueryInput()?.options.mcpServers, {}); + assert.equal(harness.getLastCreateQueryInput()?.options.strictMcpConfig, true); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + "false", + ); + assert.equal(harness.getLastCreateQueryInput()?.options.permissionMode, "plan"); + assert.equal(harness.getLastCreateQueryInput()?.options.persistSession, false); assert.equal(harness.query.closeCalls, 1); assert.deepEqual(result.models, [ { @@ -415,6 +469,33 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("closes an isolated temporary model query after discovery failure", () => { + const harness = makeHarness(); + (harness.query as { supportedModels: () => Promise }).supportedModels = + async () => { + throw new Error("simulated model discovery failure"); + }; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + if (!adapter.listModels) { + return assert.fail("Claude adapter should support model discovery."); + } + + const result = yield* Effect.exit( + adapter.listModels({ + provider: "claudeAgent", + cwd: "/tmp/claude-model-discovery-failure", + }), + ); + + assert.ok(Exit.isFailure(result)); + assert.equal(harness.query.closeCalls, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -464,27 +545,45 @@ describe("ClaudeAdapterLive", () => { it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: "claudeAgent", - runtimeMode: "approval-required", - }); + const previousConnectorSetting = process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + const interactiveSessionSentinel = "preserve-for-interactive-session"; + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = interactiveSessionSentinel; - const createInput = harness.getLastCreateQueryInput(); - assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); - assert.equal(createInput?.options.permissionMode, undefined); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); - assert.deepEqual(createInput?.options.systemPrompt, { - type: "preset", - preset: "claude_code", - append: [ - "You are running inside Scient, a scientific workspace that embeds the Claude Agent SDK.", - "Do not present the host app as Claude Code unless the user is explicitly asking about Claude Code.", - "Treat the current working directory as the active workspace for the task.", - "When the user asks about the current project, codebase, or repository, proactively inspect files in the current working directory before asking the user where to look.", - ].join("\n"), - }); + try { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "approval-required", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.equal(createInput?.options.strictMcpConfig, undefined); + assert.equal(createInput?.options.mcpServers, undefined); + assert.equal( + createInput?.options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, + interactiveSessionSentinel, + ); + assert.deepEqual(createInput?.options.systemPrompt, { + type: "preset", + preset: "claude_code", + append: [ + "You are running inside Scient, a scientific workspace that embeds the Claude Agent SDK.", + "Do not present the host app as Claude Code unless the user is explicitly asking about Claude Code.", + "Treat the current working directory as the active workspace for the task.", + "When the user asks about the current project, codebase, or repository, proactively inspect files in the current working directory before asking the user where to look.", + ].join("\n"), + }); + } finally { + if (previousConnectorSetting === undefined) { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + } else { + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = previousConnectorSetting; + } + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2050b245..34388ea1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -88,6 +88,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { buildIsolatedClaudeDiscoveryOptions } from "../claudeDiscoveryIsolation.ts"; import { buildFileAttachmentsPromptBlock } from "../attachmentProjection.ts"; import { readProviderPromptImage } from "../promptAttachments.ts"; import { buildClaudeProcessEnv } from "../claudeProcessEnv.ts"; @@ -4295,14 +4296,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { // subprocess handshake). We iterate in the background to unblock it. const tempQuery = createQuery({ prompt: neverResolvingUserMessageStream(), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ cwd, pathToClaudeCodeExecutable: binaryPath, - settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, - persistSession: false, env: claudeSdkEnvForExecutable(env, binaryPath), - }, + }), }); try { @@ -4329,14 +4328,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ): Promise { const tempQuery = createQuery({ prompt: neverResolvingUserMessageStream(), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ cwd, pathToClaudeCodeExecutable: binaryPath, - settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, - persistSession: false, env: claudeSdkEnvForExecutable(env, binaryPath), - }, + }), }); try { diff --git a/apps/server/src/provider/claudeCapabilities.test.ts b/apps/server/src/provider/claudeCapabilities.test.ts index 4714ac94..f863ab40 100644 --- a/apps/server/src/provider/claudeCapabilities.test.ts +++ b/apps/server/src/provider/claudeCapabilities.test.ts @@ -1,3 +1,15 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it, vi } from "vitest"; import { @@ -46,7 +58,15 @@ describe("Claude account capability probing", () => { persistSession: false, allowedTools: [], cwd: "/workspace", + settingSources: ["user", "project", "local"], + mcpServers: {}, + strictMcpConfig: true, + env: { + HOME: "/Users/tester", + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, }); + expect(capturedOptions?.env).not.toBe(process.env); expect(close).toHaveBeenCalledOnce(); }); @@ -79,6 +99,104 @@ describe("Claude account capability probing", () => { expect(close).toHaveBeenCalledOnce(); }); + it("aborts and closes exactly once when SDK initialization fails", async () => { + let capturedSignal: AbortSignal | undefined; + const close = vi.fn(); + const createQuery: ClaudeCapabilitiesQueryFactory = (input) => { + capturedSignal = input.options.abortController?.signal; + return { + initializationResult: async () => { + throw new Error("simulated initialization failure"); + }, + close, + }; + }; + + await expect( + probeClaudeAccountCapabilities({ executable: "claude", env: {}, createQuery }), + ).resolves.toBeUndefined(); + expect(capturedSignal?.aborted).toBe(true); + expect(close).toHaveBeenCalledOnce(); + }); + + it("serializes the isolation boundary through the pinned Claude SDK", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "scient-claude-probe-sdk-")); + const executablePath = path.join(tempDir, "fake-claude.mjs"); + const invocationPath = path.join(tempDir, "invocation.json"); + const workspaceCwd = path.join(tempDir, "workspace"); + mkdirSync(workspaceCwd, { recursive: true }); + + writeFileSync( + executablePath, + [ + "#!/usr/bin/env node", + 'import { writeFileSync } from "node:fs";', + 'import { createInterface } from "node:readline";', + "const args = process.argv.slice(2);", + "writeFileSync(process.env.SCIENT_PROBE_INVOCATION_PATH, JSON.stringify({", + " args,", + " cwd: process.cwd(),", + " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", + "}));", + "const lines = createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [],", + " agents: [],", + ' output_style: "default",', + ' available_output_styles: ["default"],', + " models: [],", + ' account: { email: "scientist@example.test", subscriptionType: "max", tokenSource: "claude.ai" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + chmodSync(executablePath, 0o755); + + try { + await expect( + probeClaudeAccountCapabilities({ + executable: executablePath, + env: { + ...process.env, + SCIENT_PROBE_INVOCATION_PATH: invocationPath, + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }, + cwd: workspaceCwd, + timeoutMs: 5_000, + }), + ).resolves.toEqual({ + email: "scientist@example.test", + subscriptionType: "max", + tokenSource: "claude.ai", + }); + + const invocation = JSON.parse(readFileSync(invocationPath, "utf8")) as { + readonly args: ReadonlyArray; + readonly cwd: string; + readonly connectorEnv: string; + }; + expect(invocation.cwd).toBe(realpathSync(workspaceCwd)); + expect(invocation.connectorEnv).toBe("false"); + expect(invocation.args).toContain("--strict-mcp-config"); + expect(invocation.args).not.toContain("--mcp-config"); + expect(invocation.args).toContain("--setting-sources=user,project,local"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("rejects token-only objects during sanitization", () => { expect(sanitizeClaudeAccountCapabilities({ accessToken: "secret" })).toBeUndefined(); }); diff --git a/apps/server/src/provider/claudeCapabilities.ts b/apps/server/src/provider/claudeCapabilities.ts index 86d5536b..4e2f0d1d 100644 --- a/apps/server/src/provider/claudeCapabilities.ts +++ b/apps/server/src/provider/claudeCapabilities.ts @@ -8,6 +8,8 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import { buildIsolatedClaudeDiscoveryOptions } from "./claudeDiscoveryIsolation.ts"; + export interface ClaudeAccountCapabilities { readonly email?: string; readonly organization?: string; @@ -114,16 +116,14 @@ export async function probeClaudeAccountCapabilities( try { runtime = createQuery({ prompt: neverSendingPrompt(abortController.signal), - options: { + options: buildIsolatedClaudeDiscoveryOptions({ pathToClaudeCodeExecutable: input.executable, env: input.env, - persistSession: false, - settingSources: ["user", "project", "local"], allowedTools: [], abortController, stderr: () => {}, ...(input.cwd ? { cwd: input.cwd } : {}), - }, + }), }); const timeout = new Promise((resolve) => { diff --git a/apps/server/src/provider/claudeDiscoveryIsolation.test.ts b/apps/server/src/provider/claudeDiscoveryIsolation.test.ts new file mode 100644 index 00000000..b709c736 --- /dev/null +++ b/apps/server/src/provider/claudeDiscoveryIsolation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { buildIsolatedClaudeDiscoveryOptions } from "./claudeDiscoveryIsolation.ts"; + +describe("Claude discovery isolation", () => { + it("preserves caller options while enforcing an MCP-free temporary process", () => { + const abortController = new AbortController(); + const stderr = () => undefined; + const env = { + HOME: "/Users/tester", + PATH: "/custom/bin", + ENABLE_CLAUDEAI_MCP_SERVERS: "true", + }; + + const result = buildIsolatedClaudeDiscoveryOptions({ + cwd: "/workspace", + pathToClaudeCodeExecutable: "/managed/claude", + permissionMode: "plan", + allowedTools: [], + abortController, + stderr, + env, + persistSession: true, + settingSources: [], + mcpServers: { + unsafe: { + command: "should-not-run", + }, + }, + strictMcpConfig: false, + agent: "unsafe-agent", + agents: { + "unsafe-agent": { + description: "Must not participate in passive discovery", + prompt: "Start an MCP server", + tools: [], + }, + }, + extraArgs: { + "mcp-config": "/tmp/unsafe-mcp.json", + }, + }); + + expect(result).toMatchObject({ + cwd: "/workspace", + pathToClaudeCodeExecutable: "/managed/claude", + permissionMode: "plan", + allowedTools: [], + abortController, + stderr, + persistSession: false, + settingSources: ["user", "project", "local"], + mcpServers: {}, + strictMcpConfig: true, + env: { + HOME: "/Users/tester", + PATH: "/custom/bin", + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + }); + expect(result.env).not.toBe(env); + expect(result).not.toHaveProperty("agent"); + expect(result).not.toHaveProperty("agents"); + expect(result).not.toHaveProperty("extraArgs"); + expect(env.ENABLE_CLAUDEAI_MCP_SERVERS).toBe("true"); + }); + + it("does not mutate process.env when enforcing the connector override", () => { + const previous = process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + try { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + const result = buildIsolatedClaudeDiscoveryOptions({ env: process.env }); + + expect(result.env?.ENABLE_CLAUDEAI_MCP_SERVERS).toBe("false"); + expect(process.env.ENABLE_CLAUDEAI_MCP_SERVERS).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env.ENABLE_CLAUDEAI_MCP_SERVERS; + } else { + process.env.ENABLE_CLAUDEAI_MCP_SERVERS = previous; + } + } + }); +}); diff --git a/apps/server/src/provider/claudeDiscoveryIsolation.ts b/apps/server/src/provider/claudeDiscoveryIsolation.ts new file mode 100644 index 00000000..8c5bfa20 --- /dev/null +++ b/apps/server/src/provider/claudeDiscoveryIsolation.ts @@ -0,0 +1,43 @@ +// FILE: claudeDiscoveryIsolation.ts +// Purpose: Keep passive Claude capability discovery from starting user-configured MCP servers. +// Layer: Provider utility. + +import type { Options as ClaudeQueryOptions, SettingSource } from "@anthropic-ai/claude-agent-sdk"; + +const CLAUDE_DISCOVERY_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; + +/** + * Applies the non-interactive safety boundary shared by temporary Claude + * discovery processes. Filesystem settings remain available for command and + * model metadata, but their MCP declarations (including Claude.ai connectors) + * cannot start. Interactive Claude sessions must not use this helper. + */ +export function buildIsolatedClaudeDiscoveryOptions( + options: ClaudeQueryOptions & { readonly env: NodeJS.ProcessEnv }, +): ClaudeQueryOptions { + const safeOptions = { ...options }; + + // strictMcpConfig blocks MCP declarations loaded from settings, but the SDK + // still accepts MCP-capable agent definitions and arbitrary raw CLI flags. + // Temporary discovery has no need for either, so remove both escape hatches + // even if a future caller passes them accidentally. + delete safeOptions.agent; + delete safeOptions.agents; + delete safeOptions.extraArgs; + + return { + ...safeOptions, + env: { + ...options.env, + ENABLE_CLAUDEAI_MCP_SERVERS: "false", + }, + settingSources: [...CLAUDE_DISCOVERY_SETTING_SOURCES], + persistSession: false, + mcpServers: {}, + strictMcpConfig: true, + }; +}