Skip to content

fix(orchestrator): gate unauthenticated plugin tools from the orchestrator's tool surface (#474) - #528

Merged
Weegy merged 12 commits into
mainfrom
feat/issue-474-orchestrator-tool-surface-auth-gate
Jul 27, 2026
Merged

fix(orchestrator): gate unauthenticated plugin tools from the orchestrator's tool surface (#474)#528
Weegy merged 12 commits into
mainfrom
feat/issue-474-orchestrator-tool-surface-auth-gate

Conversation

@Weegy

@Weegy Weegy commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 via ctx.status.report({state:'needs_action'|'error'}).
  • OAuthReadinessTracker (new) — automatic, derived from the same vault token state ctx.oauthTokens reads, for type:'oauth' plugins that never call ctx.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:

  • Tool-list assembly (Orchestrator.buildToolsList), including the hardcoded memory tool fast path
  • Tool dispatch (Orchestrator.dispatchToolInner), re-checked at invocation time to close the list-vs-dispatch race window
  • The standalone ToolDispatchService (subscription-CLI provider / loopback-MCP bridge), a separate entry point gated independently
  • System prompt promptDoc splicing and the "Fach-Agenten" domain-tool roster
  • DirectLine #token resolution (degrades to the existing "Specialist … is no longer available." notice instead of leaking an internal error string)
  • Forced tool_choice (directLineObligationState, the #332 primitive)
  • ctx.tools.registerHandler() (e.g. the Anthropic-native memory tool), which previously bypassed the agentId-based gate entirely by using a different registration API than ctx.tools.register()
  • OAuth-token expiry (a stored token that exists but is expired with no refresh token is not "ready")

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:

  1. Native tools gated at list + dispatch time (buildToolsList, dispatchToolInner), plus the standalone ToolDispatchService.
  2. getSystemPrompt()'s promptDoc collection wasn't gated — a hidden tool's documentation still reached the model.
  3. Domain tools (DomainTool, e.g. query_<slug>) weren't gated at all — a second tool-registration path the initial fix missed.
  4. The "Fach-Agenten" roster and PluginStatusRegistry.isReady()'s correctness (previously dependent on caller discipline, not its own invariant) plus forced tool_choice and DirectLine #token resolution.
  5. The entire mechanism only engaged when a plugin explicitly called ctx.status.report() — the repo's own generic OAuth Connect flow never does this automatically, so a standard type:'oauth' plugin stayed exempt until Connect completed. Added OAuthReadinessTracker, deriving readiness from the same vault state ctx.oauthTokens already reads.
  6. Branch fell behind origin/main, which had since gained a commit touching the same file — merged and re-verified.
  7. registerHandler() (a second, public plugin-api tool-registration entry point, used by harness-memory/harness-memory-postgres) didn't carry an agentId, so a plugin using it instead of register() bypassed the gate.
  8. OAuthReadinessTracker checked token presence, not usability — an expired, non-refreshable token still read as "connected". Extracted the exact expiry/refreshability logic ctx.oauthTokens.get() already used into a shared helper so the two can't drift.
  9. The hardcoded memory tool fast path in buildToolsList/dispatchToolInner bypassed the registry gate entirely, regardless of round 7's fix.
  10. docs/middleware-agent-handoff.md needed a section describing the new cross-cutting mechanism, per this repo's own documentation policy.
  11. 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 how ctx.oauthTokens.get() itself never caches a verdict.
  12. Final merge of origin/main (one more commit landed, unrelated, no code overlap — only a docs/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.ts looking like scope creep) came up repeatedly across rounds — confirmed false each time: those came from already-merged, unrelated origin/main commits (#499, #507, #525) pulled in by this branch's own merge commits, not new work. git diff origin/main...HEAD shows 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.md intentionally 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 12 commits July 27, 2026 07:54
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
Weegy enabled auto-merge (squash) July 27, 2026 11:08
@Weegy
Weegy merged commit 5df74d9 into main Jul 27, 2026
7 checks passed
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.

Non-authenticated Plugins are available to the orchestrator

1 participant