From f0135257fb772af43eb9384f3eef53b77f8cdab2 Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 21:08:16 +0900 Subject: [PATCH 1/8] chore(worker): start host tool integration audit From e51163ababa8fa71a38d37749eaa8eedaaaac7c9 Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 21:14:54 +0900 Subject: [PATCH 2/8] test(claude): refresh routability fixture --- test/e2e/claude/claude-docker.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/claude/claude-docker.spec.ts b/test/e2e/claude/claude-docker.spec.ts index 4834b94b..2a935a1a 100644 --- a/test/e2e/claude/claude-docker.spec.ts +++ b/test/e2e/claude/claude-docker.spec.ts @@ -593,7 +593,13 @@ async function createTurnLeaseServer(): Promise<{ if (request.method === "POST" && request.url === "/api/v1/tracker-state") { response.writeHead(200, { "content-type": "application/json" }); response.end( - JSON.stringify({ ok: true, outcome: "confirmed", state: "In progress" }) + JSON.stringify({ + ok: true, + outcome: "confirmed", + state: "In progress", + routable: true, + routableReason: null, + }) ); return; } From 2c0a37290fbfadf4b8ab60cf3e4081f64112a49d Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 21:20:51 +0900 Subject: [PATCH 3/8] fix(tools): enforce active issue scope --- README.md | 3 +- docs/trackers/github.md | 22 ++++++------- docs/trackers/linear.md | 20 +++++------ e2e/host-dynamic-tool-e2e.mjs | 11 ++++--- packages/tool-github-graphql/src/tool.test.ts | 20 +++++++++++ packages/tool-github-graphql/src/tool.ts | 33 +++++++++++++++++++ packages/tool-linear-graphql/src/tool.test.ts | 20 +++++++++++ packages/tool-linear-graphql/src/tool.ts | 33 +++++++++++++++++++ .../tracker-github/src/tracker-github.test.ts | 5 ++- .../tracker-linear/src/tracker-linear.test.ts | 6 +++- .../worker/src/codex-dynamic-tools.test.ts | 5 ++- packages/worker/src/worker-protocol.test.ts | 9 +++-- test/e2e/claude/claude-docker.spec.ts | 3 ++ test/e2e/stubs/claude.sh | 5 ++- 14 files changed, 163 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 88b9f9c9..778eb1ca 100644 --- a/README.md +++ b/README.md @@ -989,7 +989,8 @@ Other Codex approval policies fail workflow validation because operator approval handling is not implemented. Operators can configure the Codex sandbox settings, but must use least-privilege credentials, dedicated workspaces, and controls appropriate to their environment. The target transport keeps credentials in the Symphony host -or a host-side broker, returns only bounded issue-aware tool results to agents, +or a host-side broker, rejects tracker-tool calls that do not carry an exact +active-issue/provider identifier, returns only bounded issue-aware tool results to agents, uses loopback-only local services with scoped session capabilities, and gives the child an isolated home/configuration directory rather than a host `gh auth` store. Authenticated Git transport is performed by the host. diff --git a/docs/trackers/github.md b/docs/trackers/github.md index 02b1ab3f..3d9e56f9 100644 --- a/docs/trackers/github.md +++ b/docs/trackers/github.md @@ -6,15 +6,15 @@ the result only; the adapter uses the host's GitHub credential or token broker. ## `github_graphql` -| Property | Contract | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | `github_graphql` | -| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | -| Mutations | Permitted. A mutation must be intentionally scoped to the active issue or its repository. | -| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. The tool is an arbitrary GitHub GraphQL transport, so callers must constrain their document and variables to that active issue/repository; it does not infer or rewrite a query's target. | -| Result | The provider GraphQL payload. For queries, Symphony adds the GitHub `rateLimit` selection when absent and may return normalized rate-limit metadata with the payload. | -| Errors | Invalid tool arguments, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | -| Rate limits | GitHub GraphQL rate-limit headers and the GraphQL `rateLimit` field are measured by the host and applied to the shared GitHub rate-limit policy; callers should keep queries small and respect retry guidance. | +| Property | Contract | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | `github_graphql` | +| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | +| Mutations | Permitted. A mutation must be intentionally scoped to the active issue or its repository. | +| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. For every host-side call, the adapter requires at least one exact active issue ID, identifier, or provider-native ID in `variables`; unscoped operations are rejected before authentication or HTTP. The document is not rewritten, so callers must still keep every selected field and mutation narrow to that target. | +| Result | The provider GraphQL payload. For queries, Symphony adds the GitHub `rateLimit` selection when absent and may return normalized rate-limit metadata with the payload. | +| Errors | Invalid tool arguments, missing active-issue scope, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | +| Rate limits | GitHub GraphQL rate-limit headers and the GraphQL `rateLimit` field are measured by the host and applied to the shared GitHub rate-limit policy; callers should keep queries small and respect retry guidance. | The GitHub tool is always advertised because GitHub repository and pull-request operations remain available for Linear-tracked projects; `linear_graphql` is @@ -27,8 +27,8 @@ result, or generated Claude MCP configuration. ## Safe usage -Use a named operation and pass the active issue/repository identifiers as -variables. Keep mutations narrow—for example, update the active issue's +Use a named operation and pass the active issue ID or provider-native Project +item ID as an exact variable value. Keep mutations narrow—for example, update the active issue's project item or create a comment on that issue—and verify the returned node identifier before performing a follow-up mutation. Do not use this tool to enumerate or modify unrelated repositories, organizations, or projects. diff --git a/docs/trackers/linear.md b/docs/trackers/linear.md index f8197f85..2b77bb55 100644 --- a/docs/trackers/linear.md +++ b/docs/trackers/linear.md @@ -57,22 +57,22 @@ process. ## `linear_graphql` -| Property | Contract | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | `linear_graphql` | -| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | -| Mutations | Permitted. A mutation must be intentionally scoped to the active Linear issue or its team. | -| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never added to the GraphQL payload. The tool validates that a request has exactly one GraphQL operation, but it is an arbitrary Linear GraphQL transport: callers must constrain the operation and variables to the active issue/team; it does not infer or rewrite a target. | -| Result | The provider GraphQL response payload. | -| Errors | Empty or multi-operation documents, invalid GraphQL syntax, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | -| Rate limits | Linear's GraphQL response is returned unchanged. No Symphony-side rate-limit scheduler is currently applied, so callers must avoid bursts and honor provider throttling responses. | +| Property | Contract | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | `linear_graphql` | +| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | +| Mutations | Permitted. A mutation must be intentionally scoped to the active Linear issue or its team. | +| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never added to the GraphQL payload. The tool validates that a request has exactly one GraphQL operation and requires at least one exact active issue ID, identifier, or provider-native ID in `variables`; unscoped operations are rejected before authentication or HTTP. The document is not rewritten, so callers must still keep every selected field and mutation narrow to that target. | +| Result | The provider GraphQL response payload. | +| Errors | Empty or multi-operation documents, invalid GraphQL syntax, missing active-issue scope, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | +| Rate limits | Linear's GraphQL response is returned unchanged. No Symphony-side rate-limit scheduler is currently applied, so callers must avoid bursts and honor provider throttling responses. | The tool is advertised only when the selected tracker adapter is Linear. It is not an MCP subprocess and must not be recreated by the coding-agent child. ## Safe usage -Use one named operation and bind the active issue ID explicitly in variables. +Use one named operation and bind the active issue ID as an exact variable value. Keep mutations narrow—for example, update the active issue state or create a comment on that issue—and inspect the returned `success` flag and node ID. Do not query or mutate unrelated teams, issues, or workspace resources. diff --git a/e2e/host-dynamic-tool-e2e.mjs b/e2e/host-dynamic-tool-e2e.mjs index 4fcd0f74..dc8fad18 100644 --- a/e2e/host-dynamic-tool-e2e.mjs +++ b/e2e/host-dynamic-tool-e2e.mjs @@ -32,7 +32,7 @@ globalThis.fetch = async (url, init) => { }, }, } - : { data: { viewer: { login: "docker-host-tool" } } } + : { data: { node: { id: "docker-issue-730" } } } ), { status: 200 } ); @@ -42,7 +42,10 @@ try { responses = await Promise.all([ executeCodexDynamicToolCall( "github_graphql", - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "docker-issue-730" }, + }, context, env ), @@ -79,7 +82,7 @@ try { if ( responses.some((response) => response.success !== true) || responses[0]?.contentItems[0]?.text !== - '{"data":{"viewer":{"login":"docker-host-tool"}}}' + '{"data":{"node":{"id":"docker-issue-730"}}}' ) { throw new Error(`unexpected_host_tool_response:${JSON.stringify(responses)}`); } @@ -91,7 +94,7 @@ if ( request.url !== "https://api.github.com/graphql" || request.init?.headers?.authorization !== "Bearer host-only-test-token" ) || - !requests[0]?.init?.body?.includes("viewer") || + !requests[0]?.init?.body?.includes("docker-issue-730") || !requests[1]?.init?.body?.includes("addComment") || !requests[1]?.init?.body?.includes("docker-issue-730") || !requests[2]?.init?.body?.includes("updateProjectV2ItemFieldValue") || diff --git a/packages/tool-github-graphql/src/tool.test.ts b/packages/tool-github-graphql/src/tool.test.ts index 49e29fd9..9496fc35 100644 --- a/packages/tool-github-graphql/src/tool.test.ts +++ b/packages/tool-github-graphql/src/tool.test.ts @@ -300,6 +300,26 @@ describe("resolveGitHubGraphQLToken", () => { }); describe("executeGitHubGraphQL", () => { + it("rejects a host-side operation that is not scoped to the active issue", async () => { + const fetchImpl = vi.fn(); + + await expect( + executeGitHubGraphQL( + { query: "query Viewer { viewer { login } }" }, + { token: "ghs_static" }, + fetchImpl as typeof fetch, + { + issue: { + id: "issue-1", + identifier: "owner/repo#1", + nativeRef: { itemId: "project-item-1" }, + }, + } + ) + ).rejects.toThrow(/active issue scope/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + afterEach(() => { githubGraphQLRateLimitPolicy.reset(); }); diff --git a/packages/tool-github-graphql/src/tool.ts b/packages/tool-github-graphql/src/tool.ts index 3fc5b795..d8216d1c 100644 --- a/packages/tool-github-graphql/src/tool.ts +++ b/packages/tool-github-graphql/src/tool.ts @@ -61,6 +61,7 @@ export async function executeGitHubGraphQL( context?: TrackerToolExecutionContext ): Promise { assertTrackerToolExecutionContext(context); + assertInvocationTargetsActiveIssue(invocation, context); const token = await resolveGitHubGraphQLToken(config, { fetchImpl, }); @@ -152,6 +153,38 @@ function assertTrackerToolExecutionContext( } } +function assertInvocationTargetsActiveIssue( + invocation: GitHubGraphQLInvocation, + context: TrackerToolExecutionContext | undefined +): void { + if (!context) return; + const allowedTargets = collectScopeValues({ + id: context.issue.id, + identifier: context.issue.identifier, + nativeRef: context.issue.nativeRef, + }); + const invocationValues = collectScopeValues(invocation.variables ?? {}); + if (![...invocationValues].some((value) => allowedTargets.has(value))) { + throw new Error( + "github_graphql requires variables that target the active issue scope." + ); + } +} + +function collectScopeValues( + value: unknown, + output = new Set() +): Set { + if (typeof value === "string" && value.trim() !== "") { + output.add(value); + } else if (Array.isArray(value)) { + for (const entry of value) collectScopeValues(entry, output); + } else if (value && typeof value === "object") { + for (const entry of Object.values(value)) collectScopeValues(entry, output); + } + return output; +} + type GitHubGraphQLPayload = { data?: Record | null; errors?: Array<{ message: string }>; diff --git a/packages/tool-linear-graphql/src/tool.test.ts b/packages/tool-linear-graphql/src/tool.test.ts index 5cc41f9f..828b15b5 100644 --- a/packages/tool-linear-graphql/src/tool.test.ts +++ b/packages/tool-linear-graphql/src/tool.test.ts @@ -54,6 +54,26 @@ describe("validateLinearGraphQLInvocation", () => { }); describe("executeLinearGraphQL", () => { + it("rejects a host-side operation that is not scoped to the active issue", async () => { + const fetchImpl = vi.fn(); + + await expect( + executeLinearGraphQL( + { query: "query Viewer { viewer { id } }" }, + { apiKey: "lin_api_key" }, + fetchImpl as typeof fetch, + { + issue: { + id: "issue-1", + identifier: "ENG-1", + nativeRef: { itemId: "issue-1", projectSlug: "project-a" }, + }, + } + ) + ).rejects.toThrow(/active issue scope/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("posts a single operation with runtime-managed Authorization", async () => { const fetchImpl = vi .fn() diff --git a/packages/tool-linear-graphql/src/tool.ts b/packages/tool-linear-graphql/src/tool.ts index 75455f00..a60adf6b 100644 --- a/packages/tool-linear-graphql/src/tool.ts +++ b/packages/tool-linear-graphql/src/tool.ts @@ -32,6 +32,7 @@ export async function executeLinearGraphQL( ): Promise { assertTrackerToolExecutionContext(context); validateLinearGraphQLInvocation(invocation); + assertInvocationTargetsActiveIssue(invocation, context); const authorization = resolveLinearAuthorizationHeader(config); const apiUrl = validateLinearGraphQLApiUrl( config.apiUrl ?? DEFAULT_LINEAR_GRAPHQL_API_URL @@ -73,6 +74,38 @@ function assertTrackerToolExecutionContext( } } +function assertInvocationTargetsActiveIssue( + invocation: LinearGraphQLInvocation, + context: TrackerToolExecutionContext | undefined +): void { + if (!context) return; + const allowedTargets = collectScopeValues({ + id: context.issue.id, + identifier: context.issue.identifier, + nativeRef: context.issue.nativeRef, + }); + const invocationValues = collectScopeValues(invocation.variables ?? {}); + if (![...invocationValues].some((value) => allowedTargets.has(value))) { + throw new Error( + "linear_graphql requires variables that target the active issue scope." + ); + } +} + +function collectScopeValues( + value: unknown, + output = new Set() +): Set { + if (typeof value === "string" && value.trim() !== "") { + output.add(value); + } else if (Array.isArray(value)) { + for (const entry of value) collectScopeValues(entry, output); + } else if (value && typeof value === "object") { + for (const entry of Object.values(value)) collectScopeValues(entry, output); + } + return output; +} + export function validateLinearGraphQLInvocation( invocation: LinearGraphQLInvocation ): void { diff --git a/packages/tracker-github/src/tracker-github.test.ts b/packages/tracker-github/src/tracker-github.test.ts index 90ea9895..36ba595a 100644 --- a/packages/tracker-github/src/tracker-github.test.ts +++ b/packages/tracker-github/src/tracker-github.test.ts @@ -58,7 +58,10 @@ describe("GitHub canonical subject adapter hook", () => { await expect( githubProjectTrackerAdapter.executeAgentTool?.( "github_graphql", - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-1" }, + }, context ) ).resolves.toMatchObject({ data: { viewer: { login: "octo" } } }); diff --git a/packages/tracker-linear/src/tracker-linear.test.ts b/packages/tracker-linear/src/tracker-linear.test.ts index daaca3b7..c7c0e24c 100644 --- a/packages/tracker-linear/src/tracker-linear.test.ts +++ b/packages/tracker-linear/src/tracker-linear.test.ts @@ -98,7 +98,11 @@ describe("linearTrackerAdapter", () => { await expect( linearTrackerAdapter.executeAgentTool?.( "linear_graphql", - { query: "mutation { issueUpdate { success } }" }, + { + query: + "mutation UpdateIssue($id: String!) { issueUpdate(id: $id) { success } }", + variables: { id: "issue-1" }, + }, context ) ).resolves.toEqual({ data: { issueUpdate: { success: true } } }); diff --git a/packages/worker/src/codex-dynamic-tools.test.ts b/packages/worker/src/codex-dynamic-tools.test.ts index 686cebda..a848c4ac 100644 --- a/packages/worker/src/codex-dynamic-tools.test.ts +++ b/packages/worker/src/codex-dynamic-tools.test.ts @@ -101,7 +101,10 @@ describe("Codex host dynamic tools", () => { const response = await executeCodexDynamicToolCall( "github_graphql", - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-730" }, + }, createTrackerToolContext(env), env ); diff --git a/packages/worker/src/worker-protocol.test.ts b/packages/worker/src/worker-protocol.test.ts index 500946f8..25479d53 100644 --- a/packages/worker/src/worker-protocol.test.ts +++ b/packages/worker/src/worker-protocol.test.ts @@ -2906,7 +2906,10 @@ describe("lastEventAt timestamp tracking", () => { params: { tool: "github_graphql", callId: "call-1", - arguments: { query: "query { viewer { login } }" }, + arguments: { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-730" }, + }, }, }); @@ -2944,7 +2947,9 @@ describe("lastEventAt timestamp tracking", () => { expect(fetchSpy).toHaveBeenCalledWith( "https://api.github.com/graphql", expect.objectContaining({ - headers: expect.objectContaining({ authorization: "Bearer host-token" }), + headers: expect.objectContaining({ + authorization: "Bearer host-token", + }), }) ); fetchSpy.mockRestore(); diff --git a/test/e2e/claude/claude-docker.spec.ts b/test/e2e/claude/claude-docker.spec.ts index 2a935a1a..08256a79 100644 --- a/test/e2e/claude/claude-docker.spec.ts +++ b/test/e2e/claude/claude-docker.spec.ts @@ -177,6 +177,9 @@ global.fetch = async (url, options) => { SYMPHONY_RUN_ID: "run-worker-claude", SYMPHONY_ISSUE_ID: "issue-worker-claude", SYMPHONY_ISSUE_IDENTIFIER: "test-owner/test-repo#254", + SYMPHONY_ISSUE_NATIVE_REF: JSON.stringify({ + itemId: "item-worker-claude", + }), SYMPHONY_ISSUE_STATE: "In progress", SYMPHONY_MAX_TURNS: "2", SYMPHONY_CONTINUATION_GUIDANCE: diff --git a/test/e2e/stubs/claude.sh b/test/e2e/stubs/claude.sh index 5178fbe0..cec44494 100755 --- a/test/e2e/stubs/claude.sh +++ b/test/e2e/stubs/claude.sh @@ -131,7 +131,10 @@ if (server.type !== "http") { } (async () => { const calls = [ - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-worker-claude" }, + }, { query: "mutation AddComment($subjectId: ID!, $body: String!) { addComment(input: { subjectId: $subjectId, body: $body }) { commentEdge { node { id } } } }", variables: { subjectId: "issue-worker-claude", body: "host-side comment" }, From 189dbf77b54f48791fb8726bad1f75ab3286d254 Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 21:20:51 +0900 Subject: [PATCH 4/8] fix(claude): snapshot host tool specs --- docs/architecture.md | 3 ++ .../src/mcp-http-server.test.ts | 48 +++++++++++++++++ .../runtime-claude/src/mcp-http-server.ts | 51 +++++++++++++++---- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 18f92a0d..7960b651 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,9 @@ touches a layer, check that its slice (and the linked documents) still holds. [Linear](trackers/linear.md), and [file](trackers/file.md). GitHub's synthetic `Archived` state is a GitHub-specific implementation choice, not normalized Symphony core behavior. +- Host-side tracker tools: provider adapters own advertised schemas, credentials, + and active-issue scope validation. Codex snapshots those schemas in its runtime + plan; Claude snapshots them when its loopback Streamable HTTP MCP server starts. ### 6. Observability — events and status surfaces diff --git a/packages/runtime-claude/src/mcp-http-server.test.ts b/packages/runtime-claude/src/mcp-http-server.test.ts index 67020bd5..75934cbf 100644 --- a/packages/runtime-claude/src/mcp-http-server.test.ts +++ b/packages/runtime-claude/src/mcp-http-server.test.ts @@ -13,6 +13,54 @@ afterEach(async () => { }); describe("Claude host MCP HTTP server", () => { + it("freezes the advertised tool specs when the server starts", async () => { + let specs = [ + { + name: "snapshotted_tool", + description: "Initial tool", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ]; + const adapter = { + agentToolSpecs: () => specs, + executeAgentTool: vi.fn(), + }; + server = await startClaudeMcpHttpServer({ + env: {}, + context: { + issue: { id: "issue-1", identifier: "owner/repo#1", nativeRef: {} }, + }, + adapters: [adapter], + }); + specs = [ + { + ...specs[0]!, + name: "reloaded_tool", + description: "Reloaded tool", + }, + ]; + + const response = await fetch(server.url, { + method: "POST", + headers: { + authorization: `Bearer ${server.sessionToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + await expect(response.json()).resolves.toMatchObject({ + result: { + tools: [expect.objectContaining({ name: "snapshotted_tool" })], + }, + }); + }); + it("requires its session capability and exposes only the selected host tool", async () => { server = await startClaudeMcpHttpServer({ env: { SYMPHONY_TRACKER_KIND: "github" }, diff --git a/packages/runtime-claude/src/mcp-http-server.ts b/packages/runtime-claude/src/mcp-http-server.ts index 6c8f433e..5ac8af6f 100644 --- a/packages/runtime-claude/src/mcp-http-server.ts +++ b/packages/runtime-claude/src/mcp-http-server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import type { AgentToolExecutionContext, + AgentToolSpec, OrchestratorTrackerAdapter, } from "@gh-symphony/core"; import { githubProjectTrackerAdapter } from "@gh-symphony/tracker-github"; @@ -20,8 +21,15 @@ export async function startClaudeMcpHttpServer(options: { env: NodeJS.ProcessEnv; context: ClaudeMcpHostContext; onEvent?: (event: "started" | "stopped") => void; + adapters?: readonly Pick< + OrchestratorTrackerAdapter, + "agentToolSpecs" | "executeAgentTool" + >[]; }): Promise { const sessionToken = randomBytes(32).toString("base64url"); + const toolSnapshot = createToolSnapshot( + options.adapters ?? resolveHostToolAdapters(options.env) + ); let server: Server | null = createServer(async (request, response) => { if (!isAuthorized(request, sessionToken)) { response.writeHead(401, { "content-type": "application/json" }); @@ -38,7 +46,12 @@ export async function startClaudeMcpHttpServer(options: { response.end(JSON.stringify(error(null, -32700, "Parse error"))); return; } - const result = await dispatch(payload, options.env, options.context); + const result = await dispatch( + payload, + options.env, + options.context, + toolSnapshot + ); if (!("id" in payload)) { response.writeHead(202).end(); return; @@ -71,7 +84,8 @@ export async function startClaudeMcpHttpServer(options: { async function dispatch( payload: Record, env: NodeJS.ProcessEnv, - context: ClaudeMcpHostContext + context: ClaudeMcpHostContext, + toolSnapshot: readonly HostToolSnapshot[] ): Promise> { const id = payload.id ?? null; if (payload.method === "initialize") @@ -86,16 +100,20 @@ async function dispatch( }; if (payload.method === "ping") return { jsonrpc: "2.0", id, result: {} }; if (payload.method === "tools/list") - return { jsonrpc: "2.0", id, result: { tools: availableTools(env) } }; + return { + jsonrpc: "2.0", + id, + result: { tools: toolSnapshot.map((entry) => entry.spec) }, + }; if (payload.method !== "tools/call" || !isRecord(payload.params)) return error(id, -32601, "Method not found"); const name = payload.params.name; const argumentsValue = payload.params.arguments; if (!isRecord(argumentsValue) || typeof name !== "string") return error(id, -32602, "Tool arguments must be an object."); - const adapter = resolveHostToolAdapters(env).find((candidate) => - candidate.agentToolSpecs?.().some((tool) => tool.name === name) - ); + const adapter = toolSnapshot.find( + (entry) => entry.spec.name === name + )?.adapter; if (!adapter?.executeAgentTool) { return error(id, -32602, `Tool "${name}" is not available.`); } @@ -128,11 +146,22 @@ async function dispatch( } } -function availableTools( - env: NodeJS.ProcessEnv -): Array> { - return resolveHostToolAdapters(env).flatMap( - (adapter) => adapter.agentToolSpecs?.() ?? [] +type HostToolSnapshot = { + spec: AgentToolSpec; + adapter: Pick; +}; + +function createToolSnapshot( + adapters: readonly Pick< + OrchestratorTrackerAdapter, + "agentToolSpecs" | "executeAgentTool" + >[] +): readonly HostToolSnapshot[] { + return adapters.flatMap((adapter) => + (adapter.agentToolSpecs?.() ?? []).map((spec) => ({ + spec: structuredClone(spec), + adapter, + })) ); } From 002e911960c3e2b99d7fbcfae5c63f6d30bee37d Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 21:24:04 +0900 Subject: [PATCH 5/8] chore(changeset): release host-scoped tools --- .changeset/host-scoped-tools.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/host-scoped-tools.md diff --git a/.changeset/host-scoped-tools.md b/.changeset/host-scoped-tools.md new file mode 100644 index 00000000..fbe44ce4 --- /dev/null +++ b/.changeset/host-scoped-tools.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Run tracker tools through host-owned runtime integrations with normalized issue scope and frozen per-session contracts for #673. From b78e3b2bd2edfba810f8f4c3f3ed84b0c88b66d0 Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 23:12:37 +0900 Subject: [PATCH 6/8] fix(tools): remove unsound scope guard --- README.md | 3 +- docs/trackers/github.md | 22 ++++++------- docs/trackers/linear.md | 20 +++++------ e2e/host-dynamic-tool-e2e.mjs | 11 +++---- packages/tool-github-graphql/src/tool.test.ts | 20 ++++++++--- packages/tool-github-graphql/src/tool.ts | 33 ------------------- packages/tool-linear-graphql/src/tool.test.ts | 12 ++++--- packages/tool-linear-graphql/src/tool.ts | 33 ------------------- test/e2e/stubs/claude.sh | 5 +-- 9 files changed, 50 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 778eb1ca..88b9f9c9 100644 --- a/README.md +++ b/README.md @@ -989,8 +989,7 @@ Other Codex approval policies fail workflow validation because operator approval handling is not implemented. Operators can configure the Codex sandbox settings, but must use least-privilege credentials, dedicated workspaces, and controls appropriate to their environment. The target transport keeps credentials in the Symphony host -or a host-side broker, rejects tracker-tool calls that do not carry an exact -active-issue/provider identifier, returns only bounded issue-aware tool results to agents, +or a host-side broker, returns only bounded issue-aware tool results to agents, uses loopback-only local services with scoped session capabilities, and gives the child an isolated home/configuration directory rather than a host `gh auth` store. Authenticated Git transport is performed by the host. diff --git a/docs/trackers/github.md b/docs/trackers/github.md index 3d9e56f9..fa138265 100644 --- a/docs/trackers/github.md +++ b/docs/trackers/github.md @@ -6,15 +6,15 @@ the result only; the adapter uses the host's GitHub credential or token broker. ## `github_graphql` -| Property | Contract | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | `github_graphql` | -| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | -| Mutations | Permitted. A mutation must be intentionally scoped to the active issue or its repository. | -| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. For every host-side call, the adapter requires at least one exact active issue ID, identifier, or provider-native ID in `variables`; unscoped operations are rejected before authentication or HTTP. The document is not rewritten, so callers must still keep every selected field and mutation narrow to that target. | -| Result | The provider GraphQL payload. For queries, Symphony adds the GitHub `rateLimit` selection when absent and may return normalized rate-limit metadata with the payload. | -| Errors | Invalid tool arguments, missing active-issue scope, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | -| Rate limits | GitHub GraphQL rate-limit headers and the GraphQL `rateLimit` field are measured by the host and applied to the shared GitHub rate-limit policy; callers should keep queries small and respect retry guidance. | +| Property | Contract | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | `github_graphql` | +| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | +| Mutations | Permitted. A mutation must be intentionally scoped to the active issue or its repository. | +| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. The tool is an arbitrary GitHub GraphQL transport, so callers must constrain their document and variables to the active issue/repository; the adapter does not infer or rewrite a target. | +| Result | The provider GraphQL payload. For queries, Symphony adds the GitHub `rateLimit` selection when absent and may return normalized rate-limit metadata with the payload. | +| Errors | Invalid tool arguments, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | +| Rate limits | GitHub GraphQL rate-limit headers and the GraphQL `rateLimit` field are measured by the host and applied to the shared GitHub rate-limit policy; callers should keep queries small and respect retry guidance. | The GitHub tool is always advertised because GitHub repository and pull-request operations remain available for Linear-tracked projects; `linear_graphql` is @@ -27,8 +27,8 @@ result, or generated Claude MCP configuration. ## Safe usage -Use a named operation and pass the active issue ID or provider-native Project -item ID as an exact variable value. Keep mutations narrow—for example, update the active issue's +Use a named operation and pass the active issue/repository identifiers as +variables. Keep mutations narrow—for example, update the active issue's project item or create a comment on that issue—and verify the returned node identifier before performing a follow-up mutation. Do not use this tool to enumerate or modify unrelated repositories, organizations, or projects. diff --git a/docs/trackers/linear.md b/docs/trackers/linear.md index 2b77bb55..f8197f85 100644 --- a/docs/trackers/linear.md +++ b/docs/trackers/linear.md @@ -57,22 +57,22 @@ process. ## `linear_graphql` -| Property | Contract | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | `linear_graphql` | -| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | -| Mutations | Permitted. A mutation must be intentionally scoped to the active Linear issue or its team. | -| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never added to the GraphQL payload. The tool validates that a request has exactly one GraphQL operation and requires at least one exact active issue ID, identifier, or provider-native ID in `variables`; unscoped operations are rejected before authentication or HTTP. The document is not rewritten, so callers must still keep every selected field and mutation narrow to that target. | -| Result | The provider GraphQL response payload. | -| Errors | Empty or multi-operation documents, invalid GraphQL syntax, missing active-issue scope, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | -| Rate limits | Linear's GraphQL response is returned unchanged. No Symphony-side rate-limit scheduler is currently applied, so callers must avoid bursts and honor provider throttling responses. | +| Property | Contract | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | `linear_graphql` | +| Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | +| Mutations | Permitted. A mutation must be intentionally scoped to the active Linear issue or its team. | +| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never added to the GraphQL payload. The tool validates that a request has exactly one GraphQL operation, but it is an arbitrary Linear GraphQL transport: callers must constrain the operation and variables to the active issue/team; it does not infer or rewrite a target. | +| Result | The provider GraphQL response payload. | +| Errors | Empty or multi-operation documents, invalid GraphQL syntax, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | +| Rate limits | Linear's GraphQL response is returned unchanged. No Symphony-side rate-limit scheduler is currently applied, so callers must avoid bursts and honor provider throttling responses. | The tool is advertised only when the selected tracker adapter is Linear. It is not an MCP subprocess and must not be recreated by the coding-agent child. ## Safe usage -Use one named operation and bind the active issue ID as an exact variable value. +Use one named operation and bind the active issue ID explicitly in variables. Keep mutations narrow—for example, update the active issue state or create a comment on that issue—and inspect the returned `success` flag and node ID. Do not query or mutate unrelated teams, issues, or workspace resources. diff --git a/e2e/host-dynamic-tool-e2e.mjs b/e2e/host-dynamic-tool-e2e.mjs index dc8fad18..4fcd0f74 100644 --- a/e2e/host-dynamic-tool-e2e.mjs +++ b/e2e/host-dynamic-tool-e2e.mjs @@ -32,7 +32,7 @@ globalThis.fetch = async (url, init) => { }, }, } - : { data: { node: { id: "docker-issue-730" } } } + : { data: { viewer: { login: "docker-host-tool" } } } ), { status: 200 } ); @@ -42,10 +42,7 @@ try { responses = await Promise.all([ executeCodexDynamicToolCall( "github_graphql", - { - query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", - variables: { id: "docker-issue-730" }, - }, + { query: "query { viewer { login } }" }, context, env ), @@ -82,7 +79,7 @@ try { if ( responses.some((response) => response.success !== true) || responses[0]?.contentItems[0]?.text !== - '{"data":{"node":{"id":"docker-issue-730"}}}' + '{"data":{"viewer":{"login":"docker-host-tool"}}}' ) { throw new Error(`unexpected_host_tool_response:${JSON.stringify(responses)}`); } @@ -94,7 +91,7 @@ if ( request.url !== "https://api.github.com/graphql" || request.init?.headers?.authorization !== "Bearer host-only-test-token" ) || - !requests[0]?.init?.body?.includes("docker-issue-730") || + !requests[0]?.init?.body?.includes("viewer") || !requests[1]?.init?.body?.includes("addComment") || !requests[1]?.init?.body?.includes("docker-issue-730") || !requests[2]?.init?.body?.includes("updateProjectV2ItemFieldValue") || diff --git a/packages/tool-github-graphql/src/tool.test.ts b/packages/tool-github-graphql/src/tool.test.ts index 9496fc35..f3aa61b3 100644 --- a/packages/tool-github-graphql/src/tool.test.ts +++ b/packages/tool-github-graphql/src/tool.test.ts @@ -300,8 +300,12 @@ describe("resolveGitHubGraphQLToken", () => { }); describe("executeGitHubGraphQL", () => { - it("rejects a host-side operation that is not scoped to the active issue", async () => { - const fetchImpl = vi.fn(); + it("executes a repository query while carrying host-side issue context", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { viewer: { login: "octo" } } }), { + status: 200, + }) + ); await expect( executeGitHubGraphQL( @@ -312,12 +316,18 @@ describe("executeGitHubGraphQL", () => { issue: { id: "issue-1", identifier: "owner/repo#1", - nativeRef: { itemId: "project-item-1" }, + nativeRef: { + itemId: "project-item-1", + contentType: "Issue", + sourceState: "OPEN", + linkedPullRequests: [], + linkedPullRequestsTruncated: false, + }, }, } ) - ).rejects.toThrow(/active issue scope/); - expect(fetchImpl).not.toHaveBeenCalled(); + ).resolves.toEqual({ data: { viewer: { login: "octo" } } }); + expect(fetchImpl).toHaveBeenCalledOnce(); }); afterEach(() => { diff --git a/packages/tool-github-graphql/src/tool.ts b/packages/tool-github-graphql/src/tool.ts index d8216d1c..3fc5b795 100644 --- a/packages/tool-github-graphql/src/tool.ts +++ b/packages/tool-github-graphql/src/tool.ts @@ -61,7 +61,6 @@ export async function executeGitHubGraphQL( context?: TrackerToolExecutionContext ): Promise { assertTrackerToolExecutionContext(context); - assertInvocationTargetsActiveIssue(invocation, context); const token = await resolveGitHubGraphQLToken(config, { fetchImpl, }); @@ -153,38 +152,6 @@ function assertTrackerToolExecutionContext( } } -function assertInvocationTargetsActiveIssue( - invocation: GitHubGraphQLInvocation, - context: TrackerToolExecutionContext | undefined -): void { - if (!context) return; - const allowedTargets = collectScopeValues({ - id: context.issue.id, - identifier: context.issue.identifier, - nativeRef: context.issue.nativeRef, - }); - const invocationValues = collectScopeValues(invocation.variables ?? {}); - if (![...invocationValues].some((value) => allowedTargets.has(value))) { - throw new Error( - "github_graphql requires variables that target the active issue scope." - ); - } -} - -function collectScopeValues( - value: unknown, - output = new Set() -): Set { - if (typeof value === "string" && value.trim() !== "") { - output.add(value); - } else if (Array.isArray(value)) { - for (const entry of value) collectScopeValues(entry, output); - } else if (value && typeof value === "object") { - for (const entry of Object.values(value)) collectScopeValues(entry, output); - } - return output; -} - type GitHubGraphQLPayload = { data?: Record | null; errors?: Array<{ message: string }>; diff --git a/packages/tool-linear-graphql/src/tool.test.ts b/packages/tool-linear-graphql/src/tool.test.ts index 828b15b5..85094d1d 100644 --- a/packages/tool-linear-graphql/src/tool.test.ts +++ b/packages/tool-linear-graphql/src/tool.test.ts @@ -54,8 +54,12 @@ describe("validateLinearGraphQLInvocation", () => { }); describe("executeLinearGraphQL", () => { - it("rejects a host-side operation that is not scoped to the active issue", async () => { - const fetchImpl = vi.fn(); + it("executes a workspace query while carrying host-side issue context", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { viewer: { id: "user-1" } } }), { + status: 200, + }) + ); await expect( executeLinearGraphQL( @@ -70,8 +74,8 @@ describe("executeLinearGraphQL", () => { }, } ) - ).rejects.toThrow(/active issue scope/); - expect(fetchImpl).not.toHaveBeenCalled(); + ).resolves.toEqual({ data: { viewer: { id: "user-1" } } }); + expect(fetchImpl).toHaveBeenCalledOnce(); }); it("posts a single operation with runtime-managed Authorization", async () => { diff --git a/packages/tool-linear-graphql/src/tool.ts b/packages/tool-linear-graphql/src/tool.ts index a60adf6b..75455f00 100644 --- a/packages/tool-linear-graphql/src/tool.ts +++ b/packages/tool-linear-graphql/src/tool.ts @@ -32,7 +32,6 @@ export async function executeLinearGraphQL( ): Promise { assertTrackerToolExecutionContext(context); validateLinearGraphQLInvocation(invocation); - assertInvocationTargetsActiveIssue(invocation, context); const authorization = resolveLinearAuthorizationHeader(config); const apiUrl = validateLinearGraphQLApiUrl( config.apiUrl ?? DEFAULT_LINEAR_GRAPHQL_API_URL @@ -74,38 +73,6 @@ function assertTrackerToolExecutionContext( } } -function assertInvocationTargetsActiveIssue( - invocation: LinearGraphQLInvocation, - context: TrackerToolExecutionContext | undefined -): void { - if (!context) return; - const allowedTargets = collectScopeValues({ - id: context.issue.id, - identifier: context.issue.identifier, - nativeRef: context.issue.nativeRef, - }); - const invocationValues = collectScopeValues(invocation.variables ?? {}); - if (![...invocationValues].some((value) => allowedTargets.has(value))) { - throw new Error( - "linear_graphql requires variables that target the active issue scope." - ); - } -} - -function collectScopeValues( - value: unknown, - output = new Set() -): Set { - if (typeof value === "string" && value.trim() !== "") { - output.add(value); - } else if (Array.isArray(value)) { - for (const entry of value) collectScopeValues(entry, output); - } else if (value && typeof value === "object") { - for (const entry of Object.values(value)) collectScopeValues(entry, output); - } - return output; -} - export function validateLinearGraphQLInvocation( invocation: LinearGraphQLInvocation ): void { diff --git a/test/e2e/stubs/claude.sh b/test/e2e/stubs/claude.sh index cec44494..5178fbe0 100755 --- a/test/e2e/stubs/claude.sh +++ b/test/e2e/stubs/claude.sh @@ -131,10 +131,7 @@ if (server.type !== "http") { } (async () => { const calls = [ - { - query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", - variables: { id: "issue-worker-claude" }, - }, + { query: "query { viewer { login } }" }, { query: "mutation AddComment($subjectId: ID!, $body: String!) { addComment(input: { subjectId: $subjectId, body: $body }) { commentEdge { node { id } } } }", variables: { subjectId: "issue-worker-claude", body: "host-side comment" }, From 04025b8d8137c2f892251c4ff7bf00bc9c4be19b Mon Sep 17 00:00:00 2001 From: hojinzs Date: Sun, 30 Aug 2026 23:17:41 +0900 Subject: [PATCH 7/8] docs(changeset): clarify issue context --- .changeset/host-scoped-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/host-scoped-tools.md b/.changeset/host-scoped-tools.md index fbe44ce4..55353237 100644 --- a/.changeset/host-scoped-tools.md +++ b/.changeset/host-scoped-tools.md @@ -2,4 +2,4 @@ "@gh-symphony/cli": patch --- -Run tracker tools through host-owned runtime integrations with normalized issue scope and frozen per-session contracts for #673. +Run tracker tools through host-owned runtime integrations with normalized issue context and frozen per-session contracts for #673. From 49eae3fea3ef0dfbf397282dfb6574b28b4abe26 Mon Sep 17 00:00:00 2001 From: hojinzs Date: Mon, 31 Aug 2026 08:03:58 +0900 Subject: [PATCH 8/8] docs(tools): align context boundary --- docs/architecture.md | 8 +++++--- packages/tool-github-graphql/src/tool.test.ts | 4 ++++ packages/tool-linear-graphql/src/tool.test.ts | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7960b651..6c7c002a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,9 +93,11 @@ touches a layer, check that its slice (and the linked documents) still holds. [Linear](trackers/linear.md), and [file](trackers/file.md). GitHub's synthetic `Archived` state is a GitHub-specific implementation choice, not normalized Symphony core behavior. -- Host-side tracker tools: provider adapters own advertised schemas, credentials, - and active-issue scope validation. Codex snapshots those schemas in its runtime - plan; Claude snapshots them when its loopback Streamable HTTP MCP server starts. +- Host-side tracker tools: provider adapters own advertised schemas and credentials, + and receive normalized active-issue context that stays host-internal. Callers are + responsible for narrowing documents; adapters do not infer or rewrite a target. + Codex snapshots those schemas in its runtime plan; Claude snapshots them when its + loopback Streamable HTTP MCP server starts. ### 6. Observability — events and status surfaces diff --git a/packages/tool-github-graphql/src/tool.test.ts b/packages/tool-github-graphql/src/tool.test.ts index f3aa61b3..29f1576b 100644 --- a/packages/tool-github-graphql/src/tool.test.ts +++ b/packages/tool-github-graphql/src/tool.test.ts @@ -328,6 +328,10 @@ describe("executeGitHubGraphQL", () => { ) ).resolves.toEqual({ data: { viewer: { login: "octo" } } }); expect(fetchImpl).toHaveBeenCalledOnce(); + const body = JSON.parse( + String(fetchImpl.mock.calls[0]![1]!.body) + ) as Record; + expect(Object.keys(body).sort()).toEqual(["query"]); }); afterEach(() => { diff --git a/packages/tool-linear-graphql/src/tool.test.ts b/packages/tool-linear-graphql/src/tool.test.ts index 85094d1d..9001ce4a 100644 --- a/packages/tool-linear-graphql/src/tool.test.ts +++ b/packages/tool-linear-graphql/src/tool.test.ts @@ -76,6 +76,10 @@ describe("executeLinearGraphQL", () => { ) ).resolves.toEqual({ data: { viewer: { id: "user-1" } } }); expect(fetchImpl).toHaveBeenCalledOnce(); + const body = JSON.parse( + String(fetchImpl.mock.calls[0]![1]!.body) + ) as Record; + expect(Object.keys(body).sort()).toEqual(["query"]); }); it("posts a single operation with runtime-managed Authorization", async () => {