| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams. |
| 2 | import * as NodeEvents from "node:events"; |
| 1 | 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodeStream from "node:stream"; |
| 2 | 5 | |
| 3 | 6 | import type { |
| 4 | 7 | Query as ClaudeQuery, |
| 5 | 8 | SDKMessage, |
| 6 | 9 | SDKResultMessage, |
| 7 | 10 | SDKUserMessage, |
| 11 | SpawnedProcess, |
| 12 | SpawnOptions, |
| 8 | 13 | } from "@anthropic-ai/claude-agent-sdk"; |
| 9 | 14 | import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; |
| 10 | 15 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -63,7 +68,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; |
| 63 | 68 | import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; |
| 64 | 69 | import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; |
| 65 | 70 | import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; |
| 66 | | import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 71 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 67 | 72 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 68 | 73 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 69 | 74 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| ... | ... | @@ -499,14 +504,11 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { |
| 499 | 504 | type: "http", |
| 500 | 505 | url: "http://127.0.0.1:43123/mcp", |
| 501 | 506 | headers: { |
| 502 | | Authorization: "${T3_CODE_MCP_AUTHORIZATION}", |
| 507 | Authorization: "Bearer secret-claude-token", |
| 503 | 508 | }, |
| 504 | 509 | timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, |
| 505 | 510 | }, |
| 506 | 511 | } as const; |
| 507 | | const T3_MCP_ENVIRONMENT = { |
| 508 | | T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token", |
| 509 | | } as const; |
| 510 | 512 | |
| 511 | 513 | const mcpSessionFor = ( |
| 512 | 514 | threadId: ThreadId, |
| ... | ... | @@ -551,7 +553,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { |
| 551 | 553 | assert.deepEqual(overrides, { |
| 552 | 554 | allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], |
| 553 | 555 | mcpServers: T3_MCP_SERVERS, |
| 554 | | mcpEnvironment: T3_MCP_ENVIRONMENT, |
| 555 | 556 | }); |
| 556 | 557 | }); |
| 557 | 558 | |
| ... | ... | @@ -567,7 +568,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { |
| 567 | 568 | assert.deepEqual(overrides, { |
| 568 | 569 | allowedTools: ["Read", "mcp__t3-code__*"], |
| 569 | 570 | mcpServers: T3_MCP_SERVERS, |
| 570 | | mcpEnvironment: T3_MCP_ENVIRONMENT, |
| 571 | 571 | }); |
| 572 | 572 | }); |
| 573 | 573 | |
| ... | ... | @@ -586,7 +586,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { |
| 586 | 586 | ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, |
| 587 | 587 | ], |
| 588 | 588 | mcpServers: T3_MCP_SERVERS, |
| 589 | | mcpEnvironment: T3_MCP_ENVIRONMENT, |
| 590 | 589 | }); |
| 591 | 590 | assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); |
| 592 | 591 | }); |
| ... | ... | @@ -706,12 +705,11 @@ describe("ClaudeAdapterV2 native protocol logging", () => { |
| 706 | 705 | type: "http", |
| 707 | 706 | url: "http://127.0.0.1:43123/mcp", |
| 708 | 707 | headers: { |
| 709 | | Authorization: "${T3_CODE_MCP_AUTHORIZATION}", |
| 708 | Authorization: "Bearer secret-claude-token", |
| 710 | 709 | }, |
| 711 | 710 | timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, |
| 712 | 711 | }, |
| 713 | 712 | }, |
| 714 | | mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" }, |
| 715 | 713 | }); |
| 716 | 714 | |
| 717 | 715 | const options = ClaudeAdapterV2.makeClaudeQueryOptions({ |
| ... | ... | @@ -724,12 +722,8 @@ describe("ClaudeAdapterV2 native protocol logging", () => { |
| 724 | 722 | cwd: "/workspace", |
| 725 | 723 | allowedTools: overrides.allowedTools ?? [], |
| 726 | 724 | mcpServers: overrides.mcpServers ?? {}, |
| 727 | | environment: { ...overrides.mcpEnvironment }, |
| 725 | environment: {}, |
| 728 | 726 | }); |
| 729 | | // mcpServers becomes a CLI argument, readable by every local user; the |
| 730 | | // credential may only travel in the child's environment. |
| 731 | | assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token"); |
| 732 | | assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token"); |
| 733 | 727 | assert.isObject(options.systemPrompt); |
| 734 | 728 | const systemPrompt = options.systemPrompt as { |
| 735 | 729 | readonly type: string; |
| ... | ... | @@ -1850,6 +1844,120 @@ describe("ClaudeAdapterV2 native fork", () => { |
| 1850 | 1844 | ); |
| 1851 | 1845 | }); |
| 1852 | 1846 | |
| 1847 | // Stands in for the Claude CLI behind the SDK's spawn hook: records how it was |
| 1848 | // started and answers stdin control requests, except mcp_set_servers if asked. |
| 1849 | function makeFakeClaudeCli(answerSetServers = true) { |
| 1850 | const spawns: Array<SpawnOptions> = []; |
| 1851 | const controlRequests: Array<object> = []; |
| 1852 | const stdins: Array<NodeStream.PassThrough> = []; |
| 1853 | const spawn = (options: SpawnOptions): SpawnedProcess => { |
| 1854 | spawns.push(options); |
| 1855 | const stdin = new NodeStream.PassThrough(); |
| 1856 | const stdout = new NodeStream.PassThrough(); |
| 1857 | stdins.push(stdin); |
| 1858 | const child = Object.assign(new NodeEvents.EventEmitter(), { |
| 1859 | stdin, |
| 1860 | stdout, |
| 1861 | killed: false, |
| 1862 | exitCode: null as number | null, |
| 1863 | kill: () => { |
| 1864 | stdout.end(); |
| 1865 | child.emit("exit", null, "SIGTERM"); |
| 1866 | return true; |
| 1867 | }, |
| 1868 | }); |
| 1869 | let pending = ""; |
| 1870 | stdin.on("data", (chunk: Buffer) => { |
| 1871 | pending += chunk.toString("utf8"); |
| 1872 | for (let end = pending.indexOf("\n"); end >= 0; end = pending.indexOf("\n")) { |
| 1873 | const frame = JSON.parse(pending.slice(0, end)); |
| 1874 | pending = pending.slice(end + 1); |
| 1875 | if (frame.type !== "control_request") continue; |
| 1876 | controlRequests.push(frame.request); |
| 1877 | const setServers = frame.request.subtype === "mcp_set_servers"; |
| 1878 | if (setServers && !answerSetServers) continue; |
| 1879 | const response = { |
| 1880 | subtype: "success", |
| 1881 | request_id: frame.request_id, |
| 1882 | response: setServers ? { added: [], removed: [], errors: {} } : {}, |
| 1883 | }; |
| 1884 | stdout.write(`${JSON.stringify({ type: "control_response", response })}\n`); |
| 1885 | } |
| 1886 | }); |
| 1887 | stdin.on("end", () => { |
| 1888 | stdout.end(); |
| 1889 | child.emit("exit", 0, null); |
| 1890 | }); |
| 1891 | return child; |
| 1892 | }; |
| 1893 | // The SDK closes the CLI by ending its stdin. |
| 1894 | const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded); |
| 1895 | return { spawn, spawns, controlRequests, closed }; |
| 1896 | } |
| 1897 | |
| 1898 | describe("ClaudeAdapterV2 MCP credential channel", () => { |
| 1899 | const t3McpServer = { |
| 1900 | type: "http" as const, |
| 1901 | url: "http://127.0.0.1:43123/mcp", |
| 1902 | headers: { Authorization: "Bearer dummy-mcp-credential" }, |
| 1903 | }; |
| 1904 | const openWith = (cli: ReturnType<typeof makeFakeClaudeCli>) => |
| 1905 | Effect.gen(function* () { |
| 1906 | const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner; |
| 1907 | return yield* runner.open({ |
| 1908 | threadId: ThreadId.make("thread-claude-mcp-channel"), |
| 1909 | providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"), |
| 1910 | options: { |
| 1911 | ...ClaudeAdapterV2.makeClaudeQueryOptions({ |
| 1912 | modelSelection: CLAUDE_TEST_MODEL_SELECTION, |
| 1913 | nativeThreadId: "native-thread-claude-mcp-channel", |
| 1914 | resume: false, |
| 1915 | cwd: null, |
| 1916 | environment: { PATH: "/usr/bin" }, |
| 1917 | mcpServers: { "t3-code": t3McpServer }, |
| 1918 | }), |
| 1919 | pathToClaudeCodeExecutable: "/opt/claude/cli.js", |
| 1920 | spawnClaudeCodeProcess: cli.spawn, |
| 1921 | }, |
| 1922 | }); |
| 1923 | }).pipe( |
| 1924 | Effect.provide(ClaudeAdapterV2.layerQueryRunner), |
| 1925 | Effect.provideService( |
| 1926 | ProviderEventLoggers.ProviderEventLoggers, |
| 1927 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 1928 | ), |
| 1929 | Effect.provide(NodeServices.layer), |
| 1930 | ); |
| 1931 | |
| 1932 | it.effect("sends MCP servers over stdin, not argv or the environment", () => |
| 1933 | Effect.gen(function* () { |
| 1934 | const cli = makeFakeClaudeCli(); |
| 1935 | const session = yield* openWith(cli); |
| 1936 | yield* session.close; |
| 1937 | |
| 1938 | assert.deepInclude(cli.controlRequests, { |
| 1939 | subtype: "mcp_set_servers", |
| 1940 | servers: { "t3-code": t3McpServer }, |
| 1941 | }); |
| 1942 | const spawned = JSON.stringify(cli.spawns); |
| 1943 | assert.notInclude(spawned, "--mcp-config"); |
| 1944 | assert.notInclude(spawned, "dummy-mcp-credential"); |
| 1945 | }), |
| 1946 | ); |
| 1947 | |
| 1948 | it.effect("closes the CLI when it never answers the MCP registration", () => |
| 1949 | Effect.gen(function* () { |
| 1950 | const cli = makeFakeClaudeCli(false); |
| 1951 | const opening = yield* openWith(cli).pipe(Effect.result, Effect.forkChild); |
| 1952 | yield* TestClock.adjust("90 seconds"); |
| 1953 | const opened = yield* Fiber.join(opening); |
| 1954 | |
| 1955 | assert.equal(opened._tag, "Failure"); |
| 1956 | assert.isTrue(cli.closed()); |
| 1957 | }), |
| 1958 | ); |
| 1959 | }); |
| 1960 | |
| 1853 | 1961 | describe("ClaudeAdapterV2 native session identity", () => { |
| 1854 | 1962 | const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) => |
| 1855 | 1963 | Effect.scoped( |