Skip to content

feat(tools,worker): finalize host tool contracts - #775

Merged
moncher-dev merged 8 commits into
mainfrom
feat/673-host-tool-integration-polish
Aug 31, 2026
Merged

feat(tools,worker): finalize host tool contracts#775
moncher-dev merged 8 commits into
mainfrom
feat/673-host-tool-integration-polish

Conversation

@moncher-dev

@moncher-dev moncher-dev commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Issues

TL;DR

  • Completes integrated verification of Codex and Claude host-side tracker tools.
  • Preserves normalized { id, identifier, nativeRef } context at the host adapter boundary without exposing provider credentials to child processes, generated MCP configuration, or provider GraphQL payloads.
  • Freezes Claude tool specifications and execution bindings per worker session and documents each provider tool contract.

Change-point diagram

normalized issue { id, identifier, nativeRef }
                  |
                  v
worker host ------+------ Codex item/tool/call -------+
                  |                                    |
                  +------ Claude loopback HTTP MCP ----+--> snapshotted adapter executeTool()
                                                             |
                                                             +--> host auth + provider API

Start here

  1. Review the Claude session snapshot in packages/runtime-claude/src/mcp-http-server.ts and its reload regression test.
  2. Review normalized issue-context and provider-payload assertions in the tool and worker tests.
  3. Review the cross-runtime black-box assertions in e2e/host-dynamic-tool-e2e.mjs and test/e2e/claude/claude-docker.spec.ts.
  4. Review the per-tool contracts in docs/trackers/github.md and docs/trackers/linear.md.

Changes

  • Deep-copy Claude tool specs at server startup and bind execution to the same immutable adapter/name snapshot.
  • Verify normalized issue context reaches GitHub and Linear host adapters on Codex and Claude paths while remaining absent from provider payloads.
  • Preserve structured unknown-tool failures without ending the worker session.
  • Verify query, comment, and Project-state calls use host credentials while child environments and MCP files remain credential-free.
  • Document tool schemas, mutation capability, caller-owned scope narrowing, result/error forms, and rate-limit behavior consistently across tracker and architecture docs.
  • Add the required @gh-symphony/cli patch changeset.

Evidence

  • pnpm lint && pnpm test && pnpm typecheck && pnpm build — pass on head 49eae3fe.
  • pnpm exec vitest run packages/tool-github-graphql/src/tool.test.ts packages/tool-linear-graphql/src/tool.test.ts — pass (2 files, 41 tests), including host-internal context payload assertions.
  • GitHub Actions Test and Container Smoke — pass on head 49eae3fe.
  • Codex TC-19: docker compose -f docker-compose.e2e.yml exec -T symphony-e2e node /app/e2e/host-dynamic-tool-e2e.mjs — pass (host_dynamic_tool_e2e=pass).
  • Claude TC-19: pnpm e2e:claude — pass (5/5).
  • git merge-base --is-ancestor origin/main HEAD — pass.

Risks & rollback

  • Arbitrary GitHub and Linear GraphQL documents are not rewritten or target-authorized by these transports; callers must keep operations narrow as documented.
  • An in-flight Claude session intentionally retains the tool contract captured at server startup, even after workflow reload.
  • Rollback by reverting this PR to restore the prior Claude tool-spec lookup and test/document state.

Changed files

  • Claude session snapshot: packages/runtime-claude/src/mcp-http-server.ts and mcp-http-server.test.ts.
  • Context and behavior coverage: worker, tracker adapter, tool package, and Claude Docker tests.
  • Living docs: docs/architecture.md, docs/trackers/github.md, and docs/trackers/linear.md.
  • Release: .changeset/host-scoped-tools.md.

Post-merge / human validation

  • Validate one live GitHub tracker worker with provider credentials.
  • Validate one live Linear tracker worker with provider credentials.
  • Confirm no raw tracker credential is visible in a child environment or generated MCP configuration.

Human Validation

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 40.81% 44226 / 108347
🔵 Statements 40.81% 44226 / 108347
🔵 Functions 72.8% 2168 / 2978
🔵 Branches 78.45% 10062 / 12826
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/runtime-claude/src/mcp-http-server.ts 85.36% 75% 100% 85.36% 45-48, 118-119, 131-146, 188-189
Generated in workflow #1169 for commit 49eae3f by the Vitest Coverage Report Action

@moncher-dev
moncher-dev marked this pull request as ready for review August 30, 2026 12:25
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T12:29:16.211610Z fab57d9 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fab57d915a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +161 to +165
const allowedTargets = collectScopeValues({
id: context.issue.id,
identifier: context.issue.identifier,
nativeRef: context.issue.nativeRef,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict nativeRef matching to identity fields

Because collectScopeValues recursively accepts every string in nativeRef, non-identifiers such as GitHub's contentType, source state, branch names, or Linear's projectSlug satisfy this guard. For example, a mutation targeting another issue can pass "Issue" as its comment body and be executed with the host credential because the active GitHub issue's native reference contains contentType: "Issue"; the identical Linear helper has the same problem. Whitelist adapter-owned issue/item identifiers instead of treating all opaque native metadata as authorized targets.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by removing the presence-based scope predicate from both GraphQL adapters. It could not bind the document's actual target, so retaining it as an authorization boundary would be unsafe. Normalized { id, identifier, nativeRef } context still reaches the host adapter and remains internal; the contract now states that callers must constrain arbitrary GraphQL documents. Regression tests verify legitimate host calls continue with realistic context.

Comment on lines +166 to +167
const invocationValues = collectScopeValues(invocation.variables ?? {});
if (![...invocationValues].some((value) => allowedTargets.has(value))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Supply GitHub scope before advertising it for Linear runs

When SYMPHONY_TRACKER_KIND is linear, resolveHostToolAdapters() still advertises github_graphql for repository and PR work, but this allowed set contains only the Linear issue ID, identifier, and native reference. A normal GitHub query whose variables are a repository owner/name or PR node ID therefore has no possible match and is rejected before HTTP, despite the documented cross-provider tool availability. Linear runs need relevant GitHub repository/PR scope in the execution context, or the tool should not be advertised as usable there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by removing the tracker-context value-match predicate. github_graphql remains advertised and usable for repository and pull-request operations on Linear-tracked runs, while the Linear issue context is still delivered internally to the host adapter. The tracker documentation again describes this cross-provider capability without claiming issue-target enforcement.

@hojinzs
hojinzs self-requested a review August 30, 2026 13:37

@hojinzs hojinzs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — round 1 of 6

I verified this branch locally at fab57d9. pnpm lint, pnpm test, pnpm typecheck, pnpm build all pass, and both CI checks (Test, Container Smoke) are green — the Validation section of the description is accurate. The failure I hit first was just a build-order artifact on a cold checkout, not a real one.

Requesting changes on one issue: the active-issue scope guard is not the boundary this PR documents it as.

Smoke test

Rather than rely on reading, I exercised executeGitHubGraphQL directly with a nativeRef copied verbatim from what tracker-github actually produces. Three bypasses, all confirmed executing:

✓ BYPASS A: a low-entropy nativeRef constant authorizes another issue
✓ BYPASS B: an unused decoy variable authorizes an inlined foreign target
✓ BYPASS C: the issue state string is enough to authorize any call

Two of these carried an addComment mutation against a foreign issue node ID all the way to fetch with the host credential. Details and suggested fixes are in the inline comment on packages/tool-github-graphql/src/tool.ts.

Bypass B is the one I'd focus on: the guard reads only invocation.variables and never the query text, so an agent can park the real issue ID in an unused variable and inline the actual target as a GraphQL literal. That survives the identity-field whitelist suggested in the Codex thread — it's a target-binding problem, not a field-selection problem.

Requirement traceability vs #673

Verified present:

  • Normalized { id, identifier, nativeRef } reaches the tools on both runtimes ✅
  • Unsupported tool name → structured failure, session continues (codex-dynamic-tools.test.ts:68) ✅
  • Dead dynamic_tool_call_* / runToolProcess paths gone — no remaining references ✅
  • Tool specs pinned per session, reload doesn't affect in-flight ✅ (clean implementation, good test)
  • §10.5 per-tool contracts documented ✅

Gap: the Docker E2E cited as evidence had its viewer { login } probes rewritten to scoped node(id:) calls because the new guard rejects them. Correct in itself, but it means the E2E now covers the happy path only.

Scope / over-engineering

The scope guard is an addition beyond what #673 asked. The issue asks that context be passed so the adapter can narrow (어댑터가 provider 식별자로 scope 제한(§15.5 narrowing) 가능하게) — not that enforcement ship here. That matters for how you resolve this: dropping the guard and restoring the previous "callers must constrain their own document" doc wording is fully in scope for the issue and is a legitimate way to close this review. What I'd avoid is keeping it as-is, because README.md's security section and both tracker docs now describe a hard pre-auth boundary, and a reader will budget trust against it that the code doesn't earn.

The guard is also simultaneously too weak and too strong: it's bypassable (P1) and it breaks the documented cross-provider capability, since github_graphql is advertised on Linear runs but can never match a Linear issue context (P2).

Prior review

The Codex review on this same commit raised both a P1 and a P2, and neither has a fix or a reply on the thread. My review confirms both independently — P1 with a working reproduction, and P1 is somewhat worse than described. Please respond on those threads too, so the record shows the decision.

Findings

# Sev Where Issue
1 P1 tool-github-graphql/src/tool.ts:156-186 Scope guard doesn't bind the operation target — 3 confirmed bypasses
2 P1 tool-linear-graphql/src/tool.ts:77-108 Identical copy, same defect; projectSlug authorizes the workspace
3 P2 mcp-http-server.ts github_graphql advertised but unusable on Linear runs
4 P2 docs/trackers/*.md, README.md Docs claim "exact ID" enforcement the code doesn't implement
5 P2 tool.test.ts (both) New tests only cover the zero-variable case, which cannot fail
6 P3 both tool packages assertInvocationTargetsActiveIssue/collectScopeValues duplicated verbatim
7 nit both tool packages if (!context) return makes the check silently opt-in for context-less callers

The tool-spec freeze — the part the PR title is actually about — is solid and I have no concerns with it. Everything above is about the scope guard that came along with it.

Either of the two paths in finding #1 (bind the target properly, or drop the guard and align the docs) resolves this from my side. The narrow fix — a proper target-binding check plus the regression tests listed inline — is a contained change, so I'd expect this to close in one more round.


Generated by Claude Code

Comment thread packages/tool-github-graphql/src/tool.ts Outdated
Comment thread packages/tool-linear-graphql/src/tool.ts Outdated
Comment thread packages/tool-github-graphql/src/tool.test.ts Outdated
Comment thread docs/trackers/github.md Outdated
Comment thread packages/runtime-claude/src/mcp-http-server.ts
@moncher-dev
moncher-dev force-pushed the feat/673-host-tool-integration-polish branch from d3f9e6b to 04025b8 Compare August 30, 2026 14:17
@moncher-dev moncher-dev changed the title test(worker): verify host-side tracker tools end to end feat(tools,worker): finalize host tool contracts Aug 30, 2026
@moncher-dev
moncher-dev requested a review from hojinzs August 30, 2026 14:18

@hojinzs hojinzs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — round 2 of 6 · Approve

Verified locally at 04025b8. pnpm install && pnpm build && pnpm test && pnpm lint && pnpm typecheck && pnpm format all pass, and both CI checks (Test, Container Smoke) are green on this head.

The round-1 P1 is resolved. You took option 2 — dropping the guard and restoring the caller-owned narrowing contract — which is what I said would close this from my side, and you landed it once across both packages rather than fixing one and missing the other.

Verification

tool.ts in both tool packages is now byte-identical to origin/main (git diff origin/main...HEAD touches neither), and there are no surviving references to assertInvocationTargetsActiveIssue / collectScopeValues anywhere in the tree. The net PR is now the tool-spec freeze plus tests and docs — 182/19 across 12 files, and every remaining line is in scope for #673.

I re-ran my round-1 bypasses plus the cases the guard used to break, against the built packages:

✓ cross-provider GitHub query executes under Linear context
✓ nativeRef itemId absent from payload
✓ payload top-level keys are GraphQL-only    (query,variables)
✓ zero-variable query no longer rejected
✓ Linear query executes with context
✓ Linear nativeRef absent from payload
✓ empty-identity context still rejected

And the freeze, end-to-end against a live loopback server:

✓ reload (new array) does not affect in-flight session
✓ reloaded tool name is not callable in-flight
✓ snapshotted tool still executable
✓ in-place spec mutation does not leak (structuredClone)
✓ unknown tool -> structured error
✓ session usable after unknown tool
✓ unauthenticated request rejected 401

That last block is the part the PR title is actually about, and it is correct on all three axes — spec listing, execution binding, and deep-copy isolation.

Round-1 findings

# Sev Status
1 P1 ✅ Guard removed; 3 bypasses no longer reachable as an authorization claim
2 P1 ✅ Removed in the Linear package too, in the same commit
3 P2 ✅ Confirmed by smoke: a GitHub repo query now executes under a Linear issue context
4 P2 ⚠️ Mostly — README.md and both tracker docs corrected; docs/architecture.md:97 still claims "active-issue scope validation"
5 P2 ✅ Vacuous zero-variable tests replaced with executing tests on realistic nativeRef (see P3 inline)
6 P3 ✅ Resolved by deletion rather than extraction — the right call for code that shouldn't exist
7 nit n/a — now outside this PR's diff, since tool.ts is untouched

Requirement traceability vs #673

All acceptance criteria hold, and guard removal doesn't cost any of them — the issue asked that context be passed so the adapter can narrow (어댑터가 provider 식별자로 scope 제한(§15.5 narrowing) 가능하게), not that enforcement ship here:

  • Normalized { id, identifier, nativeRef } reaches tools on both runtimes ✅
  • Unsupported tool name → structured failure, session continues ✅ (Codex codex-dynamic-tools.test.ts:68; Claude verified by smoke)
  • Dead dynamic_tool_call_* / runToolProcess paths gone ✅
  • Tool specs pinned per session, reload doesn't affect in-flight ✅
  • §10.5 per-tool contracts documented ✅
  • Host credentials absent from child env and generated MCP config ✅

The E2E probes are back to their original repository/workspace query shape, so the Docker evidence once again exercises the real call shape rather than the guard's happy path.

Over-engineering

Nothing left beyond the issue's ask. The one over-reach — enforcement the issue didn't request — is gone. The adapters? injection seam is a fair trade for making the freeze testable, and the routable/routableReason fixture refresh is legitimate rather than scope creep: worker/src/turn-lease.ts:69 reads those fields.

Findings

# Sev Where Issue
1 P2 docs/architecture.md:97 Claims "active-issue scope validation" the code no longer does
2 P3 tool.test.ts (both) Test passes context but asserts nothing about it; nativeRef non-leak untested
3 nit mcp-http-server.test.ts:16 Freeze test covers tools/list only, not execution binding or structuredClone
4 nit docs/trackers/github.md, worker-protocol.test.ts Whitespace/prettier-only churn in the diff

Approving. Finding #1 is a three-word correction and the last echo of the problem that drove round 1 — please land it before merge, along with #2 if it's cheap; neither warrants another review round, and the rest are optional. Requesting changes again over a doc line would be exactly the ping-pong worth avoiding on a PR that is otherwise clean.

Per #673's merge gate, remember #700 ships in this same release, ordered #673 → #700.


Generated by Claude Code

Comment thread docs/architecture.md Outdated
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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this line still claims the enforcement that b78e3b2 removed

provider adapters own advertised schemas, credentials, and active-issue scope validation

This is the last surviving instance of the round-1 finding #4 defect. README.md and both tracker contracts were correctly walked back, but this bullet — added by this PR — was not updated when the guard came out.

After the removal, the only thing left in either adapter is assertTrackerToolExecutionContext (tool-github-graphql/src/tool.ts:144), which checks that issue.id / issue.identifier are non-empty. That is context shape validation, not scope validation: it inspects nothing about the operation's target, and I confirmed a foreign-target document executes fine under an active-issue context.

Suggested wording, which also matches what docs/trackers/*.md now say:

Suggested change
and active-issue scope validation. Codex snapshots those schemas in its runtime
- 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.

The second sentence about snapshotting is accurate as written — I verified both runtimes behave that way.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 49eae3fe. docs/architecture.md now uses the suggested boundary: adapters own schemas and credentials, receive host-internal normalized issue context, and leave target narrowing to callers without inferring or rewriting the GraphQL target.

});

describe("executeGitHubGraphQL", () => {
it("executes a repository query while carrying host-side issue context", async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the test's name promises more than its assertions deliver (same for the Linear twin at tool-linear-graphql/src/tool.test.ts:57)

This is a genuine improvement on what it replaced — it uses a realistic nativeRef and proves guard removal doesn't block a legitimate call, which was its stated purpose. But context is passed and then never asserted on. The two assertions are "resolves to the payload" and "fetch called once", both of which hold identically if the 4th argument is dropped entirely.

The invariant worth pinning is the one the contract states in prose and nothing tests:

nativeRef remains host-internal and is never sent as an extra GraphQL payload field.

I verified it holds today — a repo query under a full GitHub nativeRef (including linkedPullRequests URLs and branch names) produced a body with keys query,variables only, and no context string anywhere in it. Two lines pin it:

const body = JSON.parse(String(fetchImpl.mock.calls[0][1]!.body));
expect(Object.keys(body).sort()).toEqual(["query"]);

Worth having because this is a claim a reader will treat as load-bearing, and it is currently held up by nothing but the absence of code that would break it.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 49eae3fe for both GitHub and Linear. Each context-carrying execution test now parses the outbound request body and asserts its only top-level key is query, pinning that normalized context and nativeRef are not serialized into provider payloads.

});

describe("Claude host MCP HTTP server", () => {
it("freezes the advertised tool specs when the server starts", async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the freeze test covers one of the change's three halves

I have no concerns with the implementation; I exercised all three halves against a live server and every one behaves correctly. Just noting what the suite would catch on a regression:

Behavior Covered here
tools/list ignores a reloaded spec list
tools/call stays bound to the snapshot entry
structuredClone defeats in-place spec mutation

The second is the one I'd add, since it's the half that actually matters at runtime — a reload could otherwise re-point execution mid-session while tools/list still looks frozen:

const call = await fetch(server.url, { /* …tools/call name: "reloaded_tool" */ });
// must be rejected; "snapshotted_tool" must still execute

For the third: because the test reassigns specs rather than mutating it, it passes with or without structuredClone — so the deep copy is currently unverified. Mutating specs[0]!.name in place instead would cover both the reassignment and the mutation path in one test.

Non-blocking — the behavior is right, this is only about what a future regression would trip over.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged as non-blocking. I left this test unchanged in the narrow pre-merge correction: the implementation and owner smoke already verify execution binding and in-place mutation isolation, while this cycle is limited to the required architecture fix and the cheap adapter payload assertions. The existing snapshot test continues to pin the advertised-spec reload behavior.

@moncher-dev
moncher-dev merged commit b9f676d into main Aug 31, 2026
2 checks passed
@moncher-dev
moncher-dev deleted the feat/673-host-tool-integration-polish branch August 31, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tools,worker): pass the normalized issue (id, nativeRef) as tool execution context, remove the dead dynamic-tool path, document per-tool contracts

2 participants