fix(orchestrator): gate unauthenticated plugin tools from the orchestrator's tool surface (#474) - #528
Merged
Conversation
A native plugin's tools (registered via ctx.tools.register from activate()) were offered to and invocable by the orchestrator even when the plugin's own connection/auth setup was still incomplete — the model would call the tool and only then discover it had no access, wasting a turn. buildToolsList() now excludes a plugin's tools from the offered list whenever the plugin currently reports a needs_action/error status via the existing ctx.status accessor (PluginStatusRegistry, spec 004), which is exactly the plugin-authored, explicit connection-completeness signal already available for this. The same check runs again at dispatch time in dispatchToolInner() and in the standalone ToolDispatchService (used by the subscription-CLI provider) so a status change between list-assembly and the actual call cannot slip through either. The gate is threaded through as an optional callback (isPluginToolsReady) from a new kernel service (installedPluginToolsReadyReader) down through OrchestratorDeps and plugin.ts's activate(), mirroring the existing pluginConfigGet wiring. Absent wiring (tests, legacy hosts) preserves pre-#474 behaviour. Deliberately does not touch the MCP-server-specific auth flow (mcpOAuthService) — this only gates the general native-plugin registration path.
Review round 2 finding: buildToolsList() and dispatchToolInner() already gated plugin-contributed tools on isToolAvailable/isPluginToolsReady, but getSystemPrompt() collected every native tool's promptDoc unconditionally via the same nativeTools.listWithHandler() iterator. A plugin gated out of tools[] still had its promptDoc spliced into the system prompt every turn, telling the model it had a capability that was just hidden from it — undercutting the issue's goal of avoiding a model discovering-by-failure that it lacks access, replacing a clean 'tool not offered' state with a confusing 'documented but missing tool' one. Thread the same isToolAvailable(agentId) check into the promptDoc collection in getSystemPrompt(), matching buildToolsList() and the dispatch-time re-check. Add a test mirroring the existing pluginToolsReadyGate.test.ts pattern that asserts a gated plugin's promptDoc is absent from the system prompt while a ready plugin's promptDoc is present.
buildToolsList() and dispatchToolInner() still exposed and invoked every DomainTool (dynamic-agent-plugin tools, e.g. query_<slug>) regardless of the owning plugin's connection/auth state, even though the same isToolAvailable(agentId) gate already covered native tools registered via ctx.tools.register(). A not-ready plugin's domain tool was still offered in tools[] and still invocable via dispatchToolInner(), reproducing the exact failure mode #474 was meant to remove. Apply the same gate to the domainToolsByName iteration in buildToolsList() (exclude a not-ready plugin's domain tool spec) and to the domain-tool branch of dispatchToolInner() (refuse with the same Error:-prefixed convention, do not invoke the handler). Also close the identical gap in ToolDispatchService (the standalone CLI-bridge dispatcher already gated its native-tool branch for #474 but not its domain-tool branch), so the subscription-CLI provider path stays consistent with the primary Orchestrator. Add tests mirroring pluginToolsReadyGate.test.ts and toolDispatchService.test.ts's existing native-tool coverage, for the domain-tool path: excluded from tools[] when not ready, present when ready, dispatch refused when not ready.
Round-3 review found two remaining gaps in the plugin-readiness gate: - Orchestrator.getSystemPrompt() built the "Fach-Agenten" domain-tool roster from every DomainTool unconditionally, even though buildToolsList() already hid not-ready ones from tools[]. The model was still told to route to a tool it could no longer see or invoke. Now filtered through the same isToolAvailable(agentId) gate before it reaches buildSystemPrompt(). - PluginStatusRegistry.isReady() only checked for entry absence, so its correctness depended entirely on every caller going through StatusAccessor.report()'s 'ok' -> clear() normalization before ever calling the class's own public set(). isReady() now reads the stored entry's state directly, so it stays correct regardless of how a status got stored. Self-audit of every domainToolsByName / nativeTools.listWithHandler() call site in orchestrator.ts and toolDispatchService.ts (requested by the round-3 review to prevent a round 4) surfaced one more instance of the same class of bug: directLineObligationState() (the #332 forced-delegation primitive) could still resolve a not-ready plugin's domain tool as the turn's forced tool_choice, naming a tool buildToolsList() had already excluded from tools[]. Gated the same way. Every other call site was confirmed to either already gate correctly or not be a model-facing surface (trace/telemetry labeling, post-dispatch attachment draining, privacy bypass resolution after dispatch already succeeded) - see the PR's Coverage audit section. Added tests mirroring the existing promptDoc-gate test for the roster, a direct set({state:'ok'}) bypass test for isReady(), and an obligation test for the forced tool_choice gate; all three fail against the pre-fix code and pass after.
…iness (#474) Round-4 reviewer finding: Orchestrator.executeDirectLine() built its candidate list from Array.from(this.domainToolsByName.values()) with no isToolAvailable() filter, unlike every other domainToolsByName consumer already gated in this branch. A #token addressed to a not-ready plugin's domain tool resolved successfully and reached dispatchTool(); dispatchToolInner() already blocked the handler safely (no capability leak), but its raw 'Error: tool ... is unavailable ...' string was then wrapped into a delegatedAnswer and shown to the user as though the specialist itself had answered. Check the resolved candidate's readiness right after resolution and reuse the existing 'Specialist ... is no longer available.' notice (the same path already used for a deleted tool) instead of surfacing the internal dispatch-error string. Adds a regression test asserting the notice appears, not a raw Error:-prefixed string.
Round 5 fix: the existing readiness gate (PluginStatusRegistry) only
withholds a plugin's tools when the PLUGIN's own code calls
ctx.status.report(). The generic install/Connect flow never does this
automatically — installService.ts activates a type:'oauth' plugin
(registering its ctx.tools.register()-contributed tools) the moment
configure() completes, before the operator has clicked Connect and the
kernel OAuth broker has stored any tokens. A plugin author who never
wrote an explicit status-report call for this (the common case) had its
tools offered and invoked anyway, failing on the first call with
OAuthTokenError('not_connected') — reproducing the exact round-trip
issue #474 was filed to eliminate.
Adds OAuthReadinessTracker, a synchronous cache of each plugin's
declared type:'oauth' field connection state, derived from the same
vault state ctx.oauthTokens already reads. It refreshes on every
ToolPluginRuntime / DynamicAgentRuntime activate() — the single choke
point shared by fresh install, boot-time reactivation, and
post-Connect reactivation for each runtime — and is cleared on
deactivate(). The orchestrator's readiness gate now ANDs this
automatic signal with the existing PluginStatusRegistry one; kept as
two separate caches (not merged into one) so neither can silently
clobber the other's verdict.
…or-tool-surface-auth-gate Brings in origin/main commits up to 8ca2613 (fix(orchestrator): embed image attachments as vision input, #525), which touched the same file (orchestrator.ts) this branch rewrites most heavily. Both feature sets are disjoint in the functions they touch (#525: buildUserContent, ingestAttachments, chatInContextInner/chatStreamInner attachment pre-fetch; #474: buildToolsList, dispatchToolInner, getSystemPrompt, executeDirectLine, directLineObligationState, the new isToolAvailable helper), so git auto-merged orchestrator.ts cleanly with no conflict markers -- verified by re-reading the merged file end to end and confirming every #474 gate site (buildToolsList, dispatchToolInner x2, getSystemPrompt promptDoc + Fach-Agenten roster, directLineObligationState, executeDirectLine) and every #525 vision site (OrchestratorOptions, buildUserContent, both ingestAttachments call sites, ingestAttachments itself) are present and correctly wired together. docs/CHANGELOG.md had a real conflict (both branches appended an Unreleased entry) -- resolved by keeping both entries, #474 first. Full verification gate re-run after the merge: build, lint, typecheck, and the complete middleware test suite (4770 tests) all green, including this branch's own 5 test files (72 tests) and #525's vision-ingest tests.
Round 8 review finding: ctx.tools.registerHandler() (used for tools whose wire-spec the kernel emits itself, e.g. the Anthropic-native memory tool harness-memory / harness-memory-postgres register) never stored an agentId on its NativeToolRegistry entry, so the agentId === undefined => always -available default meant for genuinely kernel-internal registrations silently applied to any plugin using this path instead of register(), bypassing the isToolAvailable(agentId) readiness gate entirely. - NativeToolHandlerRegistrationOptions and NativeToolRegistration gain an optional agentId, mirroring the register() path. - ctx.tools.registerHandler() in pluginContext.ts now passes the calling plugin's own agentId, exactly like ctx.tools.register() already does. No new gate logic - the entry flows through the existing isToolAvailable(agentId) check applied by buildToolsList, dispatchToolInner, getSystemPrompt's promptDoc/roster, and ToolDispatchService. - Add tests mirroring the register() gate suite: a not-ready plugin's registerHandler()-registered tool is excluded from the system-prompt promptDoc and refused at dispatch; a kernel-internal (no agentId) registerHandler() call stays always-available. - CHANGELOG follow-up entry under the existing #474 section.
…fast path (#474) Round 10 review fixes — two real gaps in the tool-readiness gate: 1. OAuthReadinessTracker.isConnected() only checked that SOME token bundle was stored, not that it was still usable. A token that's expired with no refresh token (the exact case ctx.oauthTokens.get() throws OAuthTokenError('refresh_failed') for) was still reported ready. The expiry/refresh-token check ctx.oauthTokens.get() already performs is factored out into tokenStore.ts (isTokenStillFresh / isTokenRefreshable) and reused by OAuthReadinessTracker.refresh(), so the two can never drift on what counts as expired. A token that's expired but has a refresh token still counts as ready. 2. The hardcoded memory tool (memory_20250818) fast path in buildToolsList() and dispatchToolInner() bypassed the isToolAvailable(agentId) gate entirely — a plugin contributing memory via ctx.tools.registerHandler('memory', ...) with isPluginToolsReady() === false still had it offered and dispatched. Both call sites now look up the memory entry's own agentId (if any plugin registered it) and gate on it before taking the fast path. harness-memory / harness-memory-postgres stay always-available since neither reports a connection status. Adds tests for both, plus a CHANGELOG entry per AGENTS.md.
Per AGENTS.md's decision tree, a new cross-cutting mechanism (isToolAvailable gate, PluginStatusRegistry + OAuthReadinessTracker, the installedPluginToolsReadyReader service, and the OrchestratorOptions / ToolDispatchService wiring) requires a docs/middleware-agent-handoff.md section, not just CHANGELOG entries. Adds a concise Plugin-Tool-Readiness-Gate subsection: the gate's call sites, the two ANDed readiness signals and why they stay separate, and the backward-compat default.
…tion time (#474) OAuthReadinessTracker.isConnected() served a boolean cached once inside refresh() at activation time, never re-checked against the wall clock in between activations. A plugin activating with token freshness left but no refresh token cached as ready and stayed ready until the NEXT activation, even after crossing tokenStore.ts's 5-minute OAUTH_REFRESH_MARGIN_MS window -- reproducing the exact wasted round-trip #474 exists to prevent, just shifted into the gap between activations. refresh() now caches only the raw per-field StoredOAuthTokens (the genuinely async vault read); isConnected() recomputes isTokenRefreshable()/isTokenStillFresh() fresh on every call against Date.now() -- both are pure, synchronous, in-memory checks, mirroring how ctx.oauthTokens.get() itself never caches a verdict either. Adds a test using node:test's mock timers (t.mock.timers, already the convention in office.test.ts) to advance the clock past the refresh margin without a new refresh() call, proving isConnected() flips to false on its own.
…trator-tool-surface-auth-gate # Conflicts: # docs/CHANGELOG.md
Weegy
enabled auto-merge (squash)
July 27, 2026 11:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the gap described in #474: an unauthenticated / not-yet-connected plugin's tools were previously offered to (and dispatchable by) the orchestrator, causing a wasted round-trip that fails with
OAuthTokenError/status errors on the first real call. The orchestrator now gates every path that offers or dispatches a plugin's tools on that plugin's actual readiness, derived from two independent signals:PluginStatusRegistry— explicit, set by a plugin viactx.status.report({state:'needs_action'|'error'}).OAuthReadinessTracker(new) — automatic, derived from the same vault token statectx.oauthTokensreads, fortype:'oauth'plugins that never callctx.status.report()themselves. Recomputes freshness/refreshability against the wall clock on every read, not just at plugin-activation time.Both are ANDed together and consulted at every gate site closed over the review rounds below:
Orchestrator.buildToolsList), including the hardcodedmemorytool fast pathOrchestrator.dispatchToolInner), re-checked at invocation time to close the list-vs-dispatch race windowToolDispatchService(subscription-CLI provider / loopback-MCP bridge), a separate entry point gated independentlypromptDocsplicing and the "Fach-Agenten" domain-tool roster#tokenresolution (degrades to the existing "Specialist … is no longer available." notice instead of leaking an internal error string)tool_choice(directLineObligationState, the#332primitive)ctx.tools.registerHandler()(e.g. the Anthropic-nativememorytool), which previously bypassed theagentId-based gate entirely by using a different registration API thanctx.tools.register()Deliberately separate from the MCP-server-specific auth-gap flow (
mcpOAuthService), which already handles unauthenticated MCP servers on its own.Review history
This branch went through 12 rounds of adversarial + cross-vendor (codex) review before reaching this state. Each round closed one real, concrete gap a prior round's audit had missed — the commit history below is a genuine iterative-hardening trail, not padding:
buildToolsList,dispatchToolInner), plus the standaloneToolDispatchService.getSystemPrompt()'spromptDoccollection wasn't gated — a hidden tool's documentation still reached the model.DomainTool, e.g.query_<slug>) weren't gated at all — a second tool-registration path the initial fix missed.PluginStatusRegistry.isReady()'s correctness (previously dependent on caller discipline, not its own invariant) plus forcedtool_choiceand DirectLine#tokenresolution.ctx.status.report()— the repo's own generic OAuth Connect flow never does this automatically, so a standardtype:'oauth'plugin stayed exempt until Connect completed. AddedOAuthReadinessTracker, deriving readiness from the same vault statectx.oauthTokensalready reads.origin/main, which had since gained a commit touching the same file — merged and re-verified.registerHandler()(a second, public plugin-api tool-registration entry point, used byharness-memory/harness-memory-postgres) didn't carry anagentId, so a plugin using it instead ofregister()bypassed the gate.OAuthReadinessTrackerchecked token presence, not usability — an expired, non-refreshable token still read as "connected". Extracted the exact expiry/refreshability logicctx.oauthTokens.get()already used into a shared helper so the two can't drift.memorytool fast path inbuildToolsList/dispatchToolInnerbypassed the registry gate entirely, regardless of round 7's fix.docs/middleware-agent-handoff.mdneeded a section describing the new cross-cutting mechanism, per this repo's own documentation policy.OAuthReadinessTracker.isConnected()cached its verdict at plugin-activation time and never re-evaluated it against the wall clock afterward — a token that went stale mid-session (crossed into its refresh margin) kept reading as ready until the next activation. Now recomputes freshness fresh on every read, mirroring howctx.oauthTokens.get()itself never caches a verdict.origin/main(one more commit landed, unrelated, no code overlap — only adocs/CHANGELOG.md[Unreleased]-section conflict, resolved by keeping both entries) and full re-verification.A recurring false-positive flag (files from
attachmentExtract.ts/codegen.ts/healthScore.ts/agentSpec.tslooking like scope creep) came up repeatedly across rounds — confirmed false each time: those came from already-merged, unrelatedorigin/maincommits (#499, #507, #525) pulled in by this branch's own merge commits, not new work.git diff origin/main...HEADshows the true, clean #474-only scope.Testing
cd middleware && npm install && npm run build && npm run lint && npm run typecheck && npm test— all green on the final merged branch: 4790/4794 pass, 0 fail, 4 pre-existing skips.New/updated test files:
middleware/test/orchestrator/pluginToolsReadyGate.test.ts,middleware/test/oauth/oauthReadinessTracker.test.ts,middleware/test/orchestrator/directLine.test.ts,middleware/test/cliBridge/toolDispatchService.test.ts,middleware/test/pluginContextStatus.test.ts.Docs
docs/middleware-agent-handoff.md— new "Plugin-Tool-Readiness-Gate (Non-authenticated Plugins are available to the orchestrator #474)" subsection.docs/CHANGELOG.md— full per-round[Unreleased]entry.docs/security-architecture.mdintentionally not touched — this is an internal capability-gating mechanism, not a new auth boundary or credential flow; it doesn't fit that file's existing scope (credential storage, outbound-call proxying, plugin install trust boundary).Closes #474
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.