Skip to content
Merged
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
139 changes: 119 additions & 20 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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, [
{
Expand Down Expand Up @@ -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<ModelInfo[]> }).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* () {
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 5 additions & 8 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -4329,14 +4328,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
): Promise<ProviderListModelsResult> {
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 {
Expand Down
118 changes: 118 additions & 0 deletions apps/server/src/provider/claudeCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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<string>;
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();
});
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/provider/claudeCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<undefined>((resolve) => {
Expand Down
Loading
Loading