mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c35e9e29 | |||
| 862eb99cdb | |||
| 25b97bebdf | |||
| ee5ca9a242 | |||
| dd8543fce9 | |||
| 667942024f | |||
| 78831bbe91 | |||
| d44d7eb1a8 | |||
| 876c7d8cb3 | |||
| 98823eb769 | |||
| 4d708c30ac | |||
| 6d60ff7634 | |||
| be662c6134 | |||
| 3ef3f24c7f | |||
| db903f482a | |||
| 6aeffd1845 | |||
| a02b093733 | |||
| f311555026 | |||
| 45d95a2c1f | |||
| a2d9d9832a | |||
| ab123c6cfc | |||
| 8ad17666b9 | |||
| 03fc0861a6 | |||
| a22fb2f395 | |||
| cdcd040da2 | |||
| 834d62c9d4 | |||
| 342a77fe5c | |||
| fd7a447ef9 | |||
| 552ee3c590 | |||
| e99d3ee139 | |||
| 4f0fc3f219 | |||
| dc701986f7 | |||
| bedd25fbe7 | |||
| 251a912275 | |||
| d48902fd01 | |||
| 702ac43d0e | |||
| 01f83dc90f | |||
| 2463c480c2 | |||
| 2a3dfbc6fb | |||
| 6c3b3cc098 | |||
| 0dc52f05ee | |||
| 02929c0d00 | |||
| b2add19c56 | |||
| 5ce1873e9e | |||
| 7698a928c5 | |||
| 4e2eea2f86 | |||
| a6752cb645 | |||
| 06ba4e8d4f | |||
| 94dcaf34fd | |||
| 1a2a689033 | |||
| c02f960d0a | |||
| bfde387206 | |||
| 27d112ff60 | |||
| aeab2535b1 | |||
| 4638d22bd0 | |||
| ee3bd1dcf2 | |||
| ae3a83ccce | |||
| 0f17433e1f | |||
| 043554bb2f | |||
| 8389808add | |||
| 6cbef4f633 | |||
| 2b6dde4f7e | |||
| fbe31b9885 | |||
| ef13f40cf5 | |||
| bfa1b104cf | |||
| 324a1d1a35 | |||
| 95ab88ff6f | |||
| d29840f985 | |||
| 44c0b9c340 | |||
| cdbdf3dc2b | |||
| 8aabb061c2 | |||
| 8bd638569f | |||
| 251dc44a46 | |||
| efd0a1d000 | |||
| 2f93c39fd3 | |||
| f27ce104c6 | |||
| 20a61b692b | |||
| 1966107efe | |||
| d5b2fe6e45 | |||
| 1569819750 | |||
| 4107a30148 | |||
| d06d88b83f | |||
| c411aac939 | |||
| 104715b650 | |||
| 59a9899149 | |||
| 4da7c3b91c | |||
| 012f4e3e16 | |||
| 3636724848 | |||
| eeda5ac312 | |||
| be872b840f | |||
| ee94ae8ba1 |
@@ -152,7 +152,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: 'renovate[bot]' # let Renovate PRs get reviewed
|
||||
|
||||
@@ -3,8 +3,12 @@ name: Claude Code
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
@@ -13,6 +17,14 @@ jobs:
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
) || (
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
) || (
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
|
||||
) || (
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
@@ -33,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
+36
-227
@@ -14,242 +14,51 @@ experimental line:
|
||||
|
||||
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.7.4]
|
||||
|
||||
A feature-bearing patch for the 1.7 line, rolling up work that had stabilised
|
||||
on `main`. No schema migrations (head stays 066) and no new configuration knobs.
|
||||
|
||||
### Added
|
||||
|
||||
- **One provider transport: every model call now streams (#831).**
|
||||
The per-adapter non-streaming entry (`create_completion`) is retired;
|
||||
single-shot lanes — judges, titles, compaction, web-fetch extraction,
|
||||
perception, eval, optimizer — sample through the same streaming entry
|
||||
the chat loop uses and accumulate via one shared drain, so request
|
||||
shaping can no longer drift between the two consumption styles. Two
|
||||
operator-visible consequences: long single-shot generations (a thinking
|
||||
model composing a title, a slow local judge) no longer sit in a single
|
||||
blocking read that can hit client read-timeouts — the same reason the
|
||||
Anthropic adapter already streamed internally — and judge timeouts now
|
||||
*abort* the underlying HTTP read instead of abandoning a worker thread
|
||||
on a dead call. Because every call now streams, an alias pointed at a
|
||||
model or org that cannot stream (OpenAI's verified-org streaming
|
||||
entitlement, a gateway api-version predating `stream_options` — e.g.
|
||||
older Azure OpenAI deployments) fails at request time where 1.7's
|
||||
non-streaming single-shot call succeeded; remediation is on the
|
||||
serving side (verify the org, bump the api-version/gateway) — there is
|
||||
deliberately no per-model non-streaming fallback left to configure. These lanes are also complete-or-error now: a stream
|
||||
that ends without any finish signal is treated as a generation that
|
||||
died mid-response and retried, instead of storing the partial text as
|
||||
a clean result (previously a half-generated compaction summary could
|
||||
silently replace real history). Caveats: these lanes now carry the
|
||||
same `stream_options: {include_usage: true}` the chat loop always
|
||||
sent — OpenAI-compatible servers old enough to *ignore* it stop
|
||||
producing usage rows on these lanes, and servers strict enough to
|
||||
*reject* unknown fields (pre-2024 llama.cpp/proxy builds) will 400 —
|
||||
such a server already couldn't serve turnstone's chat loop, but a
|
||||
judge/utility alias pointed at one worked on 1.7 and needs to move to
|
||||
a current server. Transient mid-stream deaths (connection drop, proxy
|
||||
hiccup) are re-issued in place up to twice with exponential backoff —
|
||||
the retry the SDK's request loop used to provide these lanes
|
||||
invisibly. Each lane accepts its own terminal marker (Anthropic
|
||||
`message_stop`, Responses terminal events); a lax server/gateway that
|
||||
never sends any terminal signal needs
|
||||
`{"finish_reason_optional": true}` in the model definition's
|
||||
capabilities JSON, which restores 1.7's tolerance (clean end-of-stream
|
||||
after output = completion) for that model on every lane — without it
|
||||
such streams fail as died-mid-generation, because SSE gives no way to
|
||||
tell the two apart and the default favors catching truncation. The
|
||||
unread `supports_streaming` capability flag (and its admin tile) is
|
||||
gone; the o-series models it described are dropped from the capability
|
||||
table entirely (see Removed).
|
||||
|
||||
- **One turn interface for every model call: `core/model_turn.py` (#827).**
|
||||
Judges (intent + output guard), perception, title generation, compaction,
|
||||
web-fetch extraction, the eval harness, the optimizer's meta lanes, and
|
||||
task agents all advance a trajectory through the same plant-call
|
||||
primitive the agent seam pioneered — Turn IR in, one shared lowering
|
||||
(argument sanitize → minted-id restore → vLLM reasoning attach), one
|
||||
shared re-ingest (blank-id repair → native-lane finalize). The judges'
|
||||
hand-built OpenAI-dict path is gone, and with it the Gemini judge's
|
||||
tool-blindness: evidence tools now work on Google models because the
|
||||
native lane round-trips `thought_signature` (with pairwise repair for
|
||||
blank-id compat responses). Provider adapters still take lowered wire
|
||||
dicts — the transport collapse and main-loop migration are tracked as
|
||||
#831 / #832.
|
||||
|
||||
- **task_agent keeps its model's reasoning across its own tool loop — on
|
||||
every provider lane.** A task agent's replayed turns now carry the
|
||||
provider-native reasoning lane the model produced — Anthropic thinking
|
||||
blocks with their signatures (commercial or an anthropic-compatible
|
||||
server), OpenAI Responses reasoning items, Gemini `thought_signature`
|
||||
fidelity blocks, and the reasoning text a vLLM `--reasoning-parser` /
|
||||
llama.cpp `reasoning_format` surfaces on the Chat Completions lane —
|
||||
instead of each turn being rebuilt from text + tool calls with the
|
||||
reasoning dropped. On a thinking model this restores reasoning continuity
|
||||
across the agent's own multi-turn tool use. On the wire the agent's
|
||||
session-minted sub-tool ids are mapped back to the provider's own ids
|
||||
(`restore_provider_tool_ids`), so the native block — replayed verbatim,
|
||||
its signature never touched — the `tool_calls` mirror, and each tool
|
||||
result always agree; internally the minted ids still key the live card,
|
||||
recall, and the cancel ledger unchanged. Replay honors the same per-model
|
||||
`replay_reasoning_to_model` flag the main loop uses on every lane: the
|
||||
vLLM Chat-Completions field replay keeps its server-type gate, and
|
||||
llama.cpp stays capture-only, matching main-loop behavior. The native
|
||||
lane is finalized by the same shared builder as the main loop's, so the
|
||||
two harnesses cannot drift.
|
||||
|
||||
- **Background shells: `bash` gains `run_in_background`, plus `bash_output` /
|
||||
`kill_shell`.** Setting `run_in_background=true` starts the command as a
|
||||
detached shell and returns immediately with a `bash_N` handle — "start a dev
|
||||
server, use it in a later call" is back as an explicit opt-in (the shape
|
||||
follows the convention the major coding agents converged on). `bash_output`
|
||||
returns only output produced since the previous read (optionally filtered by
|
||||
a regex) plus status and exit code; `kill_shell` terminates the shell's
|
||||
whole process group. Output is buffered per shell with a drop-oldest cap, so
|
||||
a chatty server can't grow memory unbounded. When a background shell exits,
|
||||
a system notice lands at the next seam (waking an idle workstream if
|
||||
needed). Shells survive a generation cancel, die with the workstream, and
|
||||
never outlive a task_agent that started them; anything a background shell
|
||||
itself backgrounds is still reaped when that shell exits — the no-leak
|
||||
guarantee below is unchanged.
|
||||
- **Background shells for the `bash` tool** — `run_in_background=true` starts a
|
||||
command as a detached shell and returns a `bash_N` handle; new `bash_output`
|
||||
(delta output since last read, optional regex filter, status/exit code) and
|
||||
`kill_shell` (terminates the shell's process group) tools manage it. Output is
|
||||
buffered with a drop-oldest cap, a system notice lands when a shell exits, and
|
||||
shells die with their workstream — never outliving a `task_agent` that started
|
||||
them.
|
||||
- **`task_agent` carries the model's native reasoning across its own tool loop** —
|
||||
a task agent's replayed turns now preserve the provider-native reasoning lane
|
||||
(Anthropic thinking blocks with signatures, OpenAI reasoning items, Gemini
|
||||
`thought_signature`, vLLM/llama.cpp reasoning text) instead of rebuilding each
|
||||
turn from text alone, restoring reasoning continuity for thinking models.
|
||||
- **Model-shelf response controls** — the console model shelf exposes verbosity
|
||||
and reasoning-mode controls per identity.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sampling knobs (temperature, reasoning effort) now ride one assignment
|
||||
scheme: per-model alias value → operator-stored global setting → the
|
||||
model definition's declared default (effort only) → field omitted.**
|
||||
Turnstone previously manufactured values onto every unconfigured
|
||||
request — a hidden `temperature: 0.5` and a `reasoning_effort: "medium"`
|
||||
baked in at three layers — overriding serving-side defaults like a vLLM
|
||||
model's `generation_config`. Unconfigured installs now send neither
|
||||
field and the inference engine's own defaults rule; `model.temperature`
|
||||
is blank by default ("inherit each model's own default") and
|
||||
`model.reasoning_effort` defaults to the empty "inherit" choice. The
|
||||
per-model → global resolution lives in one shared resolver used by the
|
||||
session factories, the `/model` switch, and every `model_turn` lane, so
|
||||
the same alias samples identically on every surface. CLI
|
||||
`--temperature` / `--reasoning-effort` likewise default to inherit.
|
||||
|
||||
**Upgrade notes:**
|
||||
- The empty (`""`) reasoning-effort choice changed meaning from
|
||||
"explicitly disable thinking" to "inherit the model/serving default".
|
||||
On local manual-thinking models (e.g. Qwen templates with
|
||||
`enable_thinking`), a stored `""` previously sent
|
||||
`enable_thinking: false`; it now sends nothing, so the template's own
|
||||
default (often thinking ON) applies. Use **`none`** to actually
|
||||
disable reasoning.
|
||||
- Workstreams saved by earlier versions carry the old defaults
|
||||
(`temperature=0.5`, `reasoning_effort=medium`) in their persisted
|
||||
config and keep that exact behavior on resume; they pick up the new
|
||||
inherit semantics the next time you change the model or a sampling
|
||||
knob in that workstream. New workstreams inherit from the start.
|
||||
|
||||
### Removed
|
||||
|
||||
- **O-series and pre-5.4 GPT-5 rows dropped from the OpenAI capability
|
||||
table.** `o1`, `o1-mini`, `o3`, `o3-mini`, `o3-pro`, `o4-mini`,
|
||||
`gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`, `gpt-5.1`,
|
||||
`gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.2-pro`, and `gpt-5.3` no longer
|
||||
have built-in capability rows — OpenAI has retired these model ids
|
||||
from the API, so the rows described contracts no request can reach
|
||||
anymore. The table floor is now `gpt-5.4`; the search-api and
|
||||
audio/STT/TTS rows are unchanged. An alias still pinning a retired id
|
||||
fails at OpenAI itself; any other unlisted commercial id resolves to
|
||||
the generic commercial defaults (temperature sent, no declared
|
||||
reasoning-effort vocabulary, 200K window) — declare the contract on
|
||||
the model definition's capabilities JSON if you run one, or move to a
|
||||
current model.
|
||||
- **GPT-5.6 aligned with the GA API surface** — the Responses provider matches
|
||||
GPT-5.6's GA shape (typed `reasoning.mode`, `prompt_cache_options`,
|
||||
cache-write accounting); the `openai` floor moves to `>=2.45`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Static MCP servers: a pushed catalog change no longer wedges the shared
|
||||
session (#839).** The static-path `*/list_changed` handler awaited its
|
||||
catalog refresh inline in the SDK's receive loop, but the refresh's own
|
||||
request can only be answered by that (now parked) loop — the refresh never
|
||||
completed, and every user's in-flight calls on the shared per-node session
|
||||
stalled behind it, unbounded, until the health loop's ping timeout tore the
|
||||
transport down (which was also the only way the changed catalog ever
|
||||
landed). Push refreshes now run as spawned tasks — debounced, coalesced per
|
||||
(server, kind), bounded by the connect timeout, and serialized on the
|
||||
per-server connect lock — and the manual and post-reconnect refreshes
|
||||
publish under that same lock, so a slower publisher can no longer land a
|
||||
staler catalog over a fresher one. Every teardown path now also clears the
|
||||
notification debounce stamp, so a reconnected server's first push refreshes
|
||||
immediately. Push-refresh debouncing is now per (server, kind) on BOTH the
|
||||
static and per-user pool paths — a tools push no longer swallows a prompts
|
||||
push arriving in the same 5-second window. A change genuinely lost to the
|
||||
debounce window (a same-kind push landing after the prior refresh finished,
|
||||
which the server will never re-announce) is recovered by an automatic
|
||||
health-tick retry rather than staying invisible until an unrelated push or
|
||||
a reconnect. The resource-refresh fan-out on both paths no longer orphans
|
||||
its sibling list call when one of the pair fails fast — the real error
|
||||
surfaces immediately (not masked as a 30-second timeout) and the surviving
|
||||
sibling is cancelled and reaped, under a bounded grace, inside the scope. A
|
||||
push refresh that fails while the connection stays up is likewise retried on
|
||||
the next health-loop tick until one completes — previously a single
|
||||
transient blip left the shared catalog stale for every user on the node
|
||||
until an operator intervened. An operator `/mcp refresh` no longer parks
|
||||
behind a busy per-server connect lock (a slow reconnect attempt could eat
|
||||
the whole 30-second refresh budget and fail the pass for every healthy
|
||||
server behind it) — the busy server is skipped on both the connected and
|
||||
disconnected branches, reported distinctly as "skipped" rather than as a
|
||||
false "no changes", the skip arms the automatic retry, and a
|
||||
force-reconnect drops the session up front so queued push refreshes can't
|
||||
starve it. Static-path resource and prompt catalogs are now size-capped
|
||||
like the pool path's (and like static tools) at discovery and on every
|
||||
refresh, so a misbehaving server's push can't balloon the node's merged
|
||||
catalogs. Deleting or reconfiguring a server can no longer leave it
|
||||
half-removed: the config removal and all cleanup are serialized under the
|
||||
connect lock (a cancelled removal completes its cleanup rather than
|
||||
stranding a live session and published catalog with the config already
|
||||
gone), and `reconcile_sync` retries a removal that timed out instead of
|
||||
marking it done — previously a DB-driven delete of a busy server could be a
|
||||
silent, permanent no-op until process restart. A refresh outcome now
|
||||
threads consistently to every operator surface off one source of truth
|
||||
(the per-server `last_refresh_outcome`): a busy-skip and a genuine failure
|
||||
are each reported distinctly from a real "no changes" — `/mcp refresh`
|
||||
prints "skipped" or "failed" rather than a false "no changes", and the
|
||||
node-internal refresh endpoint returns `202 skipped` instead of a
|
||||
misleading `200 ok` for a refresh that never ran. A single-kind push
|
||||
refresh no longer paints the whole server healthy: because the
|
||||
error/outcome state is server-scoped, a successful tools push while the
|
||||
prompts catalog is still broken (or vice versa) no longer clears the
|
||||
failure — only a full refresh pass declares "ok".
|
||||
|
||||
- **OpenAI Responses streaming: truncated and refused responses no longer
|
||||
vanish.** A response that hit `max_output_tokens` terminates the stream
|
||||
with `response.incomplete`, which the stream consumer did not handle —
|
||||
the turn was mislabeled `finish_reason: stop` and its final usage and
|
||||
collected output items were dropped. Refusal parts had no streaming
|
||||
handler at all, so a refusal rendered as empty content instead of the
|
||||
`[Refused: …]` text the non-streaming path produced. Both now match:
|
||||
truncation maps to `length` with usage/items intact, refusals render
|
||||
in content. Applies to the chat loop and every drained single-shot
|
||||
lane (#831).
|
||||
|
||||
- **task_agent: sub-tool ids no longer alias across a local model's reused
|
||||
ids.** A local model that reissues per-response sequential tool-call ids
|
||||
(`call_0` every turn) made two of a task agent's steps share one id — the
|
||||
live card collapsed both onto one DOM row while `/history` recall kept them
|
||||
apart, so the two views disagreed. Sub-tool ids are now minted
|
||||
`{parent}::r{run}s{step}::{id}`, unique within the session (across an
|
||||
agent's turns and across concurrent or sequential runs), and that one id
|
||||
keys the nesting registry, the live rows, recall, and the cancel ledger.
|
||||
On the wire the agent's self-built history carries the provider's own ids,
|
||||
restored from the mint map (see the reasoning-lane entry under Added), and
|
||||
malformed tool-call arguments are legalized the same way the main loop's
|
||||
wire prep does.
|
||||
|
||||
- **bash tool: never hang on a backgrounded child.** A command that left a
|
||||
long-lived process running (`server &`, a daemon) could wedge the whole
|
||||
workstream forever — the tool read stdout/stderr to EOF, which never arrived
|
||||
because the child inherited the pipe, and the timeout watchdog bailed once the
|
||||
foreground `bash` had exited. The tool now waits on the tracked process
|
||||
(bounded by the tool timeout) and terminates its whole process group on
|
||||
return, so the call always completes. Undecodable output is preserved
|
||||
(`errors="replace"`) instead of being dropped as a spurious error.
|
||||
- **Behavior change:** a process the command backgrounds no longer survives
|
||||
the call — nothing persists across bash invocations. (First-class
|
||||
"run this in the background" support landed separately — see
|
||||
`run_in_background` under Added.)
|
||||
- **`bash` never hangs on a backgrounded child** — a command that left a
|
||||
long-lived process running no longer wedges the workstream; the tool waits on
|
||||
the tracked process (bounded by the timeout) and reaps its whole process group.
|
||||
- **`task_agent` sub-tool ids are session-unique** — ids are minted
|
||||
`{parent}::r{run}s{step}::{id}` so a local model reissuing sequential ids
|
||||
(`call_0` each turn) no longer aliases two steps onto one live-card row while
|
||||
`/history` keeps them apart.
|
||||
- **Judge completions honour model-definition capabilities** — a judge's
|
||||
completion now threads its model's declared capabilities instead of assuming a
|
||||
default surface.
|
||||
- **`create-admin` CLI** — adds an explicit admin-creation command; `run.sh` no
|
||||
longer onboards into a role-less user.
|
||||
- **Install script Docker handling** — installs Docker on distros
|
||||
`get.docker.com` rejects, and gates that path by `$ID` instead of trapping all
|
||||
failures.
|
||||
|
||||
## [1.7.3]
|
||||
|
||||
|
||||
+17
-26
@@ -609,7 +609,8 @@ LLMProvider (protocol)
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `create_streaming()` | The one transport: streaming request, yields normalized `StreamChunk` objects (single-shot callers accumulate via `drain_stream()` into a `CompletionResult`) |
|
||||
| `create_streaming()` | Streaming request, yields normalized `StreamChunk` objects |
|
||||
| `create_completion()` | Non-streaming request, returns `CompletionResult` |
|
||||
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
|
||||
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
|
||||
| `retryable_error_names` | Exception class names that trigger retry |
|
||||
@@ -662,7 +663,7 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
|
||||
cacheable block and advances it as conversations grow (90% input cost
|
||||
reduction on cache hits, 1.25x write on first turn). Cache metrics
|
||||
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
|
||||
the stream's usage events. The `anthropic` SDK is a core
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is a core
|
||||
dependency — the Anthropic provider is first-class alongside OpenAI.
|
||||
|
||||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||||
@@ -1149,30 +1150,20 @@ Named (aliased) workstreams are never age-pruned. Configure with
|
||||
|
||||
### API Retry
|
||||
|
||||
Every model call streams (#831); retry lives at two stacked layers:
|
||||
`ChatSession._create_stream_with_retry()` (streaming path) and the agent
|
||||
`_api_call()` (non-streaming) both use the same retry pattern:
|
||||
|
||||
- **Caller ladders** — `ChatSession._create_stream_with_retry()` (chat
|
||||
loop) and the agent `_api_call()` (drained via `model_turn`) use the
|
||||
same pattern: 4 total attempts (1 initial + 3 retries,
|
||||
`_MAX_RETRIES = 3`), exponential backoff base 1 second
|
||||
(`delay = 1s * 2^attempt`), `ui.on_info()` on retry, exception
|
||||
propagates on final failure. `_compact_messages()` wraps its drained
|
||||
call in the same loop.
|
||||
- **`model_turn`'s drain ladder** — inside every single-shot call,
|
||||
mid-stream deaths (errors raised while draining, e.g.
|
||||
`IncompleteStreamError`) are re-issued up to 2 more times with a
|
||||
0.5s-base exponential backoff (±50% jitter); request-time failures
|
||||
keep the SDK's own retry policy. The two ladders stack
|
||||
multiplicatively on transient-shaped failures.
|
||||
- **Retryable errors** are matched by class name against each
|
||||
provider's `retryable_error_names` (avoids importing
|
||||
backend-specific exception hierarchies): `RateLimitError`,
|
||||
`APITimeoutError`, `APIConnectionError`, `InternalServerError`,
|
||||
`ServiceUnavailableError`, `APIError`, plus the drained-transport
|
||||
errors `IncompleteStreamError` (stream ended with no terminal
|
||||
signal — for servers that never send one, declare
|
||||
`finish_reason_optional` in the model's capabilities JSON) and
|
||||
`ResponsesStreamFailedError` (transient in-band Responses failure).
|
||||
- **Retries**: 4 total attempts (1 initial + 3 retries, `_MAX_RETRIES = 3`)
|
||||
- **Backoff**: exponential, base 1 second (`delay = 1s * 2^attempt`)
|
||||
- **Retryable errors**: `RateLimitError`, `APITimeoutError`,
|
||||
`APIConnectionError`, `InternalServerError`, `ServiceUnavailableError`,
|
||||
`APIError` (matched by class name to avoid importing backend-specific
|
||||
exception hierarchies)
|
||||
- On retry: `ui.on_info()` notification
|
||||
- On final failure: exception propagates
|
||||
|
||||
`_compact_messages()` also wraps its non-streaming API call in the same
|
||||
retry loop.
|
||||
|
||||
### Finish Reason Handling
|
||||
|
||||
@@ -1185,7 +1176,7 @@ Every model call streams (#831); retry lives at two stacked layers:
|
||||
blocked.
|
||||
|
||||
Agent sub-sessions (`_run_agent()`) check `finish_reason` on each
|
||||
drained turn and stop the agent early on `"length"` or
|
||||
non-streaming response and stop the agent early on `"length"` or
|
||||
`"content_filter"`.
|
||||
|
||||
`_compact_messages()` checks `finish_reason` on the compaction response and
|
||||
|
||||
@@ -66,7 +66,8 @@ class "NullUI" as NullUI {
|
||||
interface "LLMProvider" as LLMProvider <<Protocol>> {
|
||||
+ provider_name: str {property}
|
||||
+ get_capabilities(model) → ModelCapabilities
|
||||
+ create_streaming(client, model, messages, ..., cancel_ref, replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_streaming(client, model, messages, ..., replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ..., replay_reasoning_to_model) → CompletionResult
|
||||
+ convert_tools(tools) → list[dict]
|
||||
+ extract_reasoning_text(provider_blocks) → str
|
||||
+ retryable_error_names: frozenset[str] {property}
|
||||
@@ -176,7 +177,7 @@ class "HeadlessSession" as HeadlessSession {
|
||||
+ send_headless(input, max_turns, ...)
|
||||
- _override_system_prompt(content)
|
||||
--
|
||||
eval.py: drained single-shot turns,
|
||||
eval.py: non-streaming,
|
||||
records all tool calls
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ end note
|
||||
|
||||
loop up to 3 turns (timeout budget)
|
||||
|
||||
Judge -> LLM : model_turn(lane, judge_turns,\ntools=[read_file, list_directory])\nvia drained create_streaming
|
||||
LLM --> Judge : ModelTurnResult
|
||||
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
|
||||
LLM --> Judge : CompletionResult
|
||||
|
||||
alt tool_calls present (turn < 3)
|
||||
Judge -> Judge : _exec_read_only_tool()
|
||||
|
||||
+4
-64
@@ -17,9 +17,8 @@ The MCP server admin form exposes three authorization modes ("Multitenant Author
|
||||
| `none` | No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
|
||||
| `static` | One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
|
||||
| `oauth_user` *(recommended for user-data servers)* | Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
|
||||
| `oauth_obo` *(sign-in passthrough)* | Each user's Turnstone **org sign-in** (OIDC) mints a per-server access token on demand — no separate per-server consent. One captured credential per user covers every `oauth_obo` server. | Enterprise deployments where the identity provider governs access (Entra, Keycloak) and you want zero per-user connect clicks. See the dedicated section below. |
|
||||
|
||||
Switching `auth_type` away from `oauth_user` / `oauth_obo` **deletes** that server's per-user rows (consents / minted cache) — see the transition table below. Switching back later starts clean: users re-consent (or re-mint) on next use. The admin **bulk-revoke** / **flush cache** affordance clears rows without an auth-type change.
|
||||
Switching `auth_type` away from `oauth_user` orphans existing per-user tokens. Use the admin **bulk-revoke** affordance on the server row (Phase 9) to clear them, or let them expire naturally — they're inert without the matching `auth_type` value.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,58 +65,6 @@ Keep this in `config.toml` rather than environment variables. An in-process LLM
|
||||
|
||||
---
|
||||
|
||||
## `auth_type=oauth_obo` — single-credential sign-in passthrough
|
||||
|
||||
Where `oauth_user` makes each user complete a **separate** browser consent per MCP server, `oauth_obo` reuses the user's Turnstone **org sign-in** (OIDC). Turnstone captures one refresh credential per user at login and, on each tool call, mints a short-lived access token scoped to that server's audience. There is no per-server connect step, and one credential covers every `oauth_obo` server. This is the right shape when your identity provider already governs who may reach each backend (an Entra tenant with Entra-protected MCP servers; a Keycloak realm with token exchange).
|
||||
|
||||
Access is governed **downstream** by the IdP: a user can only mint a token for a server their delegated permissions allow. Removing that grant at the IdP cuts the user off regardless of their Turnstone state.
|
||||
|
||||
### Deployment configuration (`[oidc]` in `config.toml`)
|
||||
|
||||
`oauth_obo` requires OIDC SSO to be configured (it is the credential source), plus:
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
# ... your existing issuer / client_id / client_secret ...
|
||||
capture_user_credential = true # persist the IdP refresh token at login
|
||||
obo_grant_profile = "entra" # "entra" | "rfc8693" — how tokens are minted
|
||||
```
|
||||
|
||||
- **`capture_user_credential`** (default `false`): when enabled, Turnstone appends `offline_access` to the login scopes and stores the returned refresh token, encrypted with the same `[security] mcp_token_encryption_key` as `oauth_user` tokens. **The encryption key is required** — Turnstone refuses to start with an `oauth_obo` row (or capture enabled) and no key.
|
||||
- **`obo_grant_profile`** picks the mint mechanism (the IdP determines which one is valid; this is deployment-wide, not per-server):
|
||||
- **`entra`** — redeems the user's refresh token directly for a token scoped to `<audience>/.default`. `oauth_scopes` on the server row is **not used** (the admin form rejects it under this profile).
|
||||
- **`rfc8693`** — a refresh grant for a subject token, then an RFC 8693 token exchange for the server audience. Per-server `oauth_scopes` **are** sent on the exchange (some IdPs require the audience scope explicitly).
|
||||
|
||||
### Adding an `oauth_obo` server
|
||||
|
||||
In the admin MCP form, choose **Sign-in passthrough** and set **Audience** (required — the downstream resource the token is minted for, e.g. `api://<app-id>` on Entra or the client id on Keycloak). The client-id / secret / registration fields do not apply and are hidden.
|
||||
|
||||
`oauth_obo` servers are accepted only when **OIDC sign-in is configured and enabled** and `[oidc] obo_grant_profile` is a valid profile — the write is rejected otherwise, since a row that can never mint would surface to users as a permanent "please retry" that never heals.
|
||||
|
||||
### Identity-provider setup
|
||||
|
||||
**Entra (`obo_grant_profile = "entra"`):**
|
||||
1. Turnstone's app registration must hold **delegated permissions** to each MCP server's exposed API, with **admin consent granted** (or the MCP app listed in Turnstone's `preAuthorizedApplications`).
|
||||
2. Set the server row's Audience to the MCP app's Application ID URI (`api://<guid>`).
|
||||
3. **Gotcha (verified):** admin-consent issued *immediately* after creating the app/service principal can silently skip a not-yet-propagated resource — the only symptom is `AADSTS65001` at mint time. Verify the delegated grant landed (`az ad app permission list-grants` / the portal's *API permissions* blade shows *Granted*), or grant it explicitly per resource. A missing grant surfaces in Turnstone as a re-login prompt on the affected server (same rail as a revoked credential), and the `mcp_server.oauth.obo_mint_rejected` log line carries the raw `AADSTS…` text.
|
||||
|
||||
**Keycloak / RFC 8693 (`obo_grant_profile = "rfc8693"`):**
|
||||
1. Enable **standard token exchange** on Turnstone's client.
|
||||
2. Grant the audience: add an audience client scope for each MCP client and attach it to Turnstone's client (optional scopes must be requested — set the server row's Scopes to that scope, or the exchange returns *"Requested audience not available"*).
|
||||
3. Set the server row's Audience to the downstream client id.
|
||||
|
||||
### Revocation & custody
|
||||
|
||||
The captured credential is a single per-user secret that can mint for every `oauth_obo` server, so treat it like any long-lived credential:
|
||||
|
||||
- **Cut off one user:** unlink their OIDC identity in the admin console (**Users → OIDC identities → delete**). This revokes the captured credential **and** purges their minted cache rows, so future mints fail and cached tokens are dropped. (Warmed in-memory sessions on server nodes self-expire at the access-token TTL; there is no cross-node per-user session-kill.) Removing the user's access at the IdP is the authoritative cut-off.
|
||||
- **Flush a server's minted tokens** (e.g. after narrowing its audience): the server row's **flush cache** action drops all users' cached tokens for that server. This is **not** a revocation — users re-mint on next use from their still-valid sign-in. It is surfaced honestly (audit `mcp_server.oauth.obo_cache_flushed`, response `effect: cache_flush_remints`) so it is never mistaken for cutting access.
|
||||
- Per-server revocation in the `oauth_user` sense does not exist for `oauth_obo` — the credential is issuer-scoped and IdP-governed. Revoke at the IdP.
|
||||
|
||||
> **Interim for Entra without OBO:** if you don't want host-side minting, admin consent + `preAuthorizedApplications` on each MCP app registration removes the second consent prompt for the plain `oauth_user` flow too (a tenant-config change, no Turnstone code). Tracked in issue #682. It does not remove the per-server connect clicks or per-(user, server) token custody — that is what `oauth_obo` is for.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **First tool call** for a user against an `oauth_user` MCP server: pool dispatch finds no stored token, returns `mcp_consent_required` to the agent. Dashboard renders an inline "Connect" action card.
|
||||
@@ -128,7 +75,7 @@ The captured credential is a single per-user secret that can mint for every `oau
|
||||
|
||||
4. **Step-up scope**: when a tool call hits `403` with `WWW-Authenticate: error="insufficient_scope"`, Turnstone emits `mcp_insufficient_scope` with the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens `/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>` so the union of original + new scopes flows into the AS authorize request.
|
||||
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks). `oauth_obo` servers are excluded: their rows are mint cache, not consents — deleting one only forces a re-mint — so the connections list hides them and the endpoint refuses them with `409` (revocation for sign-in passthrough happens at the identity layer: unlink the identity or revoke at the IdP).
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).
|
||||
|
||||
6. **Admin bulk-revoke** (Phase 9): `POST /v1/api/admin/mcp-servers/{name}/bulk-revoke` drops every user's token for the server. Upstream RFC 7009 revoke is intentionally **not** attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
|
||||
|
||||
@@ -150,13 +97,10 @@ Additional indicators (circuit-breaker state, encryption-key mismatch) are expos
|
||||
| From | To | What happens |
|
||||
|---|---|---|
|
||||
| `none` / `static` → `oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **deleted**: the tokens are bound to the auth model + URL active at consent time, and rows left behind could silently rebind if a row with the old name/URL reappears. Switching back to `oauth_user` later starts clean — users re-consent on next use. This is **not reversible**; the AS-side grants are untouched (revoke upstream via the AS if needed). |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **orphaned** — inert without a matching `auth_type`. Use admin bulk-revoke to drop them, or let them expire. Switching back to `oauth_user` later re-activates the orphaned rows if they haven't been deleted. |
|
||||
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
|
||||
| `oauth_user` ↔ `oauth_obo` | — | The per-user rows are **deleted** on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). `oauth_audience` and `oauth_scopes` mean different things in each model (a resource indicator vs. an IdP app identifier; AS-consent scopes vs. an rfc8693 exchange scope), so on a flip they **never carry** — each is taken from the request for the target model or set NULL. The admin console clears these fields when you change the auth type, so re-enter the correct values for the new mode; via the API, supply them explicitly (a flip into `oauth_obo` with no `oauth_audience` is rejected, and a non-empty `oauth_scopes` under the `entra` profile is rejected since that leg pins `<audience>/.default`). |
|
||||
| `oauth_obo` → `none` / `static` | — | Minted cache rows are deleted. |
|
||||
| `oauth_obo` **audience**, **URL**, or **`oauth_scopes`** changed | — | Minted cache rows are **deleted** (tokens are bound to the audience/URL/scopes at mint time), forcing a fresh mint — so an audience or scope narrowing takes effect immediately, not at token expiry. |
|
||||
|
||||
Every transition that changes what a stored row *means* deletes the rows outright — a stale consent or minted token must never be served under new semantics. There is no orphan-and-reactivate path.
|
||||
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,9 +113,5 @@ Every transition that changes what a stored row *means* deletes the rows outrigh
|
||||
| `mcp_oauth_url_insecure` | MCP server URL is `http://` (not `https://`) on a non-loopback host | Use `https://`. Per-user bearers must not transit cleartext. |
|
||||
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
|
||||
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
|
||||
| **`oauth_obo`**: every tool call fails, log shows `obo_misconfigured` | Server row has no Audience, or `obo_grant_profile` is unset/unknown | Set the Audience on the server row; set `[oidc] obo_grant_profile` to `entra` or `rfc8693`. |
|
||||
| **`oauth_obo`**: `obo_mint_rejected` with `AADSTS65001` | Turnstone's app lacks the (admin-consented) delegated grant to this MCP app — often admin consent that didn't propagate | Grant + admin-consent the delegated permission for this resource; verify it shows *Granted*. See the Entra gotcha above. |
|
||||
| **`oauth_obo`**: "Sign in to Turnstone again" on one server | Captured credential missing/rejected, or a Conditional Access challenge | User re-logs into Turnstone (re-captures the credential). If it persists, check the IdP grant / CA policy. |
|
||||
| **`oauth_obo`**: tools don't appear at all for a user | User has not signed in since `capture_user_credential` was enabled (no credential captured) | User logs out and back in via OIDC so the refresh credential is captured. |
|
||||
|
||||
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
|
||||
|
||||
+9
-9
@@ -77,17 +77,17 @@ IdP from redirecting the token-exchange POST (which carries
|
||||
being aimed at internal services.
|
||||
|
||||
A few public IdPs legitimately split endpoints across hostnames. Google
|
||||
and Microsoft Entra ID are the canonical examples:
|
||||
is the canonical example:
|
||||
|
||||
| IdP | Issuer host | Cross-host endpoint(s) |
|
||||
|-----|-------------|------------------------|
|
||||
| Google | `accounts.google.com` | `oauth2.googleapis.com`, `www.googleapis.com`, `openidconnect.googleapis.com` |
|
||||
| Microsoft Entra | `login.microsoftonline.com` | `graph.microsoft.com` (userinfo) |
|
||||
| Field | Hostname |
|
||||
|-------|----------|
|
||||
| issuer | `accounts.google.com` |
|
||||
| token_endpoint | `oauth2.googleapis.com` |
|
||||
| jwks_uri | `www.googleapis.com` |
|
||||
| userinfo_endpoint | `openidconnect.googleapis.com` |
|
||||
|
||||
Both sets are built in — operators using `https://accounts.google.com` or
|
||||
`https://login.microsoftonline.com/<tenant>/v2.0` need no extra
|
||||
configuration. (Entra's discovery document advertises `userinfo_endpoint`
|
||||
on `graph.microsoft.com`, distinct from the issuer host.)
|
||||
Google's set is built in — operators using `https://accounts.google.com`
|
||||
need no extra configuration.
|
||||
|
||||
For other IdPs whose discovery document references a non-issuer host,
|
||||
extend the allow-list explicitly:
|
||||
|
||||
+6
-16
@@ -779,10 +779,7 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server (debounced per server and
|
||||
notification kind, and run off the receive loop). A refresh that fails while
|
||||
the connection stays up is retried automatically on the next health-loop tick
|
||||
until one completes.
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
@@ -790,10 +787,6 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
same controls (refresh / reconnect buttons per server) for cluster-wide
|
||||
fan-out.
|
||||
|
||||
Reconnects (health-loop, dispatch-driven, or operator-forced) always end in a
|
||||
full catalog rediscovery, so a server that changed its tools while disconnected
|
||||
comes back current.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
@@ -864,16 +857,13 @@ catalog.
|
||||
|
||||
### Refresh
|
||||
|
||||
Resource lists stay current through the same mechanisms as tool lists:
|
||||
Resource lists stay current through the same three-tier mechanism as tool lists:
|
||||
|
||||
1. **Push** -- Servers declaring `resources.listChanged: true` send
|
||||
`notifications/resources/list_changed`, triggering an immediate refresh
|
||||
(with the same failed-refresh retry on the health-loop tick).
|
||||
2. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
|
||||
|
||||
Servers without push support are refreshed whenever they reconnect (every
|
||||
reconnect ends in full rediscovery) or when an operator refreshes manually;
|
||||
there is no periodic polling.
|
||||
`notifications/resources/list_changed`, triggering an immediate refresh.
|
||||
2. **Periodic** -- Servers without push are polled on the configured refresh
|
||||
interval (default 4 hours, same timer as tools).
|
||||
3. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.8.0a2"
|
||||
version = "1.7.4"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+1
-1
@@ -399,7 +399,7 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
known: true,
|
||||
capabilities: {
|
||||
context_window: 200000, supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true, supports_vision: true,
|
||||
supports_web_search: true, supports_temperature: true,
|
||||
supports_effort: true,
|
||||
},
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Entra config for the Entra e2e / spike harnesses. Copy to `.env` (gitignored)
|
||||
# and fill in from your tenant. `entra_setup.sh setup` creates the app
|
||||
# registrations and writes a populated `.env` for you.
|
||||
#
|
||||
# cp scripts/obo-e2e/.env.example scripts/obo-e2e/.env
|
||||
# # then edit, or run: ./scripts/obo-e2e/entra_setup.sh setup
|
||||
|
||||
export ENTRA_TENANT_ID=<tenant-guid-or-domain>
|
||||
export ENTRA_CLIENT_ID=<turnstone-spike-app-client-id>
|
||||
export ENTRA_CLIENT_SECRET=<client-secret>
|
||||
export SPIKE_AUDIENCE_A=api://<resource-app-a-guid> # a consented resource
|
||||
export SPIKE_AUDIENCE_B=api://<resource-app-b-guid> # a second consented resource
|
||||
export SPIKE_AUDIENCE_UNCONSENTED=api://<resource-app-c-guid> # NOT granted (negative case)
|
||||
export SPIKE_RUN_OBO=1
|
||||
# export SPIKE_PORT=8765 # redirect-listener port (default 8765)
|
||||
# export SPIKE_CALLBACK_FILE=/tmp/obo_cb.txt # remote-browser mode: paste the redirect URL here
|
||||
|
||||
# The Keycloak / OSS-path harness needs no config — keycloak_e2e.sh sets
|
||||
# everything and stands up an ephemeral container.
|
||||
@@ -1,214 +0,0 @@
|
||||
# OBO e2e harnesses — single-credential MCP token minting (`auth_type=oauth_obo`)
|
||||
|
||||
Manual test harnesses for the `oauth_obo` feature (issue #551). They exercise
|
||||
the **real** Turnstone mint path (`get_obo_access_token_classified` →
|
||||
`_obo_mint_entra` / `_obo_mint_rfc8693`) against a real identity provider — not
|
||||
mocks, not the unit suite. Two grant legs:
|
||||
|
||||
- **Entra** (`entra_e2e.py`) — real tenant, one interactive sign-in.
|
||||
- **Keycloak / RFC 8693** (`keycloak_e2e.py` + `.sh`) — ephemeral docker, fully
|
||||
headless.
|
||||
|
||||
There is also `entra_spike.py` (raw-OAuth **wire** probe, pre-implementation
|
||||
reference) and `entra_setup.sh` (creates the Entra app registrations + writes a
|
||||
populated `.env`).
|
||||
|
||||
**Secrets:** these read config from env. Real credentials live in a **gitignored
|
||||
`.env`** (copy `.env.example`); nothing tenant-specific is committed. The only
|
||||
literal secret in the tree is the ephemeral Keycloak container's throwaway
|
||||
`spike-secret`, which lives and dies with the container.
|
||||
|
||||
Not part of CI — run by hand when validating the feature against a live IdP.
|
||||
|
||||
## `entra_e2e.py` — end-to-end product exercise (post-implementation)
|
||||
|
||||
`entra_spike.py` verified the raw OAuth WIRE (before code existed). `entra_e2e.py`
|
||||
verifies the SHIPPED Turnstone code: it does a real Entra login, feeds the
|
||||
credential through the real `MCPTokenStore.upsert_oidc_credential` (the call the
|
||||
OIDC callback makes on capture), then drives the real
|
||||
`get_obo_access_token_classified` → `_obo_mint_entra` against the live Entra token
|
||||
endpoint. Checks E1–E7: real mint + aud claim, cache-hit (0 Entra calls),
|
||||
single-credential→audiences A&B, rotation write-back, force_refresh re-mint,
|
||||
unconsented-audience classification with the credential surviving, and
|
||||
flush→re-mint. Reuses the same `.env` and interactive login (SPIKE_CALLBACK_FILE
|
||||
for remote browser).
|
||||
|
||||
```bash
|
||||
source scripts/obo-e2e/.env
|
||||
uv run python scripts/obo-e2e/entra_e2e.py
|
||||
# one interactive sign-in; E1–E7 then run against the real product code. Results below.
|
||||
```
|
||||
|
||||
Results — RUN 2026-07-12 on the real tenant, ALL VERIFIED (exit 0): capture
|
||||
persisted; E1 mint A (aud=A app-id, cache row refresh_token_ct NULL); E2 cache
|
||||
hit (0 extra Entra calls); E3 mint B from the SAME credential (aud=B app-id); E4
|
||||
rotation write-back (RT rotated 2040→2091 chars, newest persisted); E5
|
||||
force_refresh re-mint (1 Entra call); E6 unconsented C → refresh_failed and the
|
||||
credential SURVIVES; E7 flush→re-mint. The real `get_obo_access_token_classified`
|
||||
→ `_obo_mint_entra` path against the live Entra token endpoint.
|
||||
|
||||
## `keycloak_e2e.py` + `keycloak_e2e.sh` — OSS path (RFC 8693), headless
|
||||
|
||||
The rfc8693 equivalent of `entra_e2e.py`: `keycloak_e2e.sh` spins up ephemeral
|
||||
Keycloak, configures the realm (turnstone client with standard token exchange,
|
||||
mcp-a/b/c clients, aud-mcp-a/b audience scopes, a test user), runs the harness
|
||||
against the real `get_obo_access_token_classified` → `_obo_mint_rfc8693`
|
||||
(refresh grant → token exchange), then tears down. No browser (password grant).
|
||||
|
||||
```bash
|
||||
./scripts/obo-e2e/keycloak_e2e.sh
|
||||
```
|
||||
|
||||
Results — RUN 2026-07-12, ALL VERIFIED: capture persisted; E1 mint A
|
||||
(refresh→exchange, aud=mcp-a, cache row refresh_token_ct NULL); E2 cache hit (0
|
||||
extra KC calls); E3 mint B from the SAME credential (aud=mcp-b); E4 rotation
|
||||
write-back (KC rotated the RT on the refresh leg, newest persisted); E5
|
||||
force_refresh re-mint (**2 KC calls** = the two-leg chain); E6 unconsented C →
|
||||
refresh_failed_transient (KC returns invalid_request for a missing audience
|
||||
scope → classified transient; credential SURVIVES either way); E7 flush→re-mint.
|
||||
Gotcha: dev-mode Keycloak boot is slow on a loaded host — the script now waits on
|
||||
kcadm auth (up to ~6 min) rather than a fixed sleep. Port 8091 (8090 = the dev
|
||||
console).
|
||||
|
||||
## Leg 1 — Entra (`entra_spike.py`) — NEEDS TENANT ACCESS
|
||||
|
||||
### Tenant / app-registration setup (one-time, ~15 min)
|
||||
|
||||
1. **Spike client app** (stands in for Turnstone's OIDC app registration):
|
||||
- New app registration, single tenant. Platform **Web**, redirect URI
|
||||
`http://localhost:8765/callback`. Create a **client secret**.
|
||||
2. **Two resource apps** (stand in for MCP servers A and B):
|
||||
- New app registrations `spike-mcp-a`, `spike-mcp-b`. In each:
|
||||
**Expose an API** → set Application ID URI (`api://<guid>`) → add a scope
|
||||
(e.g. `mcp.access`).
|
||||
3. **Delegated grants** (this is metaclassing's "proper tenant and app reg setup"):
|
||||
- On the spike client app → **API permissions** → add delegated permission to
|
||||
`spike-mcp-a` and `spike-mcp-b` scopes → **Grant admin consent**.
|
||||
- Optionally also add the spike client's app id to each resource app's
|
||||
`preAuthorizedApplications` (Expose an API → Add a client application) to
|
||||
compare against pure admin consent.
|
||||
4. **Unconsented control** (for V5): a third resource app `spike-mcp-c` with an
|
||||
exposed API but NO permission granted to the spike client.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
export ENTRA_TENANT_ID=... ENTRA_CLIENT_ID=... ENTRA_CLIENT_SECRET=...
|
||||
export SPIKE_AUDIENCE_A=api://<a-guid> SPIKE_AUDIENCE_B=api://<b-guid>
|
||||
export SPIKE_AUDIENCE_UNCONSENTED=api://<c-guid> # optional (V5)
|
||||
export SPIKE_RUN_OBO=1 # optional (V6)
|
||||
uv run python scripts/obo-e2e/entra_spike.py
|
||||
```
|
||||
|
||||
A browser opens for one interactive login (any tenant user). Everything after is
|
||||
non-interactive — that IS the feature.
|
||||
|
||||
### What each check pins down
|
||||
|
||||
| Check | Design assumption it verifies |
|
||||
| --- | --- |
|
||||
| V1 | `offline_access` on the login yields a client-bound RT (capture layer) |
|
||||
| V2/V3 | ONE RT redeems for access tokens of DIFFERENT audiences (`scope=<aud>/.default`) — the load-bearing Entra behavior |
|
||||
| V4 | rotation semantics → whether RT write-back on every mint is convenience or correctness-critical |
|
||||
| V5 | unconsented audience fails `AADSTS65001 consent_required` → maps to the reconnect-rail fallback, never a silent failure |
|
||||
| V6 | OBO jwt-bearer middle-tier variant works with the same app registration (comparison data only) |
|
||||
|
||||
Also record (manual): whether Conditional Access / MFA policies in the tenant
|
||||
produce `interaction_required` on redemption — that's the fallback path's other
|
||||
trigger.
|
||||
|
||||
### Results — RUN 2026-07-11 on a real tenant, ALL SIX VERIFIED
|
||||
|
||||
Tenant: personal default directory (Global Admin), user is an MSA member.
|
||||
Setup via `entra_setup.sh setup`; V3 initially failed (see gotcha below),
|
||||
passed after fixing the grant. Second run: V1-V6 all VERIFIED, exit 0.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| V1 offline_access login -> RT | VERIFIED (confidential client + PKCE, RT ~2KB) |
|
||||
| V2 RT -> audience A token | VERIFIED (`aud=<A app guid>`, ~70 min TTL, new RT returned) |
|
||||
| V3 SAME RT -> audience B token | **VERIFIED — the load-bearing claim: one RT, many audiences** |
|
||||
| V4 rotation | VERIFIED: RT rotates on every redemption, but the OLD RT stays valid (reuse HTTP 200) -> write-back-newest is required; races are benign on Entra |
|
||||
| V5 unconsented audience | VERIFIED: `invalid_grant` + `AADSTS65001` (error_codes=[65001]) -> clean mapping to the reconnect-rail fallback |
|
||||
| V6 OBO jwt-bearer variant | VERIFIED: middle-tier shape also works with the same app registration |
|
||||
|
||||
**Operator gotcha (feeds #682 + product docs):** `az ad app permission
|
||||
admin-consent` run immediately after SP creation SILENTLY skips
|
||||
not-yet-propagated resource SPs — grant A landed, grant B didn't, and the only
|
||||
symptom was AADSTS65001 at redemption. Verify grants after consent
|
||||
(`oauth2PermissionGrants` filter on the client SP) or write them directly with
|
||||
`az ad app permission grant --id <client> --api <resource> --scope <scope>`.
|
||||
Product-side implication: a missing tenant grant for a NEW oauth_obo server
|
||||
surfaces as AADSTS65001 -> the same reconnect-rail path as revocation; the
|
||||
admin docs must say "grant first, then add the server".
|
||||
|
||||
## Leg 2 — Keycloak RFC 8693 (portability check) — runnable locally
|
||||
|
||||
Ephemeral `quay.io/keycloak/keycloak:26.3` (`start-dev`, port 8089), realm
|
||||
`spike`, confidential client `turnstone` with **standard token exchange**
|
||||
enabled, resource clients `mcp-a`/`mcp-b`, user `alice`. Pipeline mirrors the
|
||||
product design for a generic-8693 IdP:
|
||||
|
||||
```
|
||||
stored user RT --(refresh grant)--> user AT --(RFC 8693 exchange, audience=mcp-X)--> audience-scoped AT
|
||||
```
|
||||
|
||||
i.e. the per-user credential stays ONE refresh token; per-server tokens are
|
||||
minted via standard token exchange instead of Entra's multi-resource RT
|
||||
redemption. Same substrate, different grant leg.
|
||||
|
||||
### Results — RUN 2026-07-11, VERIFIED (Keycloak 26.3, ephemeral)
|
||||
|
||||
```
|
||||
alice ONE stored RT
|
||||
-> refresh grant -> user AT (azp=turnstone); RT ROTATED on refresh
|
||||
-> 8693 exchange audience=mcp-a scope=aud-mcp-a -> AT aud=mcp-a user=alice 300s, NO RT
|
||||
-> 8693 exchange audience=mcp-b scope=aud-mcp-b -> AT aud=mcp-b (same subject AT)
|
||||
negative control audience=mcp-c -> invalid_client "Audience not found"
|
||||
```
|
||||
|
||||
Findings that feed the design:
|
||||
1. **One per-user credential -> N audience tokens: VERIFIED on a second IdP.**
|
||||
The substrate is portable; only the grant leg differs per IdP.
|
||||
2. **Exchanged tokens are cache-shaped** (short TTL, no RT) — per-server
|
||||
`mcp_user_tokens` rows as short-lived mint cache is the right model.
|
||||
3. **RT rotation happens here too** — newest-RT write-back on every redemption
|
||||
is a correctness requirement of the capture layer, not an Entra quirk.
|
||||
4. **The IdP-side "delegated grant" has a per-IdP shape**: Entra = API
|
||||
permissions + admin consent; Keycloak = audience client scopes attached to
|
||||
the requester client (optional scopes activate via `scope=` at exchange).
|
||||
Operator runbooks are per-IdP (#682 pattern), code is not.
|
||||
5. Gotchas hit: KC user needs a complete profile for direct grant ("Account is
|
||||
not fully set up"); optional audience scope must be requested explicitly or
|
||||
the exchange 400s with "Requested audience not available".
|
||||
|
||||
Repro (ephemeral, ~2 min):
|
||||
|
||||
```bash
|
||||
docker run -d --name kc-obo-spike -p 127.0.0.1:8089:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.3 start-dev
|
||||
KC="docker exec kc-obo-spike /opt/keycloak/bin/kcadm.sh"
|
||||
$KC config credentials --server http://localhost:8080 --realm master --user admin --password admin
|
||||
$KC create realms -s realm=spike -s enabled=true
|
||||
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
|
||||
-s secret=spike-secret -s directAccessGrantsEnabled=true \
|
||||
-s 'attributes={"standard.token.exchange.enabled":"true"}'
|
||||
$KC create clients -r spike -s clientId=mcp-a -s enabled=true -s publicClient=false -s secret=x
|
||||
$KC create clients -r spike -s clientId=mcp-b -s enabled=true -s publicClient=false -s secret=x
|
||||
$KC create users -r spike -s username=alice -s enabled=true -s email=a@s.test \
|
||||
-s emailVerified=true -s firstName=A -s lastName=S
|
||||
$KC set-password -r spike --username alice --new-password alice-pw
|
||||
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
|
||||
for t in mcp-a mcp-b; do
|
||||
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
|
||||
$KC create client-scopes/$SID/protocol-mappers/models -r spike -s name=aud-$t \
|
||||
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
|
||||
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}"
|
||||
$KC update clients/$TURNSTONE_UUID/optional-client-scopes/$SID -r spike
|
||||
done
|
||||
# then: password grant -> refresh grant -> token-exchange with
|
||||
# grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
|
||||
# subject_token=<user AT>, subject_token_type=...:access_token,
|
||||
# audience=mcp-a, scope=aud-mcp-a
|
||||
```
|
||||
@@ -1,286 +0,0 @@
|
||||
"""End-to-end exercise of the oauth_obo feature against a REAL Entra tenant.
|
||||
|
||||
Unlike ``entra_spike.py`` (which verified the raw OAuth wire shapes), this
|
||||
drives the ACTUAL Turnstone product code — real ``MCPTokenStore``, real
|
||||
``get_obo_access_token_classified`` → ``_obo_mint_entra`` → the real Entra
|
||||
token endpoint — so a green run proves the shipped mint engine works against
|
||||
live Entra, not just that the protocol does.
|
||||
|
||||
Flow:
|
||||
1. Interactive Entra login (auth-code + PKCE + offline_access) → a real
|
||||
refresh credential. This is what ``handle_oidc_callback`` receives.
|
||||
2. Persist it via ``MCPTokenStore.upsert_oidc_credential`` — the exact call
|
||||
the OIDC callback makes on capture (auth.py). The rest of the callback
|
||||
(JWKS validation, user provisioning) is OIDC-generic and unit-tested; the
|
||||
novel path is capture + mint, which this exercises for real.
|
||||
3. Seed real ``oauth_obo`` ``mcp_servers`` rows (audiences A/B consented, C
|
||||
not) and drive ``get_obo_access_token_classified`` — the real dispatch-time
|
||||
entry point — asserting on the minted tokens, cache, rotation, and
|
||||
classification.
|
||||
|
||||
Checks (VERIFIED / FAILED per line):
|
||||
E1 mint for audience A → kind=token; decoded aud == A; cache row written with
|
||||
refresh_token_ct NULL (cache, not custody); expires_at set
|
||||
E2 second call for A → cache hit, ZERO additional Entra calls
|
||||
E3 mint for audience B from the SAME captured credential → aud == B
|
||||
(the single-credential-many-audiences thesis, through the real engine)
|
||||
E4 rotation write-back: the stored credential holds the newest refresh token
|
||||
E5 force_refresh → a fresh mint (Entra call count increments)
|
||||
E6 unconsented audience C → NOT kind=token, and the shared credential SURVIVES
|
||||
(never auto-deleted — the load-bearing custody invariant)
|
||||
E7 cache flush → re-mint: deleting the cache row makes the next call re-mint
|
||||
|
||||
Run:
|
||||
source scripts/obo-e2e/.env
|
||||
uv run python scripts/obo-e2e/entra_e2e.py
|
||||
Env (from .env): ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET,
|
||||
SPIKE_AUDIENCE_A, SPIKE_AUDIENCE_B, SPIKE_AUDIENCE_UNCONSENTED, SPIKE_PORT.
|
||||
Remote browser: set SPIKE_CALLBACK_FILE to paste the redirect URL (as before).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
# Reuse the verified interactive-login machinery from the wire spike.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from entra_spike import interactive_login, jwt_claims_unverified, redact # noqa: E402
|
||||
|
||||
from turnstone.core.mcp_crypto import ( # noqa: E402
|
||||
MCPTokenCipher,
|
||||
MCPTokenCipherConfig,
|
||||
MCPTokenStore,
|
||||
)
|
||||
from turnstone.core.mcp_oauth import get_obo_access_token_classified # noqa: E402
|
||||
from turnstone.core.oidc import OIDCConfig # noqa: E402
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend # noqa: E402
|
||||
|
||||
USER = "e2e-user"
|
||||
RESULTS: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def record(status: str, msg: str) -> None:
|
||||
RESULTS.append((status, msg))
|
||||
print(f"[{status:>8}] {msg}")
|
||||
|
||||
|
||||
def aud_matches(token: str, want_audience: str) -> tuple[bool, str]:
|
||||
"""Compare a minted access token's aud claim to the configured audience.
|
||||
|
||||
Entra returns aud as the bare app-id GUID or the full ``api://<guid>`` URI;
|
||||
accept either.
|
||||
"""
|
||||
claims = jwt_claims_unverified(token)
|
||||
aud = str(claims.get("aud", "<none>"))
|
||||
want = want_audience.removeprefix("api://")
|
||||
return aud in (want, want_audience), aud
|
||||
|
||||
|
||||
class _CountingClient:
|
||||
"""Wraps httpx.AsyncClient, counting token-endpoint POSTs so cache hits
|
||||
(which must issue zero) are observable."""
|
||||
|
||||
def __init__(self, inner: httpx.AsyncClient) -> None:
|
||||
self._inner = inner
|
||||
self.posts = 0
|
||||
|
||||
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||
self.posts += 1
|
||||
return await self._inner.post(*args, **kwargs)
|
||||
|
||||
|
||||
def _make_app_state(
|
||||
storage: SQLiteBackend,
|
||||
store: MCPTokenStore,
|
||||
oidc_config: OIDCConfig,
|
||||
http_client: _CountingClient,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
auth_storage=storage,
|
||||
mcp_token_store=store,
|
||||
oidc_config=oidc_config,
|
||||
obo_http_client=http_client,
|
||||
mcp_oauth_refresh_locks={},
|
||||
mcp_oauth_refresh_backoff={},
|
||||
)
|
||||
|
||||
|
||||
def _seed_obo_server(storage: SQLiteBackend, name: str, audience: str) -> None:
|
||||
storage.create_mcp_server(
|
||||
server_id=f"{name}-id",
|
||||
name=name,
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.invalid/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience=audience,
|
||||
)
|
||||
|
||||
|
||||
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
|
||||
tenant = cfg["ENTRA_TENANT_ID"]
|
||||
issuer = f"https://login.microsoftonline.com/{tenant}/v2.0"
|
||||
token_endpoint = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
aud_a = cfg["SPIKE_AUDIENCE_A"]
|
||||
aud_b = cfg["SPIKE_AUDIENCE_B"]
|
||||
aud_c = cfg.get("SPIKE_AUDIENCE_UNCONSENTED", "")
|
||||
|
||||
# Real Turnstone objects.
|
||||
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-e2e-"), "e2e.db")
|
||||
storage = SQLiteBackend(db_path)
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||||
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
|
||||
oidc_config = OIDCConfig(
|
||||
enabled=True,
|
||||
issuer=issuer,
|
||||
client_id=cfg["ENTRA_CLIENT_ID"],
|
||||
client_secret=cfg["ENTRA_CLIENT_SECRET"],
|
||||
token_endpoint=token_endpoint,
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=True,
|
||||
)
|
||||
|
||||
# Step 2 — CAPTURE: the exact storage call handle_oidc_callback makes.
|
||||
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
|
||||
cap = store.get_oidc_credential(USER, issuer)
|
||||
if cap and cap["refresh_token"] == refresh_token:
|
||||
record("VERIFIED", f"capture: credential persisted for {USER} ({redact(refresh_token)})")
|
||||
else:
|
||||
record("FAILED", "capture: credential did not round-trip")
|
||||
return
|
||||
|
||||
_seed_obo_server(storage, "e2e-a", aud_a)
|
||||
_seed_obo_server(storage, "e2e-b", aud_b)
|
||||
if aud_c:
|
||||
_seed_obo_server(storage, "e2e-c", aud_c)
|
||||
|
||||
inner = httpx.AsyncClient(timeout=20.0)
|
||||
client = _CountingClient(inner)
|
||||
app_state = _make_app_state(storage, store, oidc_config, client)
|
||||
try:
|
||||
# E1 — real mint for audience A.
|
||||
r = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
if r.kind == "token" and r.token:
|
||||
ok, aud = aud_matches(r.token, aud_a)
|
||||
row = storage.get_mcp_user_token(USER, "e2e-a")
|
||||
cache_ok = (
|
||||
row is not None and row["refresh_token_ct"] is None and bool(row["expires_at"])
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if ok and cache_ok else "FAILED",
|
||||
f"E1 mint A: kind=token aud={aud} want={aud_a} cache_row_refreshless={cache_ok}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
|
||||
return
|
||||
|
||||
# E2 — cache hit issues zero Entra calls.
|
||||
posts_before = client.posts
|
||||
r2 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
|
||||
f"E2 cache hit: kind={r2.kind} extra_entra_calls={client.posts - posts_before} (want 0)",
|
||||
)
|
||||
|
||||
# E3 — same credential, audience B.
|
||||
rb = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-b"
|
||||
)
|
||||
if rb.kind == "token" and rb.token:
|
||||
ok_b, aud_bclaim = aud_matches(rb.token, aud_b)
|
||||
record(
|
||||
"VERIFIED" if ok_b else "FAILED",
|
||||
f"E3 mint B from SAME credential: aud={aud_bclaim} want={aud_b}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E3 mint B: kind={rb.kind}")
|
||||
|
||||
# E4 — rotation write-back: the stored credential is still redeemable
|
||||
# (holds the newest RT — Entra rotates on redemption).
|
||||
cred_now = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if cred_now is not None else "FAILED",
|
||||
f"E4 rotation write-back: credential persisted {redact(cred_now['refresh_token']) if cred_now else '<gone>'}",
|
||||
)
|
||||
|
||||
# E5 — force_refresh re-mints (a real Entra call).
|
||||
posts_before = client.posts
|
||||
rf = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a", force_refresh=True
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E5 force_refresh re-mint: kind={rf.kind} entra_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
|
||||
# E6 — unconsented audience: not a token, and the credential SURVIVES.
|
||||
if aud_c:
|
||||
rc = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-c"
|
||||
)
|
||||
cred_after = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
|
||||
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
|
||||
)
|
||||
else:
|
||||
record("SKIPPED", "E6 unconsented C: SPIKE_AUDIENCE_UNCONSENTED not set")
|
||||
|
||||
# E7 — cache flush → re-mint.
|
||||
store.delete_user_token(USER, "e2e-a")
|
||||
posts_before = client.posts
|
||||
r7 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E7 flush→re-mint: kind={r7.kind} entra_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
finally:
|
||||
await inner.aclose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"ENTRA_TENANT_ID",
|
||||
"ENTRA_CLIENT_ID",
|
||||
"ENTRA_CLIENT_SECRET",
|
||||
"SPIKE_AUDIENCE_A",
|
||||
"SPIKE_AUDIENCE_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("ENTRA_", "SPIKE_"))}
|
||||
missing = [k for k in required if not cfg.get(k)]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)} — did you `source scripts/obo-e2e/.env`?")
|
||||
return 2
|
||||
|
||||
print("Signing in to Entra (this is the login the feature captures)...")
|
||||
tokens = interactive_login(cfg)
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
print(f"No refresh_token from login (keys={sorted(tokens)}) — offline_access missing?")
|
||||
return 1
|
||||
|
||||
asyncio.run(_run(cfg, refresh_token))
|
||||
|
||||
print("\n=== summary ===")
|
||||
for status, msg in RESULTS:
|
||||
print(f" {status:>8} {msg}")
|
||||
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entra spike setup for entra_spike.py (#551 re-scope boundary spike).
|
||||
# Manual test tooling — not run in CI. Creates throwaway Entra app registrations.
|
||||
#
|
||||
# ./entra_setup.sh setup create app registrations + consent + .env
|
||||
# ./entra_setup.sh cleanup delete everything it created (incl. .env)
|
||||
#
|
||||
# Creates in the logged-in tenant (az login first):
|
||||
# spike-turnstone confidential client (stands in for Turnstone's OIDC app)
|
||||
# spike-mcp-a/b resource apps exposing scope mcp.access, admin-consented
|
||||
# spike-mcp-c resource app with NO grant to the client (V5 control)
|
||||
# Requires: the logged-in user can create apps + grant admin consent
|
||||
# (Global Admin on a personal tenant qualifies).
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ENV_FILE=".env"
|
||||
NAMES=(spike-turnstone spike-mcp-a spike-mcp-b spike-mcp-c)
|
||||
|
||||
log() { printf '>> %s\n' "$*"; }
|
||||
|
||||
graph_patch_api() { # $1=appId $2=scope-uuid $3=display-name
|
||||
local obj_id
|
||||
obj_id=$(az ad app show --id "$1" --query id -o tsv)
|
||||
az rest --method PATCH \
|
||||
--url "https://graph.microsoft.com/v1.0/applications/${obj_id}" \
|
||||
--headers 'Content-Type=application/json' \
|
||||
--body "{
|
||||
\"identifierUris\": [\"api://$1\"],
|
||||
\"api\": {
|
||||
\"requestedAccessTokenVersion\": 2,
|
||||
\"oauth2PermissionScopes\": [{
|
||||
\"id\": \"$2\",
|
||||
\"value\": \"mcp.access\",
|
||||
\"type\": \"Admin\",
|
||||
\"isEnabled\": true,
|
||||
\"adminConsentDisplayName\": \"Access $3\",
|
||||
\"adminConsentDescription\": \"Spike scope for $3\"
|
||||
}]
|
||||
}
|
||||
}"
|
||||
}
|
||||
|
||||
make_resource_app() { # $1=display-name ; echoes "appId scopeId"
|
||||
local app_id scope_id
|
||||
app_id=$(az ad app create --display-name "$1" \
|
||||
--sign-in-audience AzureADMyOrg --query appId -o tsv)
|
||||
scope_id=$(python3 -c 'import uuid; print(uuid.uuid4())')
|
||||
graph_patch_api "$app_id" "$scope_id" "$1" >/dev/null
|
||||
az ad sp create --id "$app_id" >/dev/null 2>&1 || true
|
||||
echo "$app_id $scope_id"
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
local tenant_id
|
||||
tenant_id=$(az account show --query tenantId -o tsv)
|
||||
log "tenant: ${tenant_id}"
|
||||
|
||||
log "creating resource apps (a, b, c)..."
|
||||
read -r APP_A SCOPE_A <<<"$(make_resource_app spike-mcp-a)"
|
||||
read -r APP_B SCOPE_B <<<"$(make_resource_app spike-mcp-b)"
|
||||
read -r APP_C _ <<<"$(make_resource_app spike-mcp-c)"
|
||||
log " a=${APP_A} b=${APP_B} c=${APP_C} (c stays unconsented)"
|
||||
|
||||
log "creating confidential client spike-turnstone..."
|
||||
CLIENT_ID=$(az ad app create --display-name spike-turnstone \
|
||||
--sign-in-audience AzureADMyOrg \
|
||||
--web-redirect-uris "http://localhost:8765/callback" \
|
||||
--query appId -o tsv)
|
||||
az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true
|
||||
# No stderr suppression here: the secret is load-bearing (it lands in .env),
|
||||
# so under `set -e` a reset failure must abort LOUDLY, not silently.
|
||||
SECRET=$(az ad app credential reset --id "$CLIENT_ID" \
|
||||
--display-name spike --years 1 --query password -o tsv)
|
||||
|
||||
log "adding delegated permissions (a, b — NOT c)..."
|
||||
# Tolerated failures (|| log): a re-run hits "permission already exists" and
|
||||
# SP-propagation delays are common right after app creation — the
|
||||
# admin-consent retry loop below is the real gate. `set -e` would otherwise
|
||||
# turn a suppressed non-zero here into a silent mid-script abort.
|
||||
az ad app permission add --id "$CLIENT_ID" \
|
||||
--api "$APP_A" --api-permissions "${SCOPE_A}=Scope" \
|
||||
|| log " warn: permission add for a failed (may already exist); admin-consent below will confirm"
|
||||
az ad app permission add --id "$CLIENT_ID" \
|
||||
--api "$APP_B" --api-permissions "${SCOPE_B}=Scope" \
|
||||
|| log " warn: permission add for b failed (may already exist); admin-consent below will confirm"
|
||||
|
||||
log "granting admin consent (retries while SPs propagate)..."
|
||||
local ok=""
|
||||
for i in 1 2 3 4 5; do
|
||||
if az ad app permission admin-consent --id "$CLIENT_ID" 2>/dev/null; then
|
||||
ok=1; break
|
||||
fi
|
||||
log " not yet (attempt $i) — waiting 15s"
|
||||
sleep 15
|
||||
done
|
||||
[ -n "$ok" ] || { log "admin-consent failed after retries — grant manually in the portal (API permissions blade) and re-run the spike"; }
|
||||
|
||||
# Single-quote the values in the generated .env: the AS-issued client secret
|
||||
# can contain $ / backtick, and an unquoted RHS would be re-expanded (or
|
||||
# partially executed) when the operator `source`s the file. The heredoc still
|
||||
# interpolates ${...} into the single-quoted output; sourcing then treats the
|
||||
# result literally. (Azure secrets are base64-ish — no single quotes to escape.)
|
||||
umask 177
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
export ENTRA_TENANT_ID='${tenant_id}'
|
||||
export ENTRA_CLIENT_ID='${CLIENT_ID}'
|
||||
export ENTRA_CLIENT_SECRET='${SECRET}'
|
||||
export SPIKE_AUDIENCE_A='api://${APP_A}'
|
||||
export SPIKE_AUDIENCE_B='api://${APP_B}'
|
||||
export SPIKE_AUDIENCE_UNCONSENTED='api://${APP_C}'
|
||||
export SPIKE_RUN_OBO=1
|
||||
EOF
|
||||
log "wrote ${ENV_FILE} (chmod 600). Next:"
|
||||
log " source scripts/obo-e2e/.env && uv run python scripts/obo-e2e/entra_spike.py"
|
||||
log "cleanup later with: ./entra_setup.sh cleanup"
|
||||
}
|
||||
|
||||
cmd_cleanup() {
|
||||
for name in "${NAMES[@]}"; do
|
||||
for app_id in $(az ad app list --display-name "$name" --query '[].appId' -o tsv); do
|
||||
log "deleting ${name} (${app_id})"
|
||||
az ad app delete --id "$app_id"
|
||||
done
|
||||
done
|
||||
rm -f "$ENV_FILE"
|
||||
log "cleanup done (app registrations + .env removed)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
setup) cmd_setup ;;
|
||||
cleanup) cmd_cleanup ;;
|
||||
*) echo "usage: $0 setup|cleanup"; exit 2 ;;
|
||||
esac
|
||||
@@ -1,333 +0,0 @@
|
||||
"""Entra boundary spike for single-credential MCP token minting (#551 re-scope).
|
||||
|
||||
Verifies, against a REAL Entra tenant, the assumptions behind the oauth_obo
|
||||
design (one IdP refresh token per user; per-MCP access tokens minted on
|
||||
demand). Each check prints VERIFIED / FAILED / SKIPPED plus redacted evidence.
|
||||
|
||||
V1 interactive confidential-client login (auth-code + PKCE + offline_access)
|
||||
-> refresh token captured [capture layer works]
|
||||
V2 RT redeemed with scope=<AUDIENCE_A>/.default -> aud claim == A
|
||||
V3 SAME credential redeemed for <AUDIENCE_B> -> aud claim == B
|
||||
KEY CHECK: Entra RTs are client-bound, not resource-bound.
|
||||
V4 rotation semantics: does each redemption return a new RT, and does the
|
||||
PREVIOUS RT keep working? [write-back design]
|
||||
V5 redemption for an unconsented audience -> AADSTS65001 consent_required
|
||||
[maps to the reconnect-rail fallback]
|
||||
V6 optional: OBO jwt-bearer leg (requested_token_use=on_behalf_of) using a
|
||||
Turnstone-audience access token as assertion [middle-tier variant]
|
||||
|
||||
Run: uv run python scripts/obo-e2e/entra_spike.py
|
||||
Env: ENTRA_TENANT_ID tenant GUID or domain
|
||||
ENTRA_CLIENT_ID Turnstone spike app registration (confidential)
|
||||
ENTRA_CLIENT_SECRET client secret for the above
|
||||
SPIKE_AUDIENCE_A e.g. api://<guid-a> (exposes a scope, consented)
|
||||
SPIKE_AUDIENCE_B e.g. api://<guid-b> (exposes a scope, consented)
|
||||
SPIKE_AUDIENCE_UNCONSENTED optional, for V5
|
||||
SPIKE_RUN_OBO optional "1" to run V6
|
||||
SPIKE_PORT redirect listener port (default 8765; register
|
||||
http://localhost:<port>/callback as a Web
|
||||
redirect URI on the spike app registration)
|
||||
|
||||
App-registration setup checklist: see README.md next to this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
RESULTS: list[tuple[str, str, str]] = [] # (check, status, evidence)
|
||||
|
||||
|
||||
def record(check: str, status: str, evidence: str) -> None:
|
||||
RESULTS.append((check, status, evidence))
|
||||
print(f"[{status:>8}] {check}: {evidence}")
|
||||
|
||||
|
||||
def b64url_json(segment: str) -> dict[str, Any]:
|
||||
pad = "=" * (-len(segment) % 4)
|
||||
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(segment + pad))
|
||||
return out
|
||||
|
||||
|
||||
def jwt_claims_unverified(token: str) -> dict[str, Any]:
|
||||
"""Spike-only unverified decode. NEVER do this in product code."""
|
||||
try:
|
||||
return b64url_json(token.split(".")[1])
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def redact(token: str | None) -> str:
|
||||
if not token:
|
||||
return "<absent>"
|
||||
return f"{token[:8]}...({len(token)} chars)"
|
||||
|
||||
|
||||
class _CodeCatcher(BaseHTTPRequestHandler):
|
||||
code: str | None = None
|
||||
state: str | None = None
|
||||
event = threading.Event()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib API name
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
_CodeCatcher.code = (q.get("code") or [None])[0]
|
||||
_CodeCatcher.state = (q.get("state") or [None])[0]
|
||||
body = b"Spike login captured - return to the terminal."
|
||||
if q.get("error"):
|
||||
body = f"IdP error: {q}".encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
_CodeCatcher.event.set()
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def interactive_login(cfg: dict[str, str]) -> dict[str, Any]:
|
||||
"""V1: authorization-code + PKCE + offline_access as a confidential client.
|
||||
|
||||
Mirrors production shape: same grant Turnstone's OIDC login uses
|
||||
(core/oidc.py exchange_code), plus offline_access.
|
||||
"""
|
||||
port = int(cfg.get("SPIKE_PORT", "8765"))
|
||||
redirect_uri = f"http://localhost:{port}/callback"
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
)
|
||||
state = secrets.token_urlsafe(16)
|
||||
authorize = (
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/authorize?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_mode": "query",
|
||||
# offline_access is THE capture-layer delta vs today's login.
|
||||
# No resource scope here: the RT is minted client-bound.
|
||||
"scope": "openid profile offline_access",
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
)
|
||||
server = HTTPServer(("127.0.0.1", port), _CodeCatcher)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
print(f"\nOpen (or auto-opened) in a browser with a tenant user:\n {authorize}\n")
|
||||
cb_file = cfg.get("SPIKE_CALLBACK_FILE", "")
|
||||
if cb_file:
|
||||
print(
|
||||
"Remote-browser mode: after sign-in the browser lands on a broken\n"
|
||||
f"http://localhost:{port}/callback?... page. Copy that FULL URL and run:\n"
|
||||
f" echo '<url>' > {cb_file}\n"
|
||||
)
|
||||
|
||||
def _watch_callback_file() -> None:
|
||||
# Driver-friendly fallback: the sign-in can happen on any device;
|
||||
# whoever signed in drops the redirected URL into SPIKE_CALLBACK_FILE.
|
||||
import time as _time
|
||||
|
||||
while not _CodeCatcher.event.is_set():
|
||||
try:
|
||||
with open(cb_file) as _f:
|
||||
pasted = _f.read().strip()
|
||||
except OSError:
|
||||
pasted = ""
|
||||
if "?" in pasted:
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(pasted).query)
|
||||
_CodeCatcher.code = (q.get("code") or [None])[0]
|
||||
_CodeCatcher.state = (q.get("state") or [None])[0]
|
||||
_CodeCatcher.event.set()
|
||||
return
|
||||
_time.sleep(1.0)
|
||||
|
||||
if cb_file:
|
||||
threading.Thread(target=_watch_callback_file, daemon=True).start()
|
||||
webbrowser.open(authorize)
|
||||
if not _CodeCatcher.event.wait(timeout=600):
|
||||
server.shutdown()
|
||||
raise SystemExit("Timed out waiting for the redirect (10 min).")
|
||||
server.shutdown()
|
||||
if _CodeCatcher.state != state:
|
||||
raise SystemExit("state mismatch on redirect - aborting.")
|
||||
if not _CodeCatcher.code:
|
||||
raise SystemExit("No code on redirect (IdP error page shown in browser).")
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": _CodeCatcher.code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
tokens: dict[str, Any] = resp.json()
|
||||
if resp.status_code != 200:
|
||||
raise SystemExit(f"code exchange failed: {json.dumps(tokens, indent=2)[:800]}")
|
||||
return tokens
|
||||
|
||||
|
||||
def redeem(cfg: dict[str, str], refresh_token: str, scope: str) -> tuple[int, dict[str, Any]]:
|
||||
"""Redeem a refresh token for an access token with the given scope."""
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"scope": scope,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
body: dict[str, Any] = resp.json()
|
||||
return resp.status_code, body
|
||||
|
||||
|
||||
def obo_exchange(cfg: dict[str, str], assertion: str, scope: str) -> tuple[int, dict[str, Any]]:
|
||||
"""V6: middle-tier OBO variant (jwt-bearer + requested_token_use)."""
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": assertion,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"scope": scope,
|
||||
"requested_token_use": "on_behalf_of",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
body: dict[str, Any] = resp.json()
|
||||
return resp.status_code, body
|
||||
|
||||
|
||||
def check_aud(label: str, status: int, body: dict[str, Any], want_aud: str) -> str | None:
|
||||
"""Common V2/V3 assertion: 200 + aud matches. Returns the new RT if any."""
|
||||
if status != 200:
|
||||
record(label, "FAILED", f"HTTP {status}: {json.dumps(body)[:300]}")
|
||||
return None
|
||||
claims = jwt_claims_unverified(body.get("access_token", ""))
|
||||
aud = str(claims.get("aud", "<none>"))
|
||||
ok = aud == want_aud or aud == want_aud.removeprefix("api://")
|
||||
record(
|
||||
label,
|
||||
"VERIFIED" if ok else "FAILED",
|
||||
f"aud={aud} want={want_aud} expires_in={body.get('expires_in')} "
|
||||
f"new_rt={redact(body.get('refresh_token'))}",
|
||||
)
|
||||
new_rt = body.get("refresh_token")
|
||||
return str(new_rt) if isinstance(new_rt, str) else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"ENTRA_TENANT_ID",
|
||||
"ENTRA_CLIENT_ID",
|
||||
"ENTRA_CLIENT_SECRET",
|
||||
"SPIKE_AUDIENCE_A",
|
||||
"SPIKE_AUDIENCE_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in required if k in os.environ}
|
||||
missing = [k for k in required if k not in cfg]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)}\nSee module docstring.")
|
||||
return 2
|
||||
for opt in ("SPIKE_AUDIENCE_UNCONSENTED", "SPIKE_PORT", "SPIKE_RUN_OBO"):
|
||||
if opt in os.environ:
|
||||
cfg[opt] = os.environ[opt]
|
||||
|
||||
# V1 - capture
|
||||
tokens = interactive_login(cfg)
|
||||
rt0 = tokens.get("refresh_token")
|
||||
if isinstance(rt0, str) and rt0:
|
||||
record("V1 capture (offline_access -> RT)", "VERIFIED", redact(rt0))
|
||||
else:
|
||||
record("V1 capture (offline_access -> RT)", "FAILED", f"keys={sorted(tokens.keys())}")
|
||||
return 1
|
||||
|
||||
# V2 - mint for audience A
|
||||
a = cfg["SPIKE_AUDIENCE_A"]
|
||||
s2, b2 = redeem(cfg, rt0, f"{a}/.default")
|
||||
rt_after_a = check_aud("V2 mint audience A from RT", s2, b2, a)
|
||||
|
||||
# V3 - SAME credential, audience B (the design-critical check)
|
||||
b = cfg["SPIKE_AUDIENCE_B"]
|
||||
s3, b3 = redeem(cfg, rt0, f"{b}/.default")
|
||||
check_aud("V3 mint audience B from SAME RT", s3, b3, b)
|
||||
|
||||
# V4 - rotation semantics
|
||||
if rt_after_a and rt_after_a != rt0:
|
||||
s4, _ = redeem(cfg, rt0, f"{a}/.default")
|
||||
record(
|
||||
"V4 rotation (new RT returned; old still valid?)",
|
||||
"VERIFIED" if s4 == 200 else "VERIFIED",
|
||||
f"rotated=yes old_rt_reuse_http={s4} "
|
||||
"(design: persist newest RT on every mint; "
|
||||
f"{'old stays valid - benign race window' if s4 == 200 else 'old INVALIDATED - write-back is correctness-critical'})",
|
||||
)
|
||||
else:
|
||||
record(
|
||||
"V4 rotation",
|
||||
"VERIFIED",
|
||||
"no rotation observed on redemption (same/absent RT) - "
|
||||
"write-back still required for the rotating case",
|
||||
)
|
||||
|
||||
# V5 - unconsented audience -> consent_required
|
||||
unc = cfg.get("SPIKE_AUDIENCE_UNCONSENTED")
|
||||
if unc:
|
||||
s5, b5 = redeem(cfg, rt0, f"{unc}/.default")
|
||||
codes = b5.get("error_codes", [])
|
||||
hit = s5 == 400 and (65001 in codes or b5.get("suberror") == "consent_required")
|
||||
record(
|
||||
"V5 unconsented audience -> AADSTS65001",
|
||||
"VERIFIED" if hit else "FAILED",
|
||||
f"http={s5} error={b5.get('error')} codes={codes}",
|
||||
)
|
||||
else:
|
||||
record("V5 unconsented audience", "SKIPPED", "SPIKE_AUDIENCE_UNCONSENTED not set")
|
||||
|
||||
# V6 - optional OBO middle-tier variant
|
||||
if cfg.get("SPIKE_RUN_OBO") == "1":
|
||||
s6a, b6a = redeem(cfg, rt0, f"{cfg['ENTRA_CLIENT_ID']}/.default")
|
||||
at_self = b6a.get("access_token", "") if s6a == 200 else ""
|
||||
if at_self:
|
||||
s6, b6 = obo_exchange(cfg, at_self, f"{a}/.default")
|
||||
check_aud("V6 OBO jwt-bearer variant", s6, b6, a)
|
||||
else:
|
||||
record(
|
||||
"V6 OBO jwt-bearer variant",
|
||||
"FAILED",
|
||||
f"could not mint self-audience assertion: HTTP {s6a}",
|
||||
)
|
||||
else:
|
||||
record("V6 OBO jwt-bearer variant", "SKIPPED", "SPIKE_RUN_OBO != 1")
|
||||
|
||||
print("\n=== summary ===")
|
||||
for check, status, _ in RESULTS:
|
||||
print(f" {status:>8} {check}")
|
||||
return 0 if all(s != "FAILED" for _, s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,271 +0,0 @@
|
||||
"""End-to-end exercise of the oauth_obo feature on the OSS path (RFC 8693).
|
||||
|
||||
Parallel to ``entra_e2e.py`` but for ``obo_grant_profile="rfc8693"`` against an
|
||||
ephemeral Keycloak — the open-source / non-Entra deployment shape. Fully
|
||||
headless (password grant, no browser), so it runs unattended.
|
||||
|
||||
Drives the REAL Turnstone code: ``MCPTokenStore.upsert_oidc_credential`` (capture)
|
||||
then ``get_obo_access_token_classified`` → ``_obo_mint_rfc8693`` (refresh grant →
|
||||
RFC 8693 token exchange) against the live Keycloak token endpoint.
|
||||
|
||||
Checks E1–E7 mirror the Entra harness:
|
||||
E1 mint audience A → token, aud claim carries A, cache row refresh_token_ct NULL
|
||||
E2 second call → cache hit, ZERO extra Keycloak calls
|
||||
E3 audience B from the SAME captured credential → aud carries B
|
||||
E4 rotation write-back (KC rotates the RT on the refresh leg)
|
||||
E5 force_refresh → re-mint (Keycloak call count increments)
|
||||
E6 unconsented audience C → NOT token, credential SURVIVES
|
||||
E7 cache flush → re-mint
|
||||
|
||||
Env (set by keycloak_e2e.sh):
|
||||
KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET,
|
||||
KC_USER, KC_PASSWORD, AUD_A, SCOPE_A, AUD_B, SCOPE_B, AUD_C
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from turnstone.core.mcp_crypto import (
|
||||
MCPTokenCipher,
|
||||
MCPTokenCipherConfig,
|
||||
MCPTokenStore,
|
||||
)
|
||||
from turnstone.core.mcp_oauth import get_obo_access_token_classified
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
USER = "e2e-user"
|
||||
RESULTS: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def record(status: str, msg: str) -> None:
|
||||
RESULTS.append((status, msg))
|
||||
print(f"[{status:>8}] {msg}")
|
||||
|
||||
|
||||
def redact(token: str | None) -> str:
|
||||
return f"{token[:8]}...({len(token)} chars)" if token else "<absent>"
|
||||
|
||||
|
||||
def jwt_claims(token: str) -> dict[str, Any]:
|
||||
seg = token.split(".")[1]
|
||||
pad = "=" * (-len(seg) % 4)
|
||||
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(seg + pad))
|
||||
return out
|
||||
|
||||
|
||||
def aud_carries(token: str, want: str) -> tuple[bool, str]:
|
||||
"""KC puts the exchanged audience in the aud claim (str or list)."""
|
||||
aud = jwt_claims(token).get("aud", [])
|
||||
auds = aud if isinstance(aud, list) else [aud]
|
||||
return want in auds, str(aud)
|
||||
|
||||
|
||||
class _CountingClient:
|
||||
def __init__(self, inner: httpx.AsyncClient) -> None:
|
||||
self._inner = inner
|
||||
self.posts = 0
|
||||
|
||||
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||
self.posts += 1
|
||||
return await self._inner.post(*args, **kwargs)
|
||||
|
||||
|
||||
def _password_login(cfg: dict[str, str]) -> str:
|
||||
"""Headless direct-access grant → a real refresh token for the user."""
|
||||
resp = httpx.post(
|
||||
cfg["KC_TOKEN_ENDPOINT"],
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": cfg["KC_CLIENT_ID"],
|
||||
"client_secret": cfg["KC_CLIENT_SECRET"],
|
||||
"username": cfg["KC_USER"],
|
||||
"password": cfg["KC_PASSWORD"],
|
||||
"scope": "openid",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return str(resp.json()["refresh_token"])
|
||||
|
||||
|
||||
def _seed(storage: SQLiteBackend, name: str, audience: str, scopes: str | None) -> None:
|
||||
storage.create_mcp_server(
|
||||
server_id=f"{name}-id",
|
||||
name=name,
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.invalid/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience=audience,
|
||||
oauth_scopes=scopes,
|
||||
)
|
||||
|
||||
|
||||
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
|
||||
issuer = cfg["KC_ISSUER"]
|
||||
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-kc-e2e-"), "e2e.db")
|
||||
storage = SQLiteBackend(db_path)
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||||
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
|
||||
oidc_config = OIDCConfig(
|
||||
enabled=True,
|
||||
issuer=issuer,
|
||||
client_id=cfg["KC_CLIENT_ID"],
|
||||
client_secret=cfg["KC_CLIENT_SECRET"],
|
||||
token_endpoint=cfg["KC_TOKEN_ENDPOINT"],
|
||||
obo_grant_profile="rfc8693",
|
||||
capture_user_credential=True,
|
||||
)
|
||||
|
||||
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
|
||||
cap = store.get_oidc_credential(USER, issuer)
|
||||
if cap and cap["refresh_token"] == refresh_token:
|
||||
record("VERIFIED", f"capture: credential persisted ({redact(refresh_token)})")
|
||||
else:
|
||||
record("FAILED", "capture: credential did not round-trip")
|
||||
return
|
||||
|
||||
_seed(storage, "kc-a", cfg["AUD_A"], cfg.get("SCOPE_A"))
|
||||
_seed(storage, "kc-b", cfg["AUD_B"], cfg.get("SCOPE_B"))
|
||||
if cfg.get("AUD_C"):
|
||||
_seed(storage, "kc-c", cfg["AUD_C"], None) # no audience scope → unconsented
|
||||
|
||||
inner = httpx.AsyncClient(timeout=20.0)
|
||||
client = _CountingClient(inner)
|
||||
app_state = SimpleNamespace(
|
||||
auth_storage=storage,
|
||||
mcp_token_store=store,
|
||||
oidc_config=oidc_config,
|
||||
obo_http_client=client,
|
||||
mcp_oauth_refresh_locks={},
|
||||
mcp_oauth_refresh_backoff={},
|
||||
)
|
||||
try:
|
||||
# E1 — rfc8693 mint (refresh grant → token exchange) for audience A.
|
||||
r = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
if r.kind == "token" and r.token:
|
||||
ok, aud = aud_carries(r.token, cfg["AUD_A"])
|
||||
row = storage.get_mcp_user_token(USER, "kc-a")
|
||||
cache_ok = row is not None and row["refresh_token_ct"] is None
|
||||
record(
|
||||
"VERIFIED" if ok and cache_ok else "FAILED",
|
||||
f"E1 mint A (refresh→exchange): kind=token aud={aud} want={cfg['AUD_A']} "
|
||||
f"cache_row_refreshless={cache_ok}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
|
||||
return
|
||||
|
||||
# E2 — cache hit.
|
||||
posts_before = client.posts
|
||||
r2 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
|
||||
f"E2 cache hit: kind={r2.kind} extra_kc_calls={client.posts - posts_before} (want 0)",
|
||||
)
|
||||
|
||||
# E3 — audience B from the SAME credential.
|
||||
rb = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-b"
|
||||
)
|
||||
if rb.kind == "token" and rb.token:
|
||||
ok_b, aud_b = aud_carries(rb.token, cfg["AUD_B"])
|
||||
record(
|
||||
"VERIFIED" if ok_b else "FAILED",
|
||||
f"E3 mint B from SAME credential: aud={aud_b} want={cfg['AUD_B']}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E3 mint B: kind={rb.kind}")
|
||||
|
||||
# E4 — rotation write-back (KC rotates the RT on the refresh leg).
|
||||
cred_now = store.get_oidc_credential(USER, issuer)
|
||||
rotated = cred_now is not None and cred_now["refresh_token"] != refresh_token
|
||||
record(
|
||||
"VERIFIED" if cred_now is not None else "FAILED",
|
||||
f"E4 rotation write-back: persisted={redact(cred_now['refresh_token']) if cred_now else '<gone>'} "
|
||||
f"rotated_from_initial={rotated}",
|
||||
)
|
||||
|
||||
# E5 — force_refresh re-mints.
|
||||
posts_before = client.posts
|
||||
rf = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a", force_refresh=True
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E5 force_refresh re-mint: kind={rf.kind} kc_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
|
||||
# E6 — unconsented audience: not a token, credential survives.
|
||||
if cfg.get("AUD_C"):
|
||||
rc = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-c"
|
||||
)
|
||||
cred_after = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
|
||||
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
|
||||
)
|
||||
else:
|
||||
record("SKIPPED", "E6 unconsented C: AUD_C not set")
|
||||
|
||||
# E7 — cache flush → re-mint.
|
||||
store.delete_user_token(USER, "kc-a")
|
||||
posts_before = client.posts
|
||||
r7 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E7 flush→re-mint: kind={r7.kind} kc_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
finally:
|
||||
await inner.aclose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"KC_TOKEN_ENDPOINT",
|
||||
"KC_ISSUER",
|
||||
"KC_CLIENT_ID",
|
||||
"KC_CLIENT_SECRET",
|
||||
"KC_USER",
|
||||
"KC_PASSWORD",
|
||||
"AUD_A",
|
||||
"AUD_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("KC_", "AUD_", "SCOPE_"))}
|
||||
missing = [k for k in required if not cfg.get(k)]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)} — run via keycloak_e2e.sh")
|
||||
return 2
|
||||
|
||||
print("Headless password login to Keycloak (the credential the feature captures)...")
|
||||
refresh_token = _password_login(cfg)
|
||||
|
||||
asyncio.run(_run(cfg, refresh_token))
|
||||
|
||||
print("\n=== summary ===")
|
||||
for status, msg in RESULTS:
|
||||
print(f" {status:>8} {msg}")
|
||||
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# OSS-path (RFC 8693) end-to-end: spin up ephemeral Keycloak, configure the
|
||||
# realm, run keycloak_e2e.py against the REAL Turnstone mint engine, tear down.
|
||||
# Fully headless — no browser. Manual test tooling, not run in CI.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.." # repo root (uv run needs it)
|
||||
|
||||
CONTAINER=kc-obo-e2e
|
||||
PORT=8091
|
||||
KC="docker exec $CONTAINER /opt/keycloak/bin/kcadm.sh"
|
||||
|
||||
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
echo ">> starting Keycloak 26.3 (ephemeral)..."
|
||||
docker run -d --name "$CONTAINER" -p "127.0.0.1:${PORT}:8080" \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.3 start-dev >/dev/null
|
||||
|
||||
echo ">> waiting for Keycloak (dev-mode boot can take a few minutes on a loaded host)..."
|
||||
# Wait on kcadm auth succeeding directly — more reliable than the host HTTP port,
|
||||
# and generous enough for a resource-starved boot (up to ~6 min).
|
||||
ready=""
|
||||
for _ in $(seq 1 90); do
|
||||
if $KC config credentials --server http://localhost:8080 --realm master \
|
||||
--user admin --password admin >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
[ -n "$ready" ] || { echo "Keycloak did not become ready in time"; docker logs "$CONTAINER" 2>&1 | tail -15; exit 1; }
|
||||
|
||||
echo ">> configuring realm 'spike'..."
|
||||
$KC create realms -s realm=spike -s enabled=true >/dev/null
|
||||
# Confidential client with standard token exchange (the RFC 8693 leg) + direct
|
||||
# access grant (headless password login to fetch the user's refresh token).
|
||||
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
|
||||
-s secret=spike-secret -s directAccessGrantsEnabled=true \
|
||||
-s 'attributes={"standard.token.exchange.enabled":"true"}' >/dev/null
|
||||
for t in mcp-a mcp-b mcp-c; do
|
||||
$KC create clients -r spike -s clientId=$t -s enabled=true -s publicClient=false -s secret=x >/dev/null
|
||||
done
|
||||
$KC create users -r spike -s username=e2e-user -s enabled=true -s email=e2e@spike.test \
|
||||
-s emailVerified=true -s firstName=E2E -s lastName=User >/dev/null
|
||||
$KC set-password -r spike --username e2e-user --new-password e2e-pw >/dev/null
|
||||
|
||||
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
|
||||
# Audience client scopes for mcp-a and mcp-b ONLY (mcp-c stays unconsented → E6).
|
||||
for t in mcp-a mcp-b; do
|
||||
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
|
||||
$KC create "client-scopes/$SID/protocol-mappers/models" -r spike -s name=aud-$t \
|
||||
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
|
||||
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}" >/dev/null
|
||||
$KC update "clients/$TURNSTONE_UUID/optional-client-scopes/$SID" -r spike >/dev/null
|
||||
done
|
||||
|
||||
echo ">> running the product e2e harness..."
|
||||
export KC_TOKEN_ENDPOINT="http://127.0.0.1:${PORT}/realms/spike/protocol/openid-connect/token"
|
||||
export KC_ISSUER="http://127.0.0.1:${PORT}/realms/spike"
|
||||
export KC_CLIENT_ID=turnstone KC_CLIENT_SECRET=spike-secret
|
||||
export KC_USER=e2e-user KC_PASSWORD=e2e-pw
|
||||
export AUD_A=mcp-a SCOPE_A=aud-mcp-a AUD_B=mcp-b SCOPE_B=aud-mcp-b AUD_C=mcp-c
|
||||
uv run python scripts/obo-e2e/keycloak_e2e.py
|
||||
Generated
+7
-368
@@ -9,7 +9,7 @@
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
},
|
||||
@@ -408,346 +408,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
@@ -1448,38 +1108,17 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,9 @@ collect it as a test file — it's an importable utility, not a test.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.providers import StreamChunk, ToolCallDelta
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
@@ -46,304 +43,3 @@ def make_session(**kwargs: Any) -> ChatSession:
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
def mock_completion_result(
|
||||
content: str = "",
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
) -> MagicMock:
|
||||
"""A provider result shaped like ``CompletionResult``.
|
||||
|
||||
Callers that route through ``model_turn`` (judges, task agents, and
|
||||
every lane #827 migrates) hit its re-ingest, which iterates
|
||||
``tool_calls``/``provider_blocks`` and joins ``reasoning`` — a bare
|
||||
MagicMock attribute would TypeError deep inside the seam, so every
|
||||
field the re-ingest reads is pinned to a real value here. ONE shared
|
||||
definition: when the re-ingest starts reading a new CompletionResult
|
||||
field, add it here and every suite moves together.
|
||||
"""
|
||||
result = MagicMock()
|
||||
result.content = content
|
||||
result.tool_calls = tool_calls
|
||||
result.finish_reason = "stop"
|
||||
result.usage = None
|
||||
result.provider_blocks = []
|
||||
result.reasoning = ""
|
||||
return result
|
||||
|
||||
|
||||
def fake_chat_stream(
|
||||
*,
|
||||
content: str | None = None,
|
||||
tool_calls: list[dict[str, str]] | None = None,
|
||||
finish_reason: str = "stop",
|
||||
prompt_tokens: int = 10,
|
||||
completion_tokens: int = 5,
|
||||
reasoning_content: str | None = None,
|
||||
reasoning: str | None = None,
|
||||
) -> list[Any]:
|
||||
"""Fake OpenAI Chat Completions SSE chunks for driving the REAL
|
||||
``OpenAIChatCompletionsProvider`` through a fake SDK client::
|
||||
|
||||
client.chat.completions.create = lambda **kw: fake_chat_stream(...)
|
||||
|
||||
Exercises the adapter's ``_iter_stream`` plus ``drain_stream`` end to
|
||||
end (the highest-fidelity fake lane), unlike ``as_stream`` which fakes
|
||||
at the provider boundary. ``tool_calls`` entries are
|
||||
``{"id", "name", "arguments"}`` dicts. ``SimpleNamespace`` (not
|
||||
``MagicMock``) so absent SDK fields read as real ``None`` — an
|
||||
auto-created mock attribute would leak into ``len()``/string paths.
|
||||
|
||||
Emits the realistic three-phase shape: data chunk(s), a finish-reason
|
||||
chunk, then the ``stream_options.include_usage`` usage-only chunk with
|
||||
empty ``choices``.
|
||||
"""
|
||||
|
||||
def _delta(
|
||||
content_val: str | None = None,
|
||||
tcs: list[Any] | None = None,
|
||||
rc: str | None = None,
|
||||
rsn: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
content=content_val,
|
||||
tool_calls=tcs,
|
||||
reasoning=rsn,
|
||||
reasoning_content=rc,
|
||||
annotations=None,
|
||||
)
|
||||
|
||||
chunks: list[Any] = []
|
||||
if reasoning_content is not None or reasoning is not None:
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason=None, delta=_delta(rc=reasoning_content, rsn=reasoning)
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
if content is not None:
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=None, delta=_delta(content))],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
if tool_calls:
|
||||
tcs = [
|
||||
SimpleNamespace(
|
||||
index=i,
|
||||
id=tc.get("id", ""),
|
||||
function=SimpleNamespace(
|
||||
name=tc.get("name", ""), arguments=tc.get("arguments", "")
|
||||
),
|
||||
)
|
||||
for i, tc in enumerate(tool_calls)
|
||||
]
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=None, delta=_delta(None, tcs))],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=finish_reason, delta=_delta())],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=None,
|
||||
input_tokens_details=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
class _ScriptedClient:
|
||||
"""Callable client-method fake following a script of stream builders.
|
||||
|
||||
Call N returns the stream described by ``scripts[N]``; the last script
|
||||
repeats for any further calls. Each script is a dict of kwargs for
|
||||
the bound stream builder, or a pre-built return value. Records every
|
||||
call's kwargs on ``.calls`` — read ``len(fn.calls)`` where a test
|
||||
previously kept its own counter cell, and ``fn.calls[i]["messages"]``
|
||||
where it captured request bodies.
|
||||
"""
|
||||
|
||||
def __init__(self, scripts: tuple[Any, ...], to_stream: Any) -> None:
|
||||
self._scripts = scripts
|
||||
self._to_stream = to_stream
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(self, **kwargs: Any) -> Any:
|
||||
self.calls.append(kwargs)
|
||||
script = self._scripts[min(len(self.calls) - 1, len(self._scripts) - 1)]
|
||||
return self._to_stream(**script) if isinstance(script, dict) else script
|
||||
|
||||
|
||||
def scripted_chat_client(*scripts: Any) -> _ScriptedClient:
|
||||
"""A scripted ``client.chat.completions.create`` — dict scripts are
|
||||
:func:`fake_chat_stream` kwargs."""
|
||||
return _ScriptedClient(scripts, fake_chat_stream)
|
||||
|
||||
|
||||
def scripted_anthropic_client(*scripts: Any) -> _ScriptedClient:
|
||||
"""A scripted ``client.messages.stream`` — dict scripts are
|
||||
:func:`fake_anthropic_stream` kwargs (``blocks`` plus optional
|
||||
``stop_reason``/``usage``)."""
|
||||
return _ScriptedClient(scripts, fake_anthropic_stream)
|
||||
|
||||
|
||||
class FakeAnthropicBlock:
|
||||
"""A full-content Anthropic content-block fake for
|
||||
:func:`fake_anthropic_stream` — plain attributes plus the
|
||||
``model_dump()`` the provider's block capture reads."""
|
||||
|
||||
def __init__(self, **fields: Any) -> None:
|
||||
self._fields = fields
|
||||
for key, value in fields.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def model_dump(self, **_kw: Any) -> dict[str, Any]:
|
||||
return dict(self._fields)
|
||||
|
||||
|
||||
def fake_anthropic_stream(
|
||||
blocks: list[Any],
|
||||
*,
|
||||
stop_reason: str | None = "end_turn",
|
||||
usage: Any = None,
|
||||
) -> Any:
|
||||
"""Fake Anthropic SDK stream context manager for tests that drive the
|
||||
REAL ``AnthropicProvider`` through a fake client::
|
||||
|
||||
client.messages.stream = lambda **kw: fake_anthropic_stream(...)
|
||||
|
||||
Accepts the same full-content block fakes the pre-#831
|
||||
``get_final_message`` fixtures used (objects with ``.type`` + fields
|
||||
and ``model_dump()``) and synthesizes the real event grammar the
|
||||
streaming iterator consumes: ``content_block_start`` carries the block
|
||||
with its text/thinking/signature EMPTIED and ``input`` as ``{}`` (the
|
||||
SDK start shape), deltas carry the content, ``content_block_stop``
|
||||
finalizes tool input, and the closing ``message_delta`` carries
|
||||
``stop_reason`` (+ optional usage object). Without the stripping, the
|
||||
provider's raw-block accumulator would double every text/thinking
|
||||
field (start capture + delta append).
|
||||
|
||||
``stop_reason=None`` omits the closing ``message_delta`` entirely —
|
||||
the terminal-signal-less lax-gateway shape ``finish_reason_optional``
|
||||
exists for (content arrives, then the stream just ends).
|
||||
"""
|
||||
events: list[Any] = []
|
||||
for idx, block in enumerate(blocks):
|
||||
d = dict(block.model_dump()) if hasattr(block, "model_dump") else dict(vars(block))
|
||||
btype = d.get("type", "")
|
||||
start = dict(d)
|
||||
if btype == "text":
|
||||
start["text"] = ""
|
||||
elif btype == "thinking":
|
||||
start["thinking"] = ""
|
||||
start["signature"] = ""
|
||||
elif btype == "tool_use":
|
||||
start["input"] = {}
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_start", index=idx, content_block=SimpleNamespace(**start)
|
||||
)
|
||||
)
|
||||
if btype == "text" and d.get("text"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="text_delta", text=d["text"]),
|
||||
)
|
||||
)
|
||||
elif btype == "thinking":
|
||||
if d.get("thinking"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="thinking_delta", thinking=d["thinking"]),
|
||||
)
|
||||
)
|
||||
if d.get("signature"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="signature_delta", signature=d["signature"]),
|
||||
)
|
||||
)
|
||||
elif btype == "tool_use":
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(
|
||||
type="input_json_delta",
|
||||
partial_json=json.dumps(d.get("input", {})),
|
||||
),
|
||||
)
|
||||
)
|
||||
events.append(SimpleNamespace(type="content_block_stop", index=idx))
|
||||
if stop_reason is not None or usage is not None:
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
|
||||
)
|
||||
)
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.__enter__ = MagicMock(return_value=events)
|
||||
mgr.__exit__ = MagicMock(return_value=False)
|
||||
return mgr
|
||||
|
||||
|
||||
def as_stream(result: Any) -> list[StreamChunk]:
|
||||
"""Adapt a ``CompletionResult``-shaped fake to a ``create_streaming``
|
||||
return value (single terminal chunk).
|
||||
|
||||
The #831 transport collapse routes every single-shot lane through
|
||||
``drain_stream(provider.create_streaming(...))``, so provider fakes
|
||||
return chunk iterables now. Tests keep building result-shaped fakes
|
||||
(``mock_completion_result`` or hand-rolled) and wrap them at
|
||||
assignment: ``provider.create_streaming.return_value =
|
||||
as_stream(result)``. A list re-iterates on every call, so one
|
||||
``return_value`` serves repeated-call tests; convert AFTER mutating
|
||||
the fake's fields — the chunk snapshots them.
|
||||
|
||||
Multi-chunk accumulation semantics are exercised by the dedicated
|
||||
``drain_stream`` unit tests, not through this helper.
|
||||
"""
|
||||
deltas = [
|
||||
ToolCallDelta(
|
||||
index=i,
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("function", {}).get("name", ""),
|
||||
arguments_delta=tc.get("function", {}).get("arguments", ""),
|
||||
)
|
||||
for i, tc in enumerate(result.tool_calls or [])
|
||||
]
|
||||
return [
|
||||
StreamChunk(
|
||||
content_delta=result.content or "",
|
||||
reasoning_delta=getattr(result, "reasoning", "") or "",
|
||||
tool_call_deltas=deltas,
|
||||
usage=result.usage,
|
||||
finish_reason=result.finish_reason or "stop",
|
||||
provider_blocks=list(result.provider_blocks or []),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -4,9 +4,6 @@ import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -219,98 +216,6 @@ def _seed_static_state(mgr: MCPClientManager, name: str, **overrides: Any) -> St
|
||||
return state
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 10) -> Any:
|
||||
"""Submit *coro* to *loop*, wait for the result.
|
||||
|
||||
The ONE copy shared by the MCP test files — four hand-synced copies
|
||||
had already drifted on the timeout (5s hardcoded vs a 10s default).
|
||||
The timeout is an upper bound on waiting, not a behavior assertion,
|
||||
so the most generous variant won the merge.
|
||||
"""
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
|
||||
def _drain_background(mgr: MCPClientManager, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Deterministically await ``mgr``'s tracked background tasks.
|
||||
|
||||
Replaces fixed sleeps for synchronizing with scheduled dead-grant
|
||||
drops / spawned refreshes: exact, and immune to slow-runner flake.
|
||||
"""
|
||||
|
||||
async def _drain() -> None:
|
||||
tasks = [t for t in list(mgr._background_tasks) if not t.done()]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
_run_on_loop(loop, _drain())
|
||||
|
||||
|
||||
def _poll_until(predicate: Callable[[], bool], timeout: float, interval: float = 0.05) -> bool:
|
||||
"""Poll *predicate* until true or *timeout* elapses — the ONE wait loop.
|
||||
|
||||
Shared by the live MCP smoke tests' condition helpers so the
|
||||
deadline/poll pattern doesn't accrete per-file hand-synced copies.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Grab an ephemeral localhost port for a live-server subprocess.
|
||||
|
||||
Shared by the live MCP smoke tests (flaky-server, push-refresh) so
|
||||
the socket-probe helpers stay in one place instead of drifting per
|
||||
file.
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
def _tcp_accepts(port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_tcp_ready(port: int, timeout: float) -> bool:
|
||||
"""Poll until something accepts TCP on 127.0.0.1:*port* (live tests)."""
|
||||
return _poll_until(lambda: _tcp_accepts(port), timeout)
|
||||
|
||||
|
||||
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
|
||||
"""Poll until static server *name* has a live session (live tests)."""
|
||||
|
||||
def _live() -> bool:
|
||||
state = mgr._static_servers.get(name)
|
||||
return state is not None and state.session is not None
|
||||
|
||||
return _poll_until(_live, timeout)
|
||||
|
||||
|
||||
def _popen_mcp_server(script_path: Any, port: int) -> subprocess.Popen[bytes]:
|
||||
"""Start a FastMCP live-server subprocess, streams to DEVNULL.
|
||||
|
||||
The shared spawn primitive for the live MCP smoke tests
|
||||
(flaky-server flap loop, push-refresh) — the readiness wait and the
|
||||
skip-vs-raise-on-failure policy legitimately differ per test and
|
||||
stay at the call sites. ``sys.executable`` runs the same interpreter,
|
||||
so a server-side import gap surfaces as a failed TCP wait, not here.
|
||||
"""
|
||||
return subprocess.Popen(
|
||||
[sys.executable, str(script_path), str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def make_oidc_test_config(**overrides: Any) -> OIDCConfig:
|
||||
"""Build a test ``OIDCConfig`` with sensible defaults.
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b"
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"system": "Output-guard: deploy output looked clean.",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b"
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"system": "Output-guard: deploy output looked clean.",
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -876,30 +876,6 @@ def test_phase8_xss_safe_render_in_build_mcp_error_embed() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_error_button_gated_on_consent_url_not_code_alone() -> None:
|
||||
"""Review finding: the chat error card rendered a Connect / Re-consent
|
||||
button from the error CODE alone, so an oauth_obo error (consent_url=None,
|
||||
since sign-in passthrough has no per-server consent flow and /start rejects
|
||||
obo rows) produced a button that dead-ended in a 'no consent URL' toast.
|
||||
The button must render only when a valid per-server consent URL is present —
|
||||
obo errors show the card's honest detail text without a broken affordance."""
|
||||
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
start = body.index("function buildMcpErrorEmbed(")
|
||||
rest = body[start:]
|
||||
end_match = re.search(r"\n}\n", rest)
|
||||
assert end_match is not None
|
||||
fn = rest[: end_match.end()]
|
||||
# The render gate combines the category with a consent-URL presence check.
|
||||
assert "hasConsentAffordance" in fn, (
|
||||
"buildMcpErrorEmbed must gate the action button on the presence of a "
|
||||
"consent URL, not on the error category alone."
|
||||
)
|
||||
assert 'category === "actionable" && hasConsentAffordance' in fn, (
|
||||
"the button-render condition must require BOTH an actionable category "
|
||||
"and a real consent URL"
|
||||
)
|
||||
|
||||
|
||||
def test_phase8_css_classes_present_in_stylesheet() -> None:
|
||||
"""The MCP error-embed + connections classes app.js/interactive.js reference
|
||||
must keep their CSS rules (else the consent / connections UX silently loses
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestSummaryTurnProvenance:
|
||||
session._generate_title()
|
||||
|
||||
uc.assert_called_once()
|
||||
prompt = uc.call_args[0][0][-1].text
|
||||
prompt = uc.call_args[0][0][-1]["content"]
|
||||
assert COMPACTION_SUMMARY_LABEL in prompt # titled FROM the real message
|
||||
|
||||
|
||||
|
||||
@@ -685,7 +685,7 @@ class TestChunkedCompaction:
|
||||
recorded: list[int] = []
|
||||
|
||||
def fake_uc(messages, **_kwargs):
|
||||
body = messages[1].text
|
||||
body = messages[1]["content"]
|
||||
prefix = session._COMPACT_USER_PREFIX
|
||||
if body.startswith(prefix):
|
||||
body = body[len(prefix) :]
|
||||
|
||||
@@ -64,75 +64,3 @@ def test_cancel_returns_promptly() -> None:
|
||||
assert time.monotonic() - start < 1.0
|
||||
stragglers = [t for t in threading.enumerate() if t.name == "dl-cancel" and not t.daemon]
|
||||
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
|
||||
|
||||
|
||||
def test_on_abandon_fires_on_timeout_and_cancel_but_not_success() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
with pytest.raises(DeadlineExceededError):
|
||||
run_with_deadline(
|
||||
lambda: time.sleep(2.0),
|
||||
timeout=0.1,
|
||||
poll=0.05,
|
||||
thread_name="dl-abandon-t",
|
||||
on_abandon=lambda: calls.append("timeout"),
|
||||
)
|
||||
assert calls == ["timeout"]
|
||||
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
with pytest.raises(DeadlineCancelledError):
|
||||
run_with_deadline(
|
||||
lambda: time.sleep(2.0),
|
||||
timeout=10.0,
|
||||
cancel_event=cancel,
|
||||
poll=0.05,
|
||||
thread_name="dl-abandon-c",
|
||||
on_abandon=lambda: calls.append("cancel"),
|
||||
)
|
||||
assert calls == ["timeout", "cancel"]
|
||||
|
||||
result = run_with_deadline(lambda: 7, timeout=1.0, on_abandon=lambda: calls.append("no"))
|
||||
assert result == 7
|
||||
assert calls == ["timeout", "cancel"]
|
||||
|
||||
|
||||
def test_on_abandon_errors_do_not_mask_the_deadline_error() -> None:
|
||||
def _boom() -> None:
|
||||
raise RuntimeError("abort hook broke")
|
||||
|
||||
with pytest.raises(DeadlineExceededError):
|
||||
run_with_deadline(
|
||||
lambda: time.sleep(2.0),
|
||||
timeout=0.1,
|
||||
poll=0.05,
|
||||
thread_name="dl-abandon-e",
|
||||
on_abandon=_boom,
|
||||
)
|
||||
|
||||
|
||||
class TestStreamAbortRef:
|
||||
def test_abort_closes_captured_stream(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.deadline import StreamAbortRef
|
||||
|
||||
ref = StreamAbortRef()
|
||||
stream = MagicMock()
|
||||
ref.append(stream)
|
||||
stream.close.assert_not_called()
|
||||
ref.abort()
|
||||
stream.close.assert_called_once()
|
||||
|
||||
def test_late_arriving_stream_closes_on_append(self) -> None:
|
||||
# The arrival race: abort fires while the worker is still inside the
|
||||
# SDK connect — the handle must close the moment it is captured.
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.deadline import StreamAbortRef
|
||||
|
||||
ref = StreamAbortRef()
|
||||
ref.abort()
|
||||
stream = MagicMock()
|
||||
ref.append(stream)
|
||||
stream.close.assert_called_once()
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Unit tests for ``drain_stream`` — the #831 single non-streaming transport.
|
||||
|
||||
Every single-shot lane consumes ``create_streaming`` through this
|
||||
accumulator, so its semantics ARE the old ``create_completion`` contract:
|
||||
each case here pins a rule the per-adapter non-streaming methods used to
|
||||
implement independently (usage max-merge, tool-delta assembly, terminal
|
||||
provider_blocks, trailing-citation fold).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.providers import (
|
||||
StreamChunk,
|
||||
ToolCallDelta,
|
||||
UsageInfo,
|
||||
drain_stream,
|
||||
)
|
||||
from turnstone.core.providers._openai_common import RETRYABLE_ERROR_NAMES
|
||||
from turnstone.core.providers._protocol import IncompleteStreamError
|
||||
|
||||
|
||||
class TestContentAndReasoning:
|
||||
def test_joins_content_deltas_in_order(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(content_delta="Hello, "),
|
||||
StreamChunk(content_delta="world"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.content == "Hello, world"
|
||||
assert result.finish_reason == "stop"
|
||||
|
||||
def test_joins_reasoning_deltas_separately_from_content(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(reasoning_delta="think "),
|
||||
StreamChunk(reasoning_delta="hard"),
|
||||
StreamChunk(content_delta="answer"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.reasoning == "think hard"
|
||||
assert result.content == "answer"
|
||||
|
||||
def test_stream_without_finish_reason_raises_incomplete(self):
|
||||
# Complete-or-error: every adapter emits a finish reason on a
|
||||
# healthy stream, so its absence means the generation died
|
||||
# mid-response — partial text must never be stored as a complete
|
||||
# result (compaction summary, title). Typed and retryable.
|
||||
assert "IncompleteStreamError" in RETRYABLE_ERROR_NAMES
|
||||
with pytest.raises(IncompleteStreamError):
|
||||
drain_stream(iter([StreamChunk(content_delta="half a summar")]))
|
||||
|
||||
def test_empty_stream_raises_incomplete(self):
|
||||
with pytest.raises(IncompleteStreamError):
|
||||
drain_stream(iter([]))
|
||||
|
||||
|
||||
class TestToolCallAssembly:
|
||||
def test_merges_deltas_by_index_id_name_once_args_concat(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(
|
||||
tool_call_deltas=[ToolCallDelta(index=0, id="call_1", name="read_file")]
|
||||
),
|
||||
StreamChunk(
|
||||
tool_call_deltas=[ToolCallDelta(index=0, arguments_delta='{"path": ')]
|
||||
),
|
||||
StreamChunk(
|
||||
tool_call_deltas=[ToolCallDelta(index=0, arguments_delta='"x.py"}')]
|
||||
),
|
||||
StreamChunk(finish_reason="tool_calls"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.tool_calls == [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "x.py"}'},
|
||||
}
|
||||
]
|
||||
|
||||
def test_parallel_calls_ordered_by_index(self):
|
||||
# Interleaved argument deltas for two calls must not cross-contaminate,
|
||||
# and the assembled list is index-ordered regardless of arrival order.
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(tool_call_deltas=[ToolCallDelta(index=1, id="b", name="beta")]),
|
||||
StreamChunk(tool_call_deltas=[ToolCallDelta(index=0, id="a", name="alpha")]),
|
||||
StreamChunk(
|
||||
tool_call_deltas=[
|
||||
ToolCallDelta(index=0, arguments_delta="{}"),
|
||||
ToolCallDelta(index=1, arguments_delta='{"k": 1}'),
|
||||
]
|
||||
),
|
||||
StreamChunk(finish_reason="tool_calls"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert [tc["id"] for tc in result.tool_calls] == ["a", "b"]
|
||||
assert result.tool_calls[1]["function"]["arguments"] == '{"k": 1}'
|
||||
|
||||
def test_blank_id_preserved_for_downstream_repair(self):
|
||||
# Google compat can stream blank tool ids — the drain must hand them
|
||||
# through untouched so model_turn's pairwise blank-id repair sees them.
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(tool_call_deltas=[ToolCallDelta(index=0, name="f")]),
|
||||
StreamChunk(finish_reason="tool_calls"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.tool_calls[0]["id"] == ""
|
||||
|
||||
# Index-degenerate parallel-call de-fusion lives in the CHAT ADAPTER's
|
||||
# iterator (so the interactive loop is fixed too) — pinned in
|
||||
# test_providers.py::TestOpenAIProvider::
|
||||
# test_streaming_remaps_index_degenerate_parallel_calls. The drain
|
||||
# accumulates by index verbatim; adapters own index sanity.
|
||||
|
||||
|
||||
class TestUsageMerge:
|
||||
def test_anthropic_split_emission_max_merges(self):
|
||||
# message_start carries prompt tokens (completion 0); message_delta
|
||||
# carries completion tokens (prompt possibly absent → 0). Neither
|
||||
# first-wins nor last-wins sees both — the max-merge does.
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(
|
||||
usage=UsageInfo(
|
||||
prompt_tokens=120,
|
||||
completion_tokens=0,
|
||||
total_tokens=120,
|
||||
cache_read_tokens=100,
|
||||
)
|
||||
),
|
||||
StreamChunk(content_delta="hi"),
|
||||
StreamChunk(
|
||||
usage=UsageInfo(prompt_tokens=0, completion_tokens=42, total_tokens=42),
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.usage.prompt_tokens == 120
|
||||
assert result.usage.completion_tokens == 42
|
||||
assert result.usage.total_tokens == 162
|
||||
assert result.usage.cache_read_tokens == 100
|
||||
|
||||
def test_single_terminal_usage_passes_through(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(content_delta="x"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
StreamChunk(
|
||||
usage=UsageInfo(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.usage.total_tokens == 15
|
||||
|
||||
|
||||
class TestFinishAndBlocks:
|
||||
def test_finish_reason_last_non_none_wins(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(finish_reason="tool_calls"),
|
||||
StreamChunk(content_delta="tail"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.finish_reason == "stop"
|
||||
|
||||
def test_provider_blocks_taken_from_terminal_emission(self):
|
||||
# Every adapter attaches its full block list exactly once (on or
|
||||
# after the terminal chunk); replace-on-nonempty keeps the last set.
|
||||
blocks = [{"type": "thinking", "thinking": "t", "signature": "s"}]
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(content_delta="a"),
|
||||
StreamChunk(finish_reason="stop", provider_blocks=blocks),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.provider_blocks == blocks
|
||||
|
||||
|
||||
class TestInfoDelta:
|
||||
def test_mid_stream_status_pings_dropped(self):
|
||||
# "[Searching…]" style transient status — the non-streaming lane
|
||||
# never surfaced these, so the drain must not leak them into content.
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(info_delta="[Searching: quakes]"),
|
||||
StreamChunk(content_delta="answer"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.content == "answer"
|
||||
|
||||
def test_trailing_citations_fold_matches_format_citations(self):
|
||||
# The chat/responses adapters emit format_citations("", anns).strip()
|
||||
# as a final info chunk after the finish reason. Folding it back as
|
||||
# content + "\n\n" + info must byte-match the old non-streaming
|
||||
# format_citations(content, anns) append.
|
||||
from turnstone.core.providers._openai_common import format_citations
|
||||
|
||||
class _Ann:
|
||||
type = "url_citation"
|
||||
url = "https://example.com"
|
||||
title = "Example"
|
||||
url_citation = None
|
||||
|
||||
anns = [_Ann()]
|
||||
trailing = format_citations("", anns).strip()
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(content_delta="body"),
|
||||
StreamChunk(finish_reason="stop"),
|
||||
StreamChunk(info_delta=trailing),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.content == format_citations("body", anns)
|
||||
|
||||
def test_trailing_fold_with_empty_content_matches_too(self):
|
||||
result = drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(finish_reason="tool_calls"),
|
||||
StreamChunk(info_delta="Sources:\n- x"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result.content == "\n\nSources:\n- x"
|
||||
|
||||
def test_finishless_stream_raises_even_with_trailing_info(self):
|
||||
# A stream that dies after a status ping must NOT return the ping
|
||||
# as content (nor the partial body as a clean result) — the
|
||||
# complete-or-error gate turns the whole stream into a retryable
|
||||
# error instead of guessing which trailing info was a citation.
|
||||
with pytest.raises(IncompleteStreamError):
|
||||
drain_stream(
|
||||
iter(
|
||||
[
|
||||
StreamChunk(content_delta="body"),
|
||||
StreamChunk(info_delta="[Searching: kubernetes CVEs]"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestErrorPropagation:
|
||||
def test_httpx_transport_error_becomes_retryable_incomplete(self):
|
||||
# Streaming moves the body read out of the SDK's wrapped request:
|
||||
# a mid-body wire failure surfaces as a raw httpx.TransportError
|
||||
# no retry predicate recognizes. The drain re-raises it (chained,
|
||||
# message preserved) as the retryable IncompleteStreamError.
|
||||
import httpx
|
||||
|
||||
def chunks():
|
||||
yield StreamChunk(content_delta="partial")
|
||||
raise httpx.RemoteProtocolError("peer closed connection")
|
||||
|
||||
with pytest.raises(IncompleteStreamError, match="RemoteProtocolError") as excinfo:
|
||||
drain_stream(chunks())
|
||||
assert isinstance(excinfo.value.__cause__, httpx.RemoteProtocolError)
|
||||
|
||||
def test_mid_stream_exception_propagates_verbatim(self):
|
||||
# Retry/deadline/fallback policy is the caller's — the drain adds
|
||||
# no exception translation, exactly like the old transport.
|
||||
def chunks():
|
||||
yield StreamChunk(content_delta="partial")
|
||||
raise RuntimeError("upstream broke")
|
||||
|
||||
with pytest.raises(RuntimeError, match="upstream broke"):
|
||||
drain_stream(chunks())
|
||||
+12
-15
@@ -200,24 +200,21 @@ class TestFlatParamLanes:
|
||||
assert eff["xhigh"] == "xhigh"
|
||||
assert eff["max"] == "xhigh"
|
||||
|
||||
def test_openai_always_reasoning_row_snaps_without_none(self) -> None:
|
||||
"""Always-reasoning rows (gpt-5.4-pro: medium/high/xhigh) declare no
|
||||
"none" level, so the knob's off position omits the param and low
|
||||
positions snap UP onto the declared floor."""
|
||||
eff = _as_map(effort_ladder_for_model("openai", "gpt-5.4-pro", None))
|
||||
def test_openai_o3_registry_row(self) -> None:
|
||||
"""o-series (except o1-mini) accept low/medium/high; no declared
|
||||
"none" level, so the knob's off position omits the param."""
|
||||
eff = _as_map(effort_ladder_for_model("openai", "o3", None))
|
||||
assert eff["none"] == "default"
|
||||
assert eff["minimal"] == "medium"
|
||||
assert eff["minimal"] == "low"
|
||||
assert eff["medium"] == "medium"
|
||||
assert eff["max"] == "xhigh"
|
||||
assert eff["xhigh"] == eff["max"] == "high"
|
||||
|
||||
def test_openai_pro_row_wins_longest_prefix(self) -> None:
|
||||
"""gpt-5.4-pro must not prefix-fall onto the gpt-5.4 row (which
|
||||
declares "none") — the pro ladder has no off position, so the
|
||||
longest-prefix row must win or the knob would wrongly omit."""
|
||||
eff = _as_map(effort_ladder_for_model("openai", "gpt-5.4-pro", None))
|
||||
assert eff["none"] == "default"
|
||||
base = _as_map(effort_ladder_for_model("openai", "gpt-5.4", None))
|
||||
assert base["none"] == "none"
|
||||
def test_openai_codex_max_has_xhigh(self) -> None:
|
||||
"""gpt-5.1-codex-max must not prefix-fall onto the gpt-5.1 row
|
||||
(which lacks xhigh) — xhigh reaches the wire verbatim."""
|
||||
eff = _as_map(effort_ladder_for_model("openai", "gpt-5.1-codex-max", None))
|
||||
assert eff["xhigh"] == "xhigh"
|
||||
assert eff["max"] == "xhigh"
|
||||
|
||||
def test_anthropic_effort_applies_even_with_thinking_mode_none(self) -> None:
|
||||
"""output_config gates on supports_effort alone at request time."""
|
||||
|
||||
@@ -509,8 +509,8 @@ class TestExtractReasoningForHistory:
|
||||
|
||||
def test_first_block_reasoning_text_dispatches_to_openai_chat(self) -> None:
|
||||
# Phase 3 path 3: synthetic ``reasoning_text`` blocks (stamped
|
||||
# by model_turn.synth_reasoning_block for vLLM / llama.cpp /
|
||||
# Gemini-compat conversations) dispatch to
|
||||
# by ChatSession._maybe_synth_reasoning_block for vLLM /
|
||||
# llama.cpp / Gemini-compat conversations) dispatch to
|
||||
# OpenAIChatCompletionsProvider.extract_reasoning_text.
|
||||
from turnstone.core.history_decoration import extract_reasoning_for_history
|
||||
|
||||
|
||||
+65
-93
@@ -9,10 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests._session_helpers import as_stream
|
||||
from tests._session_helpers import mock_completion_result as _mock_result
|
||||
from turnstone.core.judge import IntentJudge, IntentVerdict, JudgeConfig, evaluate_heuristic
|
||||
from turnstone.core.trajectory import Role
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -33,12 +30,16 @@ def _make_mock_provider(
|
||||
caps.max_output_tokens = 4096
|
||||
provider.get_capabilities.return_value = caps
|
||||
|
||||
result = MagicMock()
|
||||
result.content = response_content
|
||||
result.tool_calls = tool_calls
|
||||
result.finish_reason = "stop"
|
||||
result.usage = None
|
||||
|
||||
if side_effect:
|
||||
provider.create_streaming.side_effect = side_effect
|
||||
provider.create_completion.side_effect = side_effect
|
||||
else:
|
||||
provider.create_streaming.return_value = as_stream(
|
||||
_mock_result(response_content, tool_calls)
|
||||
)
|
||||
provider.create_completion.return_value = result
|
||||
|
||||
provider.convert_tools.side_effect = lambda tools, **kw: tools
|
||||
|
||||
@@ -255,8 +256,9 @@ class TestErrorHandling:
|
||||
def test_empty_content_returns_none(self):
|
||||
"""Provider returns empty content, no tool calls."""
|
||||
provider = _make_mock_provider(response_content="")
|
||||
result_mock = _mock_result("", None)
|
||||
provider.create_streaming.return_value = as_stream(result_mock)
|
||||
result_mock = provider.create_completion.return_value
|
||||
result_mock.tool_calls = None
|
||||
result_mock.content = ""
|
||||
|
||||
judge = _make_judge(provider)
|
||||
result = judge._evaluate_single(
|
||||
@@ -270,9 +272,10 @@ class TestErrorHandling:
|
||||
def test_empty_content_length_stop_no_retry(self):
|
||||
"""When finish_reason is 'length', don't retry — return None immediately."""
|
||||
provider = _make_mock_provider(response_content="")
|
||||
result_mock = _mock_result("", None)
|
||||
result_mock = provider.create_completion.return_value
|
||||
result_mock.tool_calls = None
|
||||
result_mock.content = ""
|
||||
result_mock.finish_reason = "length"
|
||||
provider.create_streaming.return_value = as_stream(result_mock)
|
||||
|
||||
judge = _make_judge(provider)
|
||||
result = judge._evaluate_single(
|
||||
@@ -283,7 +286,7 @@ class TestErrorHandling:
|
||||
)
|
||||
assert result is None
|
||||
# Should have been called exactly once — no retries
|
||||
assert provider.create_streaming.call_count == 1
|
||||
assert provider.create_completion.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -329,7 +332,7 @@ class TestCancelEventSemantics:
|
||||
assert all(v.tier == "llm_fallback" for v in results)
|
||||
assert all("cancelled" in v.reasoning for v in results)
|
||||
# ...and no inference was spent after the abort signal.
|
||||
assert provider.create_streaming.call_count == 0
|
||||
assert provider.create_completion.call_count == 0
|
||||
|
||||
def test_unfired_event_runs_every_item_with_default_config(self):
|
||||
"""The run-to-completion contract: with cancel_on_approval=False
|
||||
@@ -352,7 +355,7 @@ class TestCancelEventSemantics:
|
||||
|
||||
assert [v.call_id for v in results] == ["tc_0", "tc_1", "tc_2"]
|
||||
assert all(v.tier == "llm" for v in results)
|
||||
assert provider.create_streaming.call_count == 3
|
||||
assert provider.create_completion.call_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -372,23 +375,24 @@ class TestMultiTurnToolUse:
|
||||
provider.convert_tools.side_effect = lambda tools, **kw: tools
|
||||
|
||||
# Turn 1: tool call
|
||||
turn1 = _mock_result(
|
||||
"",
|
||||
[
|
||||
{
|
||||
"id": "tc_judge_1",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": "/nonexistent/file.txt"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
turn1 = MagicMock()
|
||||
turn1.content = ""
|
||||
turn1.tool_calls = [
|
||||
{
|
||||
"id": "tc_judge_1",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": "/nonexistent/file.txt"}),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Turn 2: verdict
|
||||
turn2 = _mock_result(_good_verdict_json())
|
||||
turn2 = MagicMock()
|
||||
turn2.content = _good_verdict_json()
|
||||
turn2.tool_calls = None
|
||||
|
||||
provider.create_streaming.side_effect = [as_stream(turn1), as_stream(turn2)]
|
||||
provider.create_completion.side_effect = [turn1, turn2]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
verdict = judge._evaluate_single(
|
||||
@@ -399,7 +403,7 @@ class TestMultiTurnToolUse:
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
assert provider.create_streaming.call_count == 2
|
||||
assert provider.create_completion.call_count == 2
|
||||
|
||||
def test_max_turns_reached(self):
|
||||
"""Provider keeps requesting tools — stops at _JUDGE_MAX_TURNS."""
|
||||
@@ -412,29 +416,30 @@ class TestMultiTurnToolUse:
|
||||
provider.convert_tools.side_effect = lambda tools, **kw: tools
|
||||
|
||||
# Every turn returns a tool call
|
||||
tool_result = _mock_result(
|
||||
"",
|
||||
[
|
||||
{
|
||||
"id": "tc_loop",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": "/tmp/x"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
tool_result = MagicMock()
|
||||
tool_result.content = ""
|
||||
tool_result.tool_calls = [
|
||||
{
|
||||
"id": "tc_loop",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": "/tmp/x"}),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Last turn (no tools param) returns text content
|
||||
final = _mock_result(_good_verdict_json())
|
||||
final = MagicMock()
|
||||
final.content = _good_verdict_json()
|
||||
final.tool_calls = None
|
||||
|
||||
# Turns 0-3: tool_call; turn 4 (last, tools=None): final verdict
|
||||
provider.create_streaming.side_effect = [
|
||||
as_stream(tool_result),
|
||||
as_stream(tool_result),
|
||||
as_stream(tool_result),
|
||||
as_stream(tool_result),
|
||||
as_stream(final),
|
||||
provider.create_completion.side_effect = [
|
||||
tool_result,
|
||||
tool_result,
|
||||
tool_result,
|
||||
tool_result,
|
||||
final,
|
||||
]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
@@ -444,8 +449,8 @@ class TestMultiTurnToolUse:
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
# Should have called create_streaming exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_streaming.call_count == 5
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -463,12 +468,12 @@ class TestContextPreparation:
|
||||
|
||||
result = judge._prepare_context(_make_item(), messages)
|
||||
|
||||
# Should have a system Turn + single user Turn with the transcript
|
||||
# Should have system message + single user message with transcript
|
||||
assert len(result) == 2
|
||||
assert result[0].role is Role.SYSTEM
|
||||
assert result[1].role is Role.USER
|
||||
assert "pending human approval" in result[1].text
|
||||
assert "Conversation context:" in result[1].text
|
||||
assert result[0]["role"] == "system"
|
||||
assert result[1]["role"] == "user"
|
||||
assert "pending human approval" in result[1]["content"]
|
||||
assert "Conversation context:" in result[1]["content"]
|
||||
|
||||
|
||||
class TestArgBudget:
|
||||
@@ -536,7 +541,7 @@ class TestArgBudget:
|
||||
)
|
||||
# Each included history turn renders one "ASSISTANT:" line; the
|
||||
# big-argument call fits strictly fewer of them.
|
||||
assert big[1].text.count("ASSISTANT:") < small[1].text.count("ASSISTANT:")
|
||||
assert big[1]["content"].count("ASSISTANT:") < small[1]["content"].count("ASSISTANT:")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -894,21 +899,14 @@ class TestModelAliasResolution:
|
||||
cfg = MagicMock()
|
||||
cfg.context_window = 50_000
|
||||
cfg.capabilities = capabilities if capabilities is not None else {}
|
||||
# Judges inherit the alias's configured temperature (house rule: no
|
||||
# code pins) — give the mock config a real value so the lane
|
||||
# resolution path is exercised, not a MagicMock leak.
|
||||
cfg.temperature = 0.3
|
||||
registry.has_alias.side_effect = lambda a: a == alias
|
||||
registry.resolve.return_value = (alias_client, underlying_model, cfg)
|
||||
# The unified lane resolver (model_turn.resolve_capabilities) fetches
|
||||
# the config itself rather than taking resolve()'s copy.
|
||||
registry.get_config.return_value = cfg
|
||||
registry.get_provider.return_value = alias_provider
|
||||
return registry
|
||||
|
||||
def test_alias_capabilities_merged_and_threaded_to_wire(self):
|
||||
"""#823: a judge alias's model-definition ``capabilities`` are merged
|
||||
onto the provider base AND passed to ``create_streaming`` — the same
|
||||
onto the provider base AND passed to ``create_completion`` — the same
|
||||
contract as the session / utility / sub-agent lanes. Without threading,
|
||||
operator overrides (effort passthrough, tool support) were silently
|
||||
ignored on judge calls; deleting ``capabilities=self._capabilities`` from
|
||||
@@ -944,38 +942,12 @@ class TestModelAliasResolution:
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
passed = alias_provider.create_streaming.call_args.kwargs["capabilities"]
|
||||
passed = alias_provider.create_completion.call_args.kwargs["capabilities"]
|
||||
assert passed is judge._capabilities
|
||||
# House rule: the judge pins no temperature — the wire carries the
|
||||
# alias's configured value, inherited through the lane.
|
||||
assert alias_provider.create_streaming.call_args.kwargs["temperature"] == 0.3
|
||||
|
||||
def test_constructor_resolves_from_one_config_fetch(self):
|
||||
"""The constructor consumes the ModelConfig that registry.resolve()
|
||||
already returned (the ``cfg=`` pass-through) — ZERO independent
|
||||
get_config fetches, so a registry hot-reload between two lookups
|
||||
cannot bind the resolved client/window to a different capability
|
||||
generation."""
|
||||
alias_provider = _make_mock_provider(response_content=_good_verdict_json())
|
||||
registry = self._make_alias_registry(
|
||||
"judge-mini",
|
||||
alias_provider,
|
||||
MagicMock(base_url="https://a/v1", api_key="k"),
|
||||
"local-9b",
|
||||
)
|
||||
IntentJudge(
|
||||
config=JudgeConfig(enabled=True, model="judge-mini"),
|
||||
session_provider=_make_mock_provider(),
|
||||
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
|
||||
session_model="session-model",
|
||||
session_capabilities=MagicMock(context_window=100_000),
|
||||
model_registry=registry,
|
||||
)
|
||||
assert registry.get_config.call_count == 0
|
||||
|
||||
def test_fallback_threads_session_capabilities_to_wire(self):
|
||||
"""No judge alias → the judge inherits the session model AND the
|
||||
session's resolved capabilities, threaded to ``create_streaming``."""
|
||||
session's resolved capabilities, threaded to ``create_completion``."""
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
sess_caps = ModelCapabilities(context_window=54_321, effort_passthrough=True)
|
||||
@@ -995,7 +967,7 @@ class TestModelAliasResolution:
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert provider.create_streaming.call_args.kwargs["capabilities"] is sess_caps
|
||||
assert provider.create_completion.call_args.kwargs["capabilities"] is sess_caps
|
||||
|
||||
def test_alias_uses_registry_provider_not_session_provider(self):
|
||||
"""Judge with model=alias should resolve via registry — provider, client,
|
||||
|
||||
@@ -4,13 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
@@ -194,22 +191,6 @@ def _install_token_store(app, storage) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _enabled_oidc(profile: str = "entra") -> SimpleNamespace:
|
||||
"""An OIDC config that satisfies the oauth_obo write-time gate.
|
||||
|
||||
oauth_obo mints from the user's captured sign-in, so the write choke point
|
||||
requires OIDC enabled + a valid ``obo_grant_profile``. Tests exercising obo
|
||||
writes install one of these; the finding-C tests install a disabled /
|
||||
bad-profile config instead to assert the rejection.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
enabled=True,
|
||||
issuer="https://idp.example.com",
|
||||
obo_grant_profile=profile,
|
||||
capture_user_credential=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(storage):
|
||||
"""TestClient wired to console admin MCP endpoints with full permissions."""
|
||||
@@ -219,10 +200,6 @@ def client(storage):
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
_install_token_store(app, storage)
|
||||
# Default: OIDC enabled under the entra profile so oauth_obo writes pass the
|
||||
# requirement gate. Per-test overrides install rfc8693 / disabled / bad
|
||||
# profile as needed.
|
||||
app.state.oidc_config = _enabled_oidc("entra")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -721,677 +698,6 @@ class TestUpdateMcpServer:
|
||||
).scalar()
|
||||
assert count_after == 0, "URL change must purge per-user tokens"
|
||||
|
||||
def test_admin_create_oauth_obo_requires_audience(self, client):
|
||||
"""#551: an oauth_obo row without oauth_audience is rejected at the
|
||||
write choke point (the mint engine hard-requires it)."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-no-aud",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "oauth_audience" in r.json()["error"]
|
||||
|
||||
def test_admin_create_oauth_obo_without_token_store_returns_503(self, client_no_token_store):
|
||||
"""#551: creating an oauth_obo row with no encryption key is rejected —
|
||||
accepting it would SystemExit the whole cluster at the next boot."""
|
||||
r = client_no_token_store.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-no-key",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 503, r.text
|
||||
assert "mcp_token_encryption_key" in r.json()["error"]
|
||||
|
||||
def test_admin_create_oauth_obo_happy_path(self, client):
|
||||
"""A well-formed oauth_obo row persists with its audience intact."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-ok",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["auth_type"] == "oauth_obo"
|
||||
assert data["oauth_audience"] == "api://mcp-a"
|
||||
|
||||
def test_same_type_static_edit_cannot_inject_oauth_columns(self, client, storage):
|
||||
"""Review finding (SECURITY): the OAuth columns must be a pure function
|
||||
of the target auth_type on EVERY write, not just a flip. A same-type
|
||||
static edit that injects oauth_authorization_server_url must be scrubbed
|
||||
to NULL — otherwise a later flip to oauth_user (which legitimately uses
|
||||
that column) would inherit the attacker AS URL and redirect every
|
||||
consenting user's OAuth traffic."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "static-inject",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "static",
|
||||
},
|
||||
)
|
||||
sid = r.json()["server_id"]
|
||||
# Same-type static edit trying to smuggle an oauth_user-only column.
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={
|
||||
"auth_type": "static",
|
||||
"oauth_authorization_server_url": "https://attacker.example",
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
row = storage.get_mcp_server(sid)
|
||||
assert (row.get("oauth_authorization_server_url") or None) is None
|
||||
|
||||
def test_flip_to_oauth_user_does_not_inherit_stale_as_url(self, client, storage):
|
||||
"""Review finding (SECURITY): flipping a non-oauth_user row to oauth_user
|
||||
must recompute the oauth_user-only columns from the request, never
|
||||
inherit a stale/injected authorization_server_url left on the pre-flip
|
||||
row (defence-in-depth for a value that predates the unconditional
|
||||
scrub)."""
|
||||
# Plant a static row that already carries a stale AS URL directly in DB.
|
||||
storage.create_mcp_server(
|
||||
server_id="stale-asurl-id",
|
||||
name="stale-asurl",
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.com/sse",
|
||||
auth_type="static",
|
||||
oauth_authorization_server_url="https://attacker.example",
|
||||
)
|
||||
# Flip to oauth_user WITHOUT supplying an AS URL in the body.
|
||||
r = client.put(
|
||||
"/v1/api/admin/mcp-servers/stale-asurl-id",
|
||||
json={"auth_type": "oauth_user", "oauth_client_id": "cli_x"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
row = storage.get_mcp_server("stale-asurl-id")
|
||||
assert (row.get("oauth_authorization_server_url") or None) is None
|
||||
assert row.get("oauth_client_id") == "cli_x"
|
||||
|
||||
def test_create_obo_rejected_when_capture_disabled(self, client):
|
||||
"""Review finding: oauth_obo mints from the user's CAPTURED sign-in
|
||||
credential, so with capture_user_credential off, login persists nothing
|
||||
and every dispatch returns kind='missing' with an unsatisfiable remedy.
|
||||
Reject at write time."""
|
||||
client.app.state.oidc_config = SimpleNamespace(
|
||||
enabled=True,
|
||||
issuer="https://idp.example.com",
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=False,
|
||||
)
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-no-capture",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "capture_user_credential" in r.json()["error"]
|
||||
|
||||
def test_create_obo_rejected_when_oidc_disabled(self, client):
|
||||
"""Review finding: oauth_obo mints from the user's OIDC sign-in, so an
|
||||
install with OIDC disabled can NEVER mint. Reject at write time (a
|
||||
permanent misconfig otherwise surfaces per-dispatch as a retryable
|
||||
transient that never heals)."""
|
||||
client.app.state.oidc_config = SimpleNamespace(enabled=False, obo_grant_profile="entra")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-no-oidc",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "OIDC" in r.json()["error"]
|
||||
|
||||
def test_obo_editable_when_oidc_discovery_transiently_failed(self, client, storage):
|
||||
"""Review finding: the console never runs runtime OIDC rediscovery, so a
|
||||
transient discovery failure at console boot (enabled=False,
|
||||
discovery_retryable=True) must NOT make oauth_obo servers un-editable /
|
||||
un-disable-able. OIDC is still CONFIGURED (issuer set) — the write gate
|
||||
accepts a discovery_retryable config; it rejects only a genuinely absent
|
||||
OIDC (neither flag set)."""
|
||||
# Seed an obo row (created while OIDC was healthy).
|
||||
storage.create_mcp_server(
|
||||
server_id="obo-retry-id",
|
||||
name="obo-retry",
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-a",
|
||||
)
|
||||
# Console process booted while the IdP was briefly unreachable.
|
||||
client.app.state.oidc_config = SimpleNamespace(
|
||||
enabled=False,
|
||||
issuer="https://idp.example.com",
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=True,
|
||||
discovery_retryable=True,
|
||||
)
|
||||
# Disabling the misbehaving obo server must succeed, not 400.
|
||||
r = client.put(
|
||||
"/v1/api/admin/mcp-servers/obo-retry-id",
|
||||
json={"enabled": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["enabled"] is False
|
||||
|
||||
# Even with OIDC fully operator-disabled (neither flag set), a same-type
|
||||
# edit of the EXISTING obo server is still allowed — the deployment
|
||||
# checks only fire on create / flip-into-obo, so an operator is never
|
||||
# locked out of disabling or editing a server (review finding R8-1).
|
||||
client.app.state.oidc_config = SimpleNamespace(
|
||||
enabled=False,
|
||||
issuer="",
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=True,
|
||||
discovery_retryable=False,
|
||||
)
|
||||
r2 = client.put(
|
||||
"/v1/api/admin/mcp-servers/obo-retry-id",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
|
||||
def test_create_new_obo_still_rejected_when_oidc_operator_disabled(self, client):
|
||||
"""The deployment gate still fires for a NEW obo enablement: creating a
|
||||
fresh oauth_obo server (or flipping one into obo) while OIDC is fully
|
||||
operator-disabled is rejected — only same-type edits of an existing obo
|
||||
server skip the deployment checks."""
|
||||
client.app.state.oidc_config = SimpleNamespace(
|
||||
enabled=False,
|
||||
issuer="",
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=True,
|
||||
discovery_retryable=False,
|
||||
)
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-new-nooidc",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "OIDC" in r.json()["error"]
|
||||
|
||||
def test_create_obo_rejected_on_invalid_grant_profile(self, client):
|
||||
"""Review finding: a typo'd deployment obo_grant_profile leaves the mint
|
||||
leg unresolved (obo_misconfigured per dispatch), so reject it at the
|
||||
write choke point rather than as a runtime transient."""
|
||||
client.app.state.oidc_config = _enabled_oidc("bogus-profile")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-bad-profile",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "obo_grant_profile" in r.json()["error"]
|
||||
|
||||
def test_flip_user_to_obo_via_api_without_audience_is_rejected(self, client, storage):
|
||||
"""Review finding: a flip into obo must NOT carry the oauth_user-era
|
||||
oauth_audience (a resource indicator, conventionally the MCP URL) — it
|
||||
would pass the audience-required check and then fail every mint. An API
|
||||
PUT of just {auth_type: oauth_obo} recomputes audience from the body
|
||||
(absent → NULL) and is rejected loudly, not saved with the stale value."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "flip-api-noaud",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_client_id": "cli_x",
|
||||
"oauth_audience": "https://mcp.example.com/sse", # resource indicator
|
||||
},
|
||||
)
|
||||
sid = r.json()["server_id"]
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"auth_type": "oauth_obo"}, # no audience in body
|
||||
)
|
||||
assert r2.status_code == 400, r2.text
|
||||
assert "oauth_audience" in r2.json()["error"]
|
||||
|
||||
def test_update_flip_oauth_user_to_obo_keeps_audience_and_purges_tokens(self, client, storage):
|
||||
"""#551 (findings 10344 + 10326): flipping oauth_user→oauth_obo must NOT
|
||||
null oauth_audience (the mint engine needs it), and MUST purge the old
|
||||
per-user consent-token rows (they carry per-server-AS refresh tokens that
|
||||
the mint cache invariant forbids)."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import mcp_user_tokens
|
||||
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "flip-to-obo",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_client_id": "cli_x",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
sid = r.json()["server_id"]
|
||||
|
||||
with storage._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(mcp_user_tokens),
|
||||
{
|
||||
"user_id": "u1",
|
||||
"server_name": "flip-to-obo",
|
||||
"access_token_ct": b"\x00ct-a",
|
||||
"refresh_token_ct": b"\x00ct-r",
|
||||
"expires_at": "2026-12-31T00:00:00",
|
||||
"scopes": "openid",
|
||||
"as_issuer": "https://auth.example.com",
|
||||
"audience": "api://mcp-a",
|
||||
"created": "2026-05-04T11:00:00",
|
||||
"last_refreshed": None,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"auth_type": "oauth_obo", "oauth_audience": "api://mcp-a"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
data = r2.json()
|
||||
assert data["auth_type"] == "oauth_obo"
|
||||
assert data["oauth_audience"] == "api://mcp-a" # NOT nulled
|
||||
# The oauth_user-only client_id is cleared.
|
||||
assert data["oauth_client_id"] in (None, "")
|
||||
# Old consent-token rows purged.
|
||||
with storage._engine.connect() as conn:
|
||||
remaining = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(mcp_user_tokens)
|
||||
.where(mcp_user_tokens.c.server_name == "flip-to-obo")
|
||||
).scalar()
|
||||
assert remaining == 0, "oauth_user→oauth_obo flip must purge stale per-user rows"
|
||||
|
||||
def test_flip_to_obo_clears_stale_oauth_user_scopes(self, client):
|
||||
"""#551 follow-up: flipping oauth_user→oauth_obo without supplying new
|
||||
scopes must CLEAR the old AS-consent scopes — otherwise the rfc8693 mint
|
||||
leg would send them and loop on invalid_scope."""
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "flip-scopes",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_scopes": "openid profile offline_access",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
sid = r.json()["server_id"]
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"auth_type": "oauth_obo", "oauth_audience": "api://mcp-a"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["oauth_scopes"] in (None, "") # stale scopes cleared
|
||||
|
||||
def _create_oauth_user_row_with_scopes(self, client, name: str) -> str:
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": name,
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_scopes": "openid profile offline_access",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
},
|
||||
)
|
||||
sid: str = r.json()["server_id"]
|
||||
return sid
|
||||
|
||||
def test_flip_to_obo_under_entra_rejects_explicit_scopes_but_omit_clears(self, client):
|
||||
"""Redesign: a flip into obo recomputes scopes from the body (never
|
||||
carries the old row's value across the semantic boundary). Under entra,
|
||||
an EXPLICIT non-empty scopes value is rejected 400 — an honest visible
|
||||
snap rather than a silent drop — while the console-realistic flip (the
|
||||
form clears the semantic field on the auth-type switch, so scopes is
|
||||
omitted/empty) succeeds with scopes NULL."""
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
# Explicit non-empty scopes on the flip → 400 (they can't apply on entra).
|
||||
sid = self._create_oauth_user_row_with_scopes(client, "flip-resend-entra")
|
||||
rejected = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
"oauth_scopes": "openid profile offline_access",
|
||||
},
|
||||
)
|
||||
assert rejected.status_code == 400
|
||||
assert "entra" in rejected.json()["error"]
|
||||
# The realistic flip (scopes field cleared → omitted) succeeds, NULL scopes.
|
||||
sid2 = self._create_oauth_user_row_with_scopes(client, "flip-omit-entra")
|
||||
ok = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid2}",
|
||||
json={"auth_type": "oauth_obo", "oauth_audience": "api://mcp-a"},
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["oauth_scopes"] in (None, "") # not carried across the flip
|
||||
|
||||
def test_flip_to_obo_resent_scopes_kept_under_rfc8693(self, client):
|
||||
"""Review finding: under rfc8693 oauth_scopes IS the token-exchange
|
||||
scope — an operator flipping to obo and keeping the same value (the
|
||||
Keycloak optional-audience scope can legitimately equal the old
|
||||
consent scope string) must NOT have it silently nulled; only an
|
||||
omitted field clears (previous test)."""
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._create_oauth_user_row_with_scopes(client, "flip-resend-rfc")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
"oauth_scopes": "openid profile offline_access",
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["oauth_scopes"] == "openid profile offline_access"
|
||||
|
||||
def test_entra_obo_row_with_scopes_stays_editable(self, client, storage):
|
||||
"""Review finding: a pre-existing oauth_obo row carrying scopes under the
|
||||
entra profile must stay editable — an unrelated PUT that doesn't touch
|
||||
scopes must NOT be rejected (the entra-scope reject fires only on a real
|
||||
scopes write)."""
|
||||
# Seed an obo row that already has scopes (e.g. created under rfc8693).
|
||||
storage.create_mcp_server(
|
||||
server_id="entra-edit-id",
|
||||
name="entra-edit",
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-a",
|
||||
oauth_scopes="custom.scope",
|
||||
)
|
||||
# Now the deployment is on the entra profile.
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
|
||||
# An unrelated maintenance edit (disable) — does NOT touch scopes.
|
||||
r = client.put(
|
||||
"/v1/api/admin/mcp-servers/entra-edit-id",
|
||||
json={"enabled": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text # NOT a 400 lockout
|
||||
|
||||
# But actively SETTING scopes under entra is still rejected.
|
||||
r2 = client.put(
|
||||
"/v1/api/admin/mcp-servers/entra-edit-id",
|
||||
json={"oauth_scopes": "another.scope"},
|
||||
)
|
||||
assert r2.status_code == 400, r2.text
|
||||
assert "oauth_scopes" in r2.json()["error"]
|
||||
|
||||
def test_obo_server_reports_consented_users_count_for_flush_button(self, client, storage):
|
||||
"""Review finding: obo rows must report consented_users_count (users with a
|
||||
minted cache row) so the console flush-cache action (gated on count>0)
|
||||
renders — previously only oauth_user rows got the count."""
|
||||
storage.create_mcp_server(
|
||||
server_id="obo-count-id",
|
||||
name="obo-count",
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-a",
|
||||
)
|
||||
for i in range(2):
|
||||
storage.create_mcp_user_token(
|
||||
f"u{i}",
|
||||
"obo-count",
|
||||
access_token_ct=b"\x00ct",
|
||||
refresh_token_ct=None,
|
||||
expires_at="2026-12-31T00:00:00",
|
||||
scopes=None,
|
||||
as_issuer="https://idp.test",
|
||||
audience="api://mcp-a",
|
||||
)
|
||||
|
||||
# The list handler fans out node status; no cluster nodes in this test.
|
||||
client.app.state.collector = SimpleNamespace(get_all_nodes=lambda: [])
|
||||
client.app.state.proxy_client = MagicMock()
|
||||
r = client.get("/v1/api/admin/mcp-servers")
|
||||
assert r.status_code == 200, r.text
|
||||
row = next(s for s in r.json()["servers"] if s["name"] == "obo-count")
|
||||
assert row["consented_users_count"] == 2
|
||||
|
||||
def test_obo_audience_change_purges_cached_tokens(self, client, storage):
|
||||
"""#551 follow-up: changing an obo row's oauth_audience purges cached
|
||||
tokens minted for the OLD audience (they are audience-bound)."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import mcp_user_tokens
|
||||
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "aud-change",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://old-aud",
|
||||
},
|
||||
)
|
||||
sid = r.json()["server_id"]
|
||||
with storage._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(mcp_user_tokens),
|
||||
{
|
||||
"user_id": "u1",
|
||||
"server_name": "aud-change",
|
||||
"access_token_ct": b"\x00ct",
|
||||
"refresh_token_ct": None,
|
||||
"expires_at": "2026-12-31T00:00:00",
|
||||
"scopes": None,
|
||||
"as_issuer": "https://idp.test",
|
||||
"audience": "api://old-aud",
|
||||
"created": "2026-05-04T11:00:00",
|
||||
"last_refreshed": None,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"oauth_audience": "api://new-aud"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
with storage._engine.connect() as conn:
|
||||
remaining = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(mcp_user_tokens)
|
||||
.where(mcp_user_tokens.c.server_name == "aud-change")
|
||||
).scalar()
|
||||
assert remaining == 0, "audience change must purge old-audience cache rows"
|
||||
|
||||
def _seed_obo_row_with_cache(self, client, storage, *, name: str, scopes: str | None) -> str:
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import mcp_user_tokens
|
||||
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": name,
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://aud",
|
||||
**({"oauth_scopes": scopes} if scopes else {}),
|
||||
},
|
||||
)
|
||||
sid: str = r.json()["server_id"]
|
||||
with storage._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(mcp_user_tokens),
|
||||
{
|
||||
"user_id": "u1",
|
||||
"server_name": name,
|
||||
"access_token_ct": b"\x00ct",
|
||||
"refresh_token_ct": None,
|
||||
"expires_at": "2026-12-31T00:00:00",
|
||||
"scopes": scopes,
|
||||
"as_issuer": "https://idp.test",
|
||||
"audience": "api://aud",
|
||||
"created": "2026-05-04T11:00:00",
|
||||
"last_refreshed": None,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
return sid
|
||||
|
||||
def _count_cache_rows(self, storage, name: str) -> int:
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import mcp_user_tokens
|
||||
|
||||
with storage._engine.connect() as conn:
|
||||
count = conn.execute(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(mcp_user_tokens)
|
||||
.where(mcp_user_tokens.c.server_name == name)
|
||||
).scalar()
|
||||
return int(count or 0)
|
||||
|
||||
def test_obo_scope_change_purges_cached_tokens(self, client, storage):
|
||||
"""Review finding: under rfc8693 the exchange scope shapes the minted
|
||||
bearer's privileges exactly like the audience does — narrowing
|
||||
oauth_scopes must purge cached rows or the reduction silently waits
|
||||
out the token TTL (inconsistent with the audience purge)."""
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(
|
||||
client, storage, name="scope-change", scopes="api.read api.write"
|
||||
)
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"oauth_scopes": "api.read"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert self._count_cache_rows(storage, "scope-change") == 0, (
|
||||
"scope change must purge cache rows minted with the old scopes"
|
||||
)
|
||||
|
||||
def test_obo_scope_noop_resend_does_not_purge(self, client, storage):
|
||||
"""Review finding companion: the admin form re-submits the pre-filled
|
||||
scopes on every save — an EQUAL value is normalized out of the update
|
||||
and must not flush every user's minted tokens."""
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(client, storage, name="scope-noop", scopes="api.read")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"oauth_scopes": "api.read", "enabled": True},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert self._count_cache_rows(storage, "scope-noop") == 1, (
|
||||
"a no-op scopes re-send must not purge the mint cache"
|
||||
)
|
||||
|
||||
def test_flip_obo_to_oauth_user_clears_obo_audience_and_scopes(self, client, storage):
|
||||
"""Review finding: the obo-era oauth_audience is an IdP-side app
|
||||
identifier, not the resource indicator oauth_user sends to its AS —
|
||||
carried over, every consent yields a wrong-resource token that 401s
|
||||
with no visible cause. The flip must clear it (and the rfc8693
|
||||
exchange scopes) unless the request explicitly sets new values."""
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(client, storage, name="flip-back", scopes="api.read")
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"auth_type": "oauth_user", "oauth_client_id": "client-xyz"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
data = r2.json()
|
||||
assert data["auth_type"] == "oauth_user"
|
||||
assert data["oauth_audience"] in (None, ""), "obo app-id audience must not carry over"
|
||||
assert data["oauth_scopes"] in (None, ""), "rfc8693 exchange scopes must not carry over"
|
||||
# The flip is an auth-model change → mint-cache rows purged too.
|
||||
assert self._count_cache_rows(storage, "flip-back") == 0
|
||||
|
||||
def test_entra_obo_equal_scope_resend_is_accepted(self, client, storage):
|
||||
"""Review finding: the admin form always re-submits the pre-filled
|
||||
oauth_scopes, so a same-type edit of an entra-profile obo row carrying
|
||||
legacy scopes must accept an EQUAL value (normalized to a no-op)
|
||||
instead of 400ing — only a genuine scope CHANGE is rejected."""
|
||||
# The legacy-scoped entra row arises from a deployment profile switch:
|
||||
# the row is created while the profile is rfc8693 (scopes accepted),
|
||||
# then the deployment flips to entra.
|
||||
client.app.state.oidc_config = _enabled_oidc("rfc8693")
|
||||
sid = self._seed_obo_row_with_cache(
|
||||
client, storage, name="entra-resend", scopes="legacy.scope"
|
||||
)
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
# Equal re-send + unrelated change → accepted, scopes untouched.
|
||||
r2 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"oauth_scopes": "legacy.scope", "enabled": False},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["oauth_scopes"] == "legacy.scope"
|
||||
# A genuine CHANGE to non-empty scopes still 400s under entra.
|
||||
r3 = client.put(
|
||||
f"/v1/api/admin/mcp-servers/{sid}",
|
||||
json={"oauth_scopes": "new.scope"},
|
||||
)
|
||||
assert r3.status_code == 400
|
||||
assert "entra" in r3.json()["error"]
|
||||
|
||||
def test_create_obo_rejects_scopes_under_entra_profile(self, client):
|
||||
"""#551 follow-up: oauth_scopes is meaningless for the entra grant leg
|
||||
(it mints <audience>/.default), so the write path rejects it rather than
|
||||
silently ignoring it at mint time."""
|
||||
client.app.state.oidc_config = _enabled_oidc("entra")
|
||||
r = client.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "obo-entra-scopes",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_obo",
|
||||
"oauth_audience": "api://mcp-a",
|
||||
"oauth_scopes": "custom.scope",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert "oauth_scopes" in r.json()["error"]
|
||||
|
||||
def test_update_invalid_auth_type(self, client):
|
||||
created = _create_server(client, name="bad-auth-update")
|
||||
sid = created["server_id"]
|
||||
@@ -1936,28 +1242,6 @@ class TestNotifyNodesMcpReload:
|
||||
assert "error" in result["n1"]
|
||||
assert "refused" in result["n1"]["error"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_records_error_on_non_2xx(self):
|
||||
"""A node replying non-2xx (e.g. 503) is recorded as an error, not
|
||||
counted as a reached node — raise_for_status() routes the status into
|
||||
the error path so a stale node trips the 'did not reach' WARNING, and
|
||||
the (unused) response body is never consulted."""
|
||||
http_req = httpx.Request("POST", "http://n1:8000/x")
|
||||
resp = MagicMock()
|
||||
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"503", request=http_req, response=httpx.Response(503, request=http_req)
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.post.return_value = resp
|
||||
req = _fake_request(
|
||||
{"node_id": "n1", "server_url": "http://n1:8000"},
|
||||
proxy_client=client,
|
||||
)
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert "n1" in result
|
||||
assert "error" in result["n1"]
|
||||
resp.json.assert_not_called()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_cluster(self):
|
||||
req = _fake_request()
|
||||
@@ -2048,141 +1332,6 @@ class TestAdminMcpReloadEndpoint:
|
||||
assert data["results"]["n1"] == {"reloaded": 2}
|
||||
assert "error" in data["results"]["n2"]
|
||||
|
||||
def test_reload_fails_loud_without_fanout_infra(self, storage: SQLiteBackend) -> None:
|
||||
"""F4 guard: the operator reload drains + reports, so with storage and
|
||||
admin.mcp permission but no collector/proxy_client on app.state it must
|
||||
fail loudly (500) — never silently 200 with empty results (which a
|
||||
re-introduced None-guard would do)."""
|
||||
app = Starlette(
|
||||
routes=_ROUTES,
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
# Deliberately omit app.state.collector / proxy_client.
|
||||
c = TestClient(app, raise_server_exceptions=False)
|
||||
r = c.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 500
|
||||
|
||||
|
||||
class TestMcpWriteAutoReload:
|
||||
"""create / update / delete schedule a node reload (after the 200) so a
|
||||
write reaches nodes — and active per-user pools re-prime — without a
|
||||
separate /reload. The fan-out rides only the success response; an error
|
||||
return schedules nothing. (The error paths tested here return before the
|
||||
row is written; a post-write secret-apply failure is a separate pre-existing
|
||||
partial-write path, not exercised here.)"""
|
||||
|
||||
def test_create_notifies_nodes(self, client: TestClient) -> None:
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
_create_server(client, name="auto-reload-create")
|
||||
notify.assert_awaited_once()
|
||||
|
||||
def test_update_notifies_nodes(self, client: TestClient) -> None:
|
||||
sid = _create_server(client, name="auto-reload-update")["server_id"]
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
r = client.put(f"/v1/api/admin/mcp-servers/{sid}", json={"enabled": False})
|
||||
assert r.status_code == 200
|
||||
notify.assert_awaited_once()
|
||||
|
||||
def test_delete_notifies_nodes(self, client: TestClient) -> None:
|
||||
sid = _create_server(client, name="auto-reload-delete")["server_id"]
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
r = client.delete(f"/v1/api/admin/mcp-servers/{sid}")
|
||||
assert r.status_code == 200
|
||||
notify.assert_awaited_once()
|
||||
|
||||
def test_delete_does_not_notify_on_missing_server(self, client: TestClient) -> None:
|
||||
"""A 404 (server not found) returns before the success response, so no
|
||||
node reload is scheduled — the fan-out rides only the success path."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
r = client.delete("/v1/api/admin/mcp-servers/does-not-exist")
|
||||
assert r.status_code == 404
|
||||
notify.assert_not_awaited()
|
||||
|
||||
def test_update_does_not_notify_on_missing_server(self, client: TestClient) -> None:
|
||||
"""A 404 on update likewise schedules no reload."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
r = client.put("/v1/api/admin/mcp-servers/does-not-exist", json={"enabled": False})
|
||||
assert r.status_code == 404
|
||||
notify.assert_not_awaited()
|
||||
|
||||
def test_create_does_not_notify_on_secret_store_503(
|
||||
self, client_no_token_store: TestClient
|
||||
) -> None:
|
||||
"""A create that 503s on the OAuth-secret token-store gate returns an
|
||||
error before any write — so no reload is scheduled."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
) as notify:
|
||||
r = client_no_token_store.post(
|
||||
"/v1/api/admin/mcp-servers",
|
||||
json={
|
||||
"name": "no-notify-503",
|
||||
"transport": "streamable-http",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"auth_type": "oauth_user",
|
||||
"oauth_client_id": "cli_abc",
|
||||
"oauth_client_secret": "secret-value",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 503, r.text
|
||||
notify.assert_not_awaited()
|
||||
|
||||
def test_write_warns_when_reload_reaches_no_node(
|
||||
self, client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A background fan-out that leaves nodes unreached is surfaced at
|
||||
WARNING (not swallowed at debug) — operators need a signal the cluster
|
||||
catalog may be stale, since there is no periodic node reconcile."""
|
||||
with (
|
||||
patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"n1": {"error": "Connection refused"}},
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
_create_server(client, name="warn-on-stale")
|
||||
assert any("did not reach" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_write_warns_when_reload_fan_out_raises(
|
||||
self, client: TestClient, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A systemic fan-out fault (the whole reload raises) is logged at
|
||||
WARNING rather than lost, for the same reason."""
|
||||
with (
|
||||
patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("collector exploded"),
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
_create_server(client, name="warn-on-fault")
|
||||
assert any("fan-out failed after admin write" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node reload endpoint: POST /v1/api/_internal/mcp-reload
|
||||
@@ -2578,59 +1727,6 @@ class TestInternalMcpRefreshOneEndpoint:
|
||||
assert "url" not in data["server"]
|
||||
assert data["server"]["circuit_open"] is True
|
||||
|
||||
def test_refresh_one_skipped_returns_202(self, node_app_factory) -> None:
|
||||
# A busy-lock skip never ran the refresh — it must NOT be reported
|
||||
# as 200 "ok" (the caller would believe the catalog is current).
|
||||
# 202 Accepted + status "skipped": the health-tick retry will run it.
|
||||
mgr = MagicMock()
|
||||
mgr.refresh_sync.return_value = {"srv": None}
|
||||
# The endpoint reads the outcome from the manager accessor, not the
|
||||
# stripped status (the public projection whitelists it out).
|
||||
mgr.last_refresh_outcome.return_value = "skipped"
|
||||
mgr.get_server_status.return_value = {
|
||||
"connected": True,
|
||||
"tools": 3,
|
||||
"resources": 0,
|
||||
"prompts": 1,
|
||||
"error": "",
|
||||
"transport": "stdio",
|
||||
"command": "secret",
|
||||
"url": "",
|
||||
"circuit_open": False,
|
||||
"consecutive_failures": 0,
|
||||
}
|
||||
c = node_app_factory(mgr)
|
||||
r = c.post("/v1/api/_internal/mcp-refresh/srv")
|
||||
assert r.status_code == 202
|
||||
data = r.json()
|
||||
assert data["status"] == "skipped"
|
||||
assert "command" not in data["server"] # stripped
|
||||
mgr.last_refresh_outcome.assert_called_with("srv")
|
||||
|
||||
def test_refresh_one_error_beats_skip_returns_500(self, node_app_factory) -> None:
|
||||
# A skip on a server that ALSO carries a live error pill must
|
||||
# surface as 500, not a benign 202 — a status-code-keyed caller
|
||||
# would otherwise treat a genuinely erroring server as healthy.
|
||||
mgr = MagicMock()
|
||||
mgr.refresh_sync.return_value = {"srv": None}
|
||||
mgr.last_refresh_outcome.return_value = "skipped"
|
||||
mgr.get_server_status.return_value = {
|
||||
"connected": False,
|
||||
"tools": 0,
|
||||
"resources": 0,
|
||||
"prompts": 0,
|
||||
"error": "Refresh failed: connection refused",
|
||||
"transport": "stdio",
|
||||
"command": "secret",
|
||||
"url": "",
|
||||
"circuit_open": True,
|
||||
"consecutive_failures": 5,
|
||||
}
|
||||
c = node_app_factory(mgr)
|
||||
r = c.post("/v1/api/_internal/mcp-refresh/srv")
|
||||
assert r.status_code == 500, "a live error must win over the skip"
|
||||
assert r.json()["status"] == "error"
|
||||
|
||||
def test_refresh_one_invalid_name_returns_400(self, node_app_factory) -> None:
|
||||
# sec-4: name validation symmetric with console side.
|
||||
mgr = MagicMock()
|
||||
|
||||
@@ -157,44 +157,6 @@ def test_400_on_invalid_server_name(storage: SQLiteBackend) -> None:
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_200_on_oauth_obo_server(storage: SQLiteBackend) -> None:
|
||||
"""#551: bulk-revoke serves oauth_obo too (it populates mcp_user_tokens with
|
||||
minted cache rows) — the documented remediation for stale rows after an
|
||||
oauth_user→oauth_obo flip."""
|
||||
storage.create_mcp_server(
|
||||
server_id="srv-obo-id",
|
||||
name="srv-obo",
|
||||
transport="streamable-http",
|
||||
url="https://example.com/mcp",
|
||||
auth_type="oauth_obo",
|
||||
)
|
||||
_seed_user_tokens(storage, "srv-obo", users=2)
|
||||
|
||||
client = TestClient(_build_app(storage))
|
||||
resp = client.post("/v1/api/admin/mcp-servers/srv-obo/bulk-revoke")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["rows_deleted"] == 2
|
||||
assert storage.count_mcp_consented_users_by_server("srv-obo") == 0
|
||||
# Honest semantics: obo is a cache flush (re-mints), NOT a consent revoke.
|
||||
assert body["effect"] == "cache_flush_remints"
|
||||
events = storage.list_audit_events(action="mcp_server.oauth.obo_cache_flushed")
|
||||
assert len(events) == 1
|
||||
# The oauth_user revoke event must NOT be emitted for an obo flush.
|
||||
assert storage.list_audit_events(action="mcp_server.oauth.bulk_revoked") == []
|
||||
|
||||
|
||||
def test_oauth_user_bulk_revoke_keeps_revoke_semantics(storage: SQLiteBackend) -> None:
|
||||
"""The oauth_user path is unchanged: durable revoke event + effect."""
|
||||
_seed_oauth_server(storage)
|
||||
_seed_user_tokens(storage, "srv-oauth", users=1)
|
||||
client = TestClient(_build_app(storage))
|
||||
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["effect"] == "revoked_until_reconsent"
|
||||
assert len(storage.list_audit_events(action="mcp_server.oauth.bulk_revoked")) == 1
|
||||
|
||||
|
||||
def test_200_on_success_with_no_consented_users(storage: SQLiteBackend) -> None:
|
||||
_seed_oauth_server(storage)
|
||||
client = TestClient(_build_app(storage))
|
||||
|
||||
+88
-1302
File diff suppressed because it is too large
Load Diff
@@ -97,12 +97,10 @@ def test_every_user_actionable_structured_error_passes_consent_url() -> None:
|
||||
def test_audit_finds_all_known_user_actionable_sites() -> None:
|
||||
"""Lock the count so accidental deletions are caught.
|
||||
|
||||
There are 5 user-actionable ``_structured_error`` call sites today:
|
||||
1 in ``_pool_lookup_error`` (the ``_pool_lookup_verdict`` split
|
||||
collapsed the former 3 consent-required renderings into one site —
|
||||
the per-kind branches now select only the DETAIL copy), 3 in the
|
||||
post-retry-failed branches, and 1 in ``_handle_auth_403``'s
|
||||
insufficient-scope branch. If a new exec path is added the count
|
||||
There are 13 user-actionable ``_structured_error`` call sites today
|
||||
(4 each in the tool / resource / prompt token-classify branches +
|
||||
3 in the post-retry-failed branches + 1 in ``_handle_auth_403``'s
|
||||
insufficient-scope branch). If a new exec path is added the count
|
||||
can rise; if a branch is removed the count can fall — both are
|
||||
fine, but require an intentional bump of this number to confirm
|
||||
the change went through review.
|
||||
@@ -112,8 +110,8 @@ def test_audit_finds_all_known_user_actionable_sites() -> None:
|
||||
user_actionable_count = sum(
|
||||
1 for _, blk in blocks if any(f'code="{code}"' in blk for code in _USER_ACTIONABLE_CODES)
|
||||
)
|
||||
assert user_actionable_count == 5, (
|
||||
f"Expected 5 user-actionable _structured_error sites, got "
|
||||
assert user_actionable_count == 13, (
|
||||
f"Expected 13 user-actionable _structured_error sites, got "
|
||||
f"{user_actionable_count}. If this is intentional, bump the "
|
||||
f"expected count and document why in the commit message."
|
||||
)
|
||||
|
||||
@@ -402,66 +402,3 @@ class TestReconcileSync:
|
||||
assert result == {"added": [], "removed": [], "updated": []}
|
||||
# Existing server untouched
|
||||
assert "srv" in mgr._server_configs
|
||||
|
||||
def test_reprimes_active_users_on_new_pool_server(self) -> None:
|
||||
"""A newly-appeared oauth_obo server re-primes active sessions' users so
|
||||
a mid-session registration surfaces without a fresh workstream."""
|
||||
mgr = MCPClientManager({})
|
||||
primed: list[str] = []
|
||||
mgr.prime_user_pools = lambda uid: primed.append(uid) # type: ignore[method-assign]
|
||||
mgr.add_listener(lambda: None, user_id="u1")
|
||||
mgr.add_listener(lambda: None, user_id="u2")
|
||||
mgr.add_listener(lambda: None, user_id=None) # global/admin — skipped
|
||||
row = _db_row("azobo", transport="streamable-http", command="", url="https://azobo:8443/")
|
||||
row["auth_type"] = "oauth_obo"
|
||||
mgr.reconcile_sync(_FakeStorage([row]))
|
||||
assert sorted(primed) == ["u1", "u2"]
|
||||
assert mgr._obo_server_names == {"azobo"}
|
||||
|
||||
def test_no_reprime_when_pool_server_already_known(self) -> None:
|
||||
"""Reconcile that reveals no NEW pool server does not re-prime — avoids
|
||||
re-warming every active session on every unrelated reload."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._obo_server_names = {"azobo"} # already known before this reconcile
|
||||
primed: list[str] = []
|
||||
mgr.prime_user_pools = lambda uid: primed.append(uid) # type: ignore[method-assign]
|
||||
mgr.add_listener(lambda: None, user_id="u1")
|
||||
row = _db_row("azobo", transport="streamable-http", command="", url="https://azobo:8443/")
|
||||
row["auth_type"] = "oauth_obo"
|
||||
mgr.reconcile_sync(_FakeStorage([row]))
|
||||
assert primed == []
|
||||
|
||||
def test_reprimes_on_pool_auth_type_flip(self) -> None:
|
||||
"""A server MIGRATED in place oauth_user -> oauth_obo (same name) re-primes
|
||||
active users — a name-only diff would see the same name on both sides and
|
||||
miss the flip."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._oauth_user_server_names = {"srv"} # previously oauth_user
|
||||
primed: list[str] = []
|
||||
mgr.prime_user_pools = lambda uid: primed.append(uid) # type: ignore[method-assign]
|
||||
mgr.add_listener(lambda: None, user_id="u1")
|
||||
row = _db_row("srv", transport="streamable-http", command="", url="https://srv:8443/")
|
||||
row["auth_type"] = "oauth_obo" # flipped in place
|
||||
mgr.reconcile_sync(_FakeStorage([row]))
|
||||
assert primed == ["u1"]
|
||||
assert mgr._obo_server_names == {"srv"}
|
||||
assert mgr._oauth_user_server_names == set()
|
||||
|
||||
def test_reprime_survives_prime_exception(self) -> None:
|
||||
"""One user's prime scheduling failure must not abort the loop or propagate
|
||||
out of reconcile_sync (which would 500 the reload endpoint)."""
|
||||
mgr = MCPClientManager({})
|
||||
primed: list[str] = []
|
||||
|
||||
def _prime(uid: str) -> None:
|
||||
if uid == "boom-user":
|
||||
raise RuntimeError("scheduling blew up")
|
||||
primed.append(uid)
|
||||
|
||||
mgr.prime_user_pools = _prime # type: ignore[method-assign]
|
||||
mgr.add_listener(lambda: None, user_id="boom-user")
|
||||
mgr.add_listener(lambda: None, user_id="ok-user")
|
||||
row = _db_row("azobo", transport="streamable-http", command="", url="https://azobo:8443/")
|
||||
row["auth_type"] = "oauth_obo"
|
||||
mgr.reconcile_sync(_FakeStorage([row])) # must not raise
|
||||
assert "ok-user" in primed # the other user was still primed
|
||||
|
||||
@@ -19,6 +19,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import gc
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -26,11 +29,9 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import _free_port, _popen_mcp_server, _wait_session_live, _wait_tcp_ready
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_SRC = textwrap.dedent(
|
||||
@@ -56,6 +57,33 @@ SERVER_SRC = textwrap.dedent(
|
||||
).lstrip()
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_tcp_ready(port: int, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
state = mgr._static_servers.get(name)
|
||||
if state is not None and state.session is not None:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
async def _armed_scope_count() -> int:
|
||||
"""Armed scopes hosted on THIS (the mcp) loop — mirrors the production
|
||||
disarm sweep's scoping, and keeps an unrelated scope on another loop that
|
||||
@@ -107,7 +135,11 @@ class TestFlakyServerNoSpin:
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_HEALTH_PING_TIMEOUT_S", 1.5)
|
||||
|
||||
def _spawn_server(*, initial: bool = False) -> subprocess.Popen[bytes]:
|
||||
proc = _popen_mcp_server(script, port)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(script), str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if not _wait_tcp_ready(port, 10.0):
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Live push-refresh smoke test: a real ``tools/list_changed`` lands, no wedge.
|
||||
|
||||
End-to-end regression for #839: the static-path notification handler used to
|
||||
await its catalog refresh inline in the SDK's receive loop, but the refresh
|
||||
issues a request on the SAME session — a request whose response only that
|
||||
(now parked) receive loop could route. The refresh never completed, the
|
||||
receive loop wedged permanently, and every call on the shared per-node
|
||||
session stalled behind it; the only "recovery" was the health loop's ping
|
||||
timeout tearing the transport down.
|
||||
|
||||
A real streamable-http MCP server (FastMCP, subprocess) registers an extra
|
||||
tool at runtime inside a tool call and pushes ``notifications/tools/
|
||||
list_changed`` on the live session — so the notification and the call result
|
||||
are multiplexed on the real SDK receive loop, exactly the production shape.
|
||||
Pass criteria are discriminating on purpose:
|
||||
|
||||
* the TRIGGERING ``call_tool`` returns promptly — on the pre-fix code its
|
||||
result could never route past the parked receive loop, so this call is
|
||||
itself the deadlock repro;
|
||||
* the pushed catalog change lands on the SAME session object (health loop
|
||||
slowed to keep teardown/reconnect out of the picture) — the push did the
|
||||
work, not a rebuild;
|
||||
* the newly pushed tool DISPATCHES — the merged tool map was rebuilt, and
|
||||
the receive loop is still routing responses afterwards.
|
||||
|
||||
Self-contained (spawns its own server; no LLM backend, no network beyond
|
||||
127.0.0.1) — deliberately NOT marked ``live``, mirroring
|
||||
``test_mcp_live_flaky_server.py``. Wall clock ~5s.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import (
|
||||
_free_port,
|
||||
_poll_until,
|
||||
_popen_mcp_server,
|
||||
_wait_session_live,
|
||||
_wait_tcp_ready,
|
||||
)
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_SRC = textwrap.dedent(
|
||||
'''
|
||||
"""Streamable-http MCP server that grows a tool at runtime (#839 repro)."""
|
||||
import sys
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
port = int(sys.argv[1])
|
||||
mcp = FastMCP("push-victim", host="127.0.0.1", port=port)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ping_me(x: int) -> int:
|
||||
"""Return x + 1."""
|
||||
return x + 1
|
||||
|
||||
|
||||
def extra_tool(y: int) -> int:
|
||||
"""Return y * 2."""
|
||||
return y * 2
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def register_extra(ctx: Context) -> str:
|
||||
"""Register extra_tool, then push tools/list_changed on this session."""
|
||||
mcp.add_tool(extra_tool)
|
||||
await ctx.session.send_tool_list_changed()
|
||||
return "registered"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
'''
|
||||
).lstrip()
|
||||
|
||||
|
||||
def _wait_tool_visible(mgr: MCPClientManager, server: str, tool: str, timeout: float) -> bool:
|
||||
"""Poll the per-server catalog for *tool* — the push refresh landing."""
|
||||
|
||||
def _visible() -> bool:
|
||||
state = mgr._static_servers.get(server)
|
||||
return state is not None and any(t["function"]["name"] == tool for t in state.tools)
|
||||
|
||||
return _poll_until(_visible, timeout)
|
||||
|
||||
|
||||
class TestPushRefreshNoDeadlock:
|
||||
def test_list_changed_push_refreshes_without_teardown(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The subprocess runs sys.executable, so importability HERE is a
|
||||
# faithful proxy for the server side. Environment gaps skip, not fail.
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
script = tmp_path / "push_srv.py"
|
||||
script.write_text(SERVER_SRC)
|
||||
port = _free_port()
|
||||
|
||||
# Bound connect/discovery/refresh phases for a unit-test budget, but
|
||||
# SLOW the health loop right down: pre-fix, its ping-timeout teardown
|
||||
# was the accidental recovery path, and this test must prove the push
|
||||
# itself does the work on the ORIGINAL session.
|
||||
monkeypatch.setattr(MCPClientManager, "_CONNECT_TIMEOUT", 5)
|
||||
monkeypatch.setattr(MCPClientManager, "_TCP_PROBE_TIMEOUT", 1)
|
||||
|
||||
proc: subprocess.Popen[bytes] | None = None
|
||||
mgr: MCPClientManager | None = None
|
||||
try:
|
||||
proc = _popen_mcp_server(script, port)
|
||||
if not _wait_tcp_ready(port, 10.0):
|
||||
pytest.skip("push-refresh server subprocess did not come up")
|
||||
with patch(
|
||||
"turnstone.core.mcp_client.load_config",
|
||||
return_value={"static_health_check_seconds": 30},
|
||||
):
|
||||
mgr = MCPClientManager(
|
||||
{"push": {"type": "http", "url": f"http://127.0.0.1:{port}/mcp"}}
|
||||
)
|
||||
mgr.start()
|
||||
assert _wait_session_live(mgr, "push", 8.0), "initial connect failed"
|
||||
state = mgr._static_servers["push"]
|
||||
session_before = state.session
|
||||
assert not any(t["function"]["name"] == "mcp__push__extra_tool" for t in state.tools), (
|
||||
"extra_tool must not exist before the push"
|
||||
)
|
||||
|
||||
# THE repro: the server pushes list_changed while this call is in
|
||||
# flight, so its result and the notification share the receive
|
||||
# loop. Pre-fix, the inline-await handler parked that loop and
|
||||
# this call never returned.
|
||||
out = mgr.call_tool_sync("mcp__push__register_extra", {}, timeout=10)
|
||||
assert "registered" in out
|
||||
|
||||
# The push-driven refresh completes on its own — no teardown.
|
||||
assert _wait_tool_visible(mgr, "push", "mcp__push__extra_tool", 8.0), (
|
||||
"pushed tools/list_changed never refreshed the catalog"
|
||||
)
|
||||
assert mgr._static_servers["push"].session is session_before, (
|
||||
"catalog arrived via teardown/reconnect, not via the push refresh"
|
||||
)
|
||||
|
||||
# The merged map rebuilt AND the receive loop still routes:
|
||||
# the brand-new tool dispatches end-to-end.
|
||||
out = mgr.call_tool_sync("mcp__push__extra_tool", {"y": 21}, timeout=10)
|
||||
assert "42" in out
|
||||
finally:
|
||||
if mgr is not None:
|
||||
mgr.shutdown()
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
@@ -353,33 +353,6 @@ class TestListConnections:
|
||||
):
|
||||
assert forbidden not in row, f"secret field {forbidden!r} leaked in {row!r}"
|
||||
|
||||
def test_list_connections_hides_obo_mint_cache_rows(
|
||||
self, storage: SQLiteBackend, http_client_mock: MagicMock
|
||||
) -> None:
|
||||
"""Review finding: obo cache rows in the connections list offered a
|
||||
"Disconnect" that silently undid itself (session-start priming
|
||||
re-mints from the captured credential). The list shows only rows the
|
||||
user can actually revoke — obo mint-cache rows are hidden."""
|
||||
token_store = _make_token_store(storage)
|
||||
_seed_oauth_user_server(storage)
|
||||
_seed_user_token(token_store)
|
||||
storage.create_mcp_server(
|
||||
server_id="srv-obo-id",
|
||||
name="srv-obo",
|
||||
transport="streamable-http",
|
||||
url="https://mcp-obo.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-obo",
|
||||
)
|
||||
_seed_user_token(token_store, server_name="srv-obo", refresh_token=None)
|
||||
|
||||
app = _build_app(storage=storage, http_client=http_client_mock, token_store=token_store)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/v1/api/mcp/oauth/connections")
|
||||
assert resp.status_code == 200
|
||||
names = [c["server_name"] for c in resp.json()["connections"]]
|
||||
assert names == ["srv-oauth"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /connections/{server_name}
|
||||
@@ -387,31 +360,6 @@ class TestListConnections:
|
||||
|
||||
|
||||
class TestRevokeConnection:
|
||||
def test_revoke_connection_obo_server_409_and_keeps_row(
|
||||
self, storage: SQLiteBackend, http_client_mock: MagicMock
|
||||
) -> None:
|
||||
"""Review finding: DELETE on an obo server returned 204, audited
|
||||
token_revoked, and then priming silently re-minted from the surviving
|
||||
captured credential — a disconnect that undoes itself. The endpoint
|
||||
refuses honestly (409) and leaves the mint-cache row untouched."""
|
||||
token_store = _make_token_store(storage)
|
||||
storage.create_mcp_server(
|
||||
server_id="srv-obo-id",
|
||||
name="srv-obo",
|
||||
transport="streamable-http",
|
||||
url="https://mcp-obo.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-obo",
|
||||
)
|
||||
_seed_user_token(token_store, server_name="srv-obo", refresh_token=None)
|
||||
|
||||
app = _build_app(storage=storage, http_client=http_client_mock, token_store=token_store)
|
||||
with TestClient(app) as client:
|
||||
resp = client.delete("/v1/api/mcp/oauth/connections/srv-obo")
|
||||
assert resp.status_code == 409
|
||||
assert "sign-in" in resp.json()["error"].lower()
|
||||
assert token_store.get_user_token("user-1", "srv-obo") is not None
|
||||
|
||||
def test_revoke_connection_unauthenticated_401(
|
||||
self, storage: SQLiteBackend, http_client_mock: MagicMock
|
||||
) -> None:
|
||||
@@ -804,18 +752,12 @@ class TestEvictUserSession:
|
||||
mgr._loop = loop # type: ignore[attr-defined]
|
||||
mgr._user_pool_entries = {} # type: ignore[attr-defined]
|
||||
mgr._last_pool_notification_refresh = {} # type: ignore[attr-defined]
|
||||
mgr._background_tasks = set() # type: ignore[attr-defined]
|
||||
evicted: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_evict(key: tuple[str, str], **_kwargs: object) -> None:
|
||||
def _fake_evict(key: tuple[str, str]) -> None:
|
||||
evicted.append(key)
|
||||
|
||||
# The revoke entry point must take the LOCKED drop-catalog
|
||||
# path — the user asked for the disconnect, so their live
|
||||
# sessions see the tools leave (unlike dispatch-failure
|
||||
# eviction, #836), serialized against an in-flight connect
|
||||
# so a completing discovery can't resurrect the catalog.
|
||||
mgr._drop_catalog_locked = _fake_evict # type: ignore[method-assign]
|
||||
mgr._evict_session = _fake_evict # type: ignore[method-assign]
|
||||
|
||||
# Run the dispatch on a separate thread so the loop can drain.
|
||||
import threading
|
||||
|
||||
@@ -232,35 +232,6 @@ class TestRefreshFailureClassification:
|
||||
# Token survives a transient failure — no cluster-wide revoke; self-heals.
|
||||
assert state.mcp_token_store.get_user_token("user-1", "srv-oauth") is not None
|
||||
|
||||
def test_oversized_error_body_stays_transient_for_oauth_user(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Review finding: routing refresh_token() through the shared
|
||||
_hardened_token_post must NOT change the oauth_user oversized-error
|
||||
behavior. On main an over-cap error body was TRANSIENT (default class,
|
||||
token kept, retryable forever); the OBO-only status-based escalation must
|
||||
not leak onto oauth_user, or a large upstream error could escalate a
|
||||
pre-existing consent to an unexpected re-consent. oauth_user keeps
|
||||
TRANSIENT; the token survives."""
|
||||
_seed_server(storage)
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_mk_response(200, _good_as_metadata_doc()))
|
||||
# 401 with an error body over the 64KB cap.
|
||||
client.post = AsyncMock(
|
||||
return_value=_mk_response(401, {"error": "invalid_grant", "pad": "x" * (70 * 1024)})
|
||||
)
|
||||
state = _make_app_state(storage, http_client=client)
|
||||
_seed_token(state, expires_in_seconds=-1000)
|
||||
|
||||
result = self._lookup(state)
|
||||
|
||||
assert result.kind == "refresh_failed_transient"
|
||||
# Kept (TRANSIENT), not revoked — and the ambiguous streak did not advance.
|
||||
assert state.mcp_token_store.get_user_token("user-1", "srv-oauth") is not None
|
||||
from turnstone.core.mcp_oauth import _refresh_backoff_state
|
||||
|
||||
assert _refresh_backoff_state(state, "user-1", "srv-oauth").ambiguous_streak == 0
|
||||
|
||||
def test_permanent_invalid_grant_revokes(self, storage: SQLiteBackend) -> None:
|
||||
"""Contrast: 400 invalid_grant IS permanent — deletion is correct and the
|
||||
eventual fix MUST preserve it."""
|
||||
@@ -276,32 +247,6 @@ class TestRefreshFailureClassification:
|
||||
assert result.kind == "refresh_failed"
|
||||
assert state.mcp_token_store.get_user_token("user-1", "srv-oauth") is None
|
||||
|
||||
def test_permanent_revoke_audits_even_when_row_concurrently_deleted(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""Review finding: gating the token_revoked audit on delete-returned-True
|
||||
(the obo spam fix) must NOT suppress the oauth_user audit when a
|
||||
concurrent admin/user revoke deletes the row first. For oauth_user a
|
||||
refresh failure means a grant EXISTED (a real revocation), so the audit
|
||||
fires even on an empty delete — an operator's SIEM must not miss it."""
|
||||
from unittest.mock import patch
|
||||
|
||||
_seed_server(storage)
|
||||
client = MagicMock(spec=httpx.AsyncClient)
|
||||
client.get = AsyncMock(return_value=_mk_response(200, _good_as_metadata_doc()))
|
||||
client.post = AsyncMock(return_value=_mk_response(400, {"error": "invalid_grant"}))
|
||||
state = _make_app_state(storage, http_client=client)
|
||||
_seed_token(state, expires_in_seconds=-1000)
|
||||
|
||||
# Simulate a concurrent external revoke: the dispatch-side delete finds
|
||||
# the row already gone (returns False).
|
||||
with patch.object(state.mcp_token_store, "delete_user_token", return_value=False):
|
||||
result = self._lookup(state)
|
||||
|
||||
assert result.kind == "refresh_failed"
|
||||
events = storage.list_audit_events(action="mcp_server.oauth.token_revoked")
|
||||
assert len(events) == 1 # audited despite the empty delete
|
||||
|
||||
def test_400_invalid_client_keeps_token(self, storage: SQLiteBackend) -> None:
|
||||
"""A 400 ``invalid_client`` is operator-fixable, NOT a dead grant: keep
|
||||
the token. Pins the discriminator on the *error code*, not the 4xx
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ Validates protocol additions backing the dashboard pending-consent badge:
|
||||
- ``delete_mcp_pending_consent`` — single-row clear
|
||||
- ``delete_all_mcp_pending_consent_by_user`` — bulk clear
|
||||
- ``count_mcp_consented_users_by_server`` — admin status pill
|
||||
- ``any_user_scoped_mcp_servers`` — install-level gate
|
||||
- ``any_oauth_user_mcp_servers`` — install-level gate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -204,10 +204,10 @@ class TestCountConsentedUsersByServer:
|
||||
|
||||
|
||||
class TestInstallGate:
|
||||
def test_any_user_scoped_returns_false_on_empty(self, backend) -> None:
|
||||
assert backend.any_user_scoped_mcp_servers() is False
|
||||
def test_any_oauth_user_returns_false_on_empty(self, backend) -> None:
|
||||
assert backend.any_oauth_user_mcp_servers() is False
|
||||
|
||||
def test_any_user_scoped_ignores_static_rows(self, backend) -> None:
|
||||
def test_any_oauth_user_ignores_static_rows(self, backend) -> None:
|
||||
backend.create_mcp_server(
|
||||
server_id="srv-1",
|
||||
name="static-only",
|
||||
@@ -221,9 +221,9 @@ class TestInstallGate:
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
assert backend.any_user_scoped_mcp_servers() is False
|
||||
assert backend.any_oauth_user_mcp_servers() is False
|
||||
|
||||
def test_any_user_scoped_returns_true_for_oauth_user(self, backend) -> None:
|
||||
def test_any_oauth_user_returns_true_when_one_exists(self, backend) -> None:
|
||||
backend.create_mcp_server(
|
||||
server_id="srv-2",
|
||||
name="oauth-srv",
|
||||
@@ -238,40 +238,4 @@ class TestInstallGate:
|
||||
created_by="admin",
|
||||
)
|
||||
backend.update_mcp_server("srv-2", auth_type="oauth_user")
|
||||
assert backend.any_user_scoped_mcp_servers() is True
|
||||
|
||||
def test_any_user_scoped_true_for_obo_only_install(self, backend) -> None:
|
||||
"""#551: the pending-consent badge gate must fire for an oauth_obo-only
|
||||
install — obo dispatch writes pending rows, so short-circuiting to
|
||||
{pending: 0} would hide the re-login affordance."""
|
||||
backend.create_mcp_server(
|
||||
server_id="srv-obo",
|
||||
name="obo-only",
|
||||
transport="streamable-http",
|
||||
command="",
|
||||
args="[]",
|
||||
url="https://example.com",
|
||||
headers="{}",
|
||||
env="{}",
|
||||
auto_approve=False,
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
backend.update_mcp_server("srv-obo", auth_type="oauth_obo")
|
||||
assert backend.any_user_scoped_mcp_servers() is True
|
||||
|
||||
def test_any_user_scoped_false_on_static_only(self, backend) -> None:
|
||||
backend.create_mcp_server(
|
||||
server_id="srv-s",
|
||||
name="static-only",
|
||||
transport="streamable-http",
|
||||
command="",
|
||||
args="[]",
|
||||
url="https://example.com",
|
||||
headers="{}",
|
||||
env="{}",
|
||||
auto_approve=False,
|
||||
enabled=True,
|
||||
created_by="admin",
|
||||
)
|
||||
assert backend.any_user_scoped_mcp_servers() is False
|
||||
assert backend.any_oauth_user_mcp_servers() is True
|
||||
|
||||
@@ -34,7 +34,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import _run_on_loop, make_mcp_token_cipher, stop_loop_thread
|
||||
from tests.conftest import make_mcp_token_cipher, stop_loop_thread
|
||||
from turnstone.core.mcp_client import (
|
||||
MCPClientManager,
|
||||
_AuthCapture,
|
||||
@@ -134,6 +134,11 @@ def running_loop_mgr():
|
||||
stop_loop_thread(loop, thread)
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify_failure with capture vs legacy fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -370,10 +370,6 @@ class TestPoolTransportOwnerLifecycle:
|
||||
assert owner.done()
|
||||
assert entry.session is None # evicted by the done-callback
|
||||
assert entry.owner_task is None
|
||||
# Third session-drop site of the bearer-clearing sweep: the entry
|
||||
# may now cool indefinitely, so the dead plaintext bearer copy
|
||||
# must not cool with it.
|
||||
assert entry.bound_token is None
|
||||
assert key in mgr._user_pool_entries # entry kept
|
||||
assert entry.tools == [
|
||||
{"name": "mcp__pool-srv__ping", "server": "pool-srv"}
|
||||
|
||||
+67
-319
@@ -25,7 +25,6 @@ from unittest.mock import MagicMock
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import _run_on_loop
|
||||
from turnstone.core.mcp_client import (
|
||||
MCPClientManager,
|
||||
PoolEntryState,
|
||||
@@ -65,6 +64,11 @@ def running_loop_mgr() -> Any:
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 10) -> Any:
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
|
||||
def _build_mock_transport_factory(
|
||||
mgr: MCPClientManager,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -426,76 +430,17 @@ def test_pool_tool_visibility_user_isolation(
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__shared_tool", user_id="user-3") is False
|
||||
|
||||
|
||||
def test_evict_session_keeps_catalog_and_fires_no_listener(
|
||||
def test_eviction_drops_catalog_and_fires_listener(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_evict_session`` (dispatch-failure eviction: 401 / 403 /
|
||||
transport blip) drops ONLY the session — the catalog and per-user
|
||||
maps stay, no listener fires, and ``is_mcp_tool`` keeps resolving
|
||||
the name (#836).
|
||||
"""``_evict_session`` clears ``entry.tools``, rebuilds the user's
|
||||
map (drops the now-empty entry), and fires user + admin listeners.
|
||||
|
||||
Clearing the catalog here silently removed the server's tools from
|
||||
the user's live sessions on the first failed dispatch: the maps
|
||||
rebuilt empty, the session-side ``is_mcp_tool`` gate closed, and
|
||||
with no re-prime path the tools never came back — which also made
|
||||
the breaker's half-open recovery and the consent / step-up cards
|
||||
unreachable.
|
||||
|
||||
Verified by restoring the old catalog-cleanup block in
|
||||
``_evict_session`` (``evict.tools = None`` + rebuild + notify):
|
||||
this test fails because the listener fires AND ``is_mcp_tool``
|
||||
flips to False.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
list_tools_response=_list_tools_payload([_tool_spec("do_thing")]),
|
||||
)
|
||||
_build_mock_transport_factory(mgr, monkeypatch, handler)
|
||||
_patch_tcp_probe(mgr, monkeypatch)
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
|
||||
user_calls = [0]
|
||||
admin_calls = [0]
|
||||
|
||||
def _user_cb() -> None:
|
||||
user_calls[0] += 1
|
||||
|
||||
def _admin_cb() -> None:
|
||||
admin_calls[0] += 1
|
||||
|
||||
mgr.add_listener(_user_cb, user_id="user-1")
|
||||
mgr.add_listener(_admin_cb) # admin / None
|
||||
|
||||
mgr._evict_session(("user-1", "pool-srv"))
|
||||
|
||||
# Session dropped; catalog RETAINED; dead bearer copy cleared.
|
||||
entry = mgr._user_pool_entries[("user-1", "pool-srv")]
|
||||
assert entry.session is None
|
||||
assert entry.bound_token is None
|
||||
assert entry.tools is not None
|
||||
# User map intact — the live session's merged tool list is untouched.
|
||||
assert "user-1" in mgr._user_tool_map
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
# Nothing changed catalog-wise → NO fan-out.
|
||||
assert user_calls[0] == 0, f"user-keyed listener fired {user_calls[0]} times; expected 0"
|
||||
assert admin_calls[0] == 0, f"admin listener fired {admin_calls[0]} times; expected 0"
|
||||
|
||||
|
||||
def test_evict_user_session_drops_catalog_and_fires_listener(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_evict_session_drop_catalog`` (the explicit-revocation flavor
|
||||
behind ``evict_user_session``) clears ``entry.tools``, rebuilds the
|
||||
user's map (drops the now-empty entry), and fires user + admin
|
||||
listeners — the user asked for the disconnect, so their live
|
||||
sessions SHOULD see the tools leave.
|
||||
|
||||
Verified by reverting the catalog-cleanup block in
|
||||
``_evict_session_drop_catalog``: the test fails because the
|
||||
Verified by reverting the catalog-cleanup block in ``_evict_session``
|
||||
(drop the ``evict.tools = None`` / ``_rebuild_user_tool_map`` /
|
||||
``_notify_user_tool_listeners`` calls): the test fails because the
|
||||
listener never fires AND ``is_mcp_tool`` keeps returning True for
|
||||
the now-revoked tool.
|
||||
the now-evicted tool.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
@@ -508,7 +453,7 @@ def test_evict_user_session_drops_catalog_and_fires_listener(
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
|
||||
# Register one user-keyed and one admin (None) listener; both
|
||||
# MUST fire on this user's revocation.
|
||||
# MUST fire on this user's eviction.
|
||||
user_calls = [0]
|
||||
admin_calls = [0]
|
||||
other_calls = [0]
|
||||
@@ -526,7 +471,7 @@ def test_evict_user_session_drops_catalog_and_fires_listener(
|
||||
mgr.add_listener(_admin_cb) # admin / None
|
||||
mgr.add_listener(_other_cb, user_id="user-2")
|
||||
|
||||
mgr._evict_session_drop_catalog(("user-1", "pool-srv"))
|
||||
mgr._evict_session(("user-1", "pool-srv"))
|
||||
|
||||
# Catalog cleared.
|
||||
entry = mgr._user_pool_entries[("user-1", "pool-srv")]
|
||||
@@ -534,7 +479,7 @@ def test_evict_user_session_drops_catalog_and_fires_listener(
|
||||
assert entry.tools is None
|
||||
# User map dropped (no remaining pool entries for this user).
|
||||
assert "user-1" not in mgr._user_tool_map
|
||||
# is_mcp_tool no longer surfaces the revoked name.
|
||||
# is_mcp_tool no longer surfaces the evicted name.
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is False
|
||||
# Listener fan-out: matching user + admin fire, OTHER user does not.
|
||||
assert user_calls[0] == 1, f"user-keyed listener fired {user_calls[0]} times; expected 1"
|
||||
@@ -545,19 +490,22 @@ def test_evict_user_session_drops_catalog_and_fires_listener(
|
||||
)
|
||||
|
||||
|
||||
def test_close_pool_entry_if_idle_cools_entry_for_live_session_user(
|
||||
def test_close_pool_entry_if_idle_clears_catalog_and_fires_listener(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""TTL/LRU eviction COOLS the entry when the user has a live
|
||||
session (a registered user-scoped tool listener): the transport is
|
||||
torn down but the entry, its catalog, the per-user maps, and the
|
||||
entry's ``open_lock`` all survive, and NO listener fires — the live
|
||||
session's model-visible tool list must not shrink because the user
|
||||
went 10 minutes without an MCP dispatch (#836).
|
||||
"""LRU/TTL eviction (``_close_pool_entry_if_idle``) mirrors
|
||||
``_evict_session``'s catalog-cleanup contract: drops the entry,
|
||||
prunes the notification-debounce dict, rebuilds the user's tool
|
||||
map, and fires user + admin listeners.
|
||||
|
||||
Negative test: restore the unconditional pop + rebuild + notify in
|
||||
``_close_pool_entry_if_idle``: this test fails because the entry
|
||||
vanishes, ``is_mcp_tool`` flips to False, and the listener fires.
|
||||
Phase 7 round-2 review hardening (round2-1): the bug-2 fix added
|
||||
the catalog-cleanup block to this method but no integration test
|
||||
drove it — exactly the failure mode flagged in
|
||||
``feedback_tests_through_boundaries.md``. Negative test: drop the
|
||||
``_rebuild_user_tool_map`` / ``_notify_user_tool_listeners`` calls
|
||||
from ``_close_pool_entry_if_idle``'s post-pop block; this test
|
||||
fails because ``is_mcp_tool`` keeps returning ``True`` for the
|
||||
evicted name AND no listener fires.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
@@ -568,119 +516,48 @@ def test_close_pool_entry_if_idle_cools_entry_for_live_session_user(
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
key = ("user-1", "pool-srv")
|
||||
# Retention also requires the server to still exist as a pool server.
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
# Sanity: in_flight must be 0 for the eviction path to proceed.
|
||||
assert mgr._user_pool_entries[key].in_flight == 0
|
||||
# Seed the debounce dict so the prune-on-close is observable.
|
||||
mgr._last_pool_notification_refresh[(key, "tools")] = 0.0
|
||||
# Seed the debounce dict so the perf-1 prune is observable.
|
||||
mgr._last_pool_notification_refresh[key] = 0.0
|
||||
|
||||
user_calls = [0]
|
||||
admin_calls = [0]
|
||||
other_calls = [0]
|
||||
|
||||
def _user_cb() -> None:
|
||||
user_calls[0] += 1
|
||||
|
||||
def _admin_cb() -> None:
|
||||
admin_calls[0] += 1
|
||||
|
||||
# The user-scoped tool listener is the liveness signal (#836).
|
||||
mgr.add_listener(_user_cb, user_id="user-1")
|
||||
mgr.add_listener(_admin_cb) # admin / None
|
||||
|
||||
_run_on_loop(loop, mgr._close_pool_entry_if_idle(key))
|
||||
|
||||
# Entry cooled, not dropped: transport gone, catalog intact, dead
|
||||
# bearer copy cleared with the transport.
|
||||
entry = mgr._user_pool_entries.get(key)
|
||||
assert entry is not None, "cooled entry must survive TTL eviction"
|
||||
assert entry.session is None
|
||||
assert entry.owner_task is None
|
||||
assert entry.bound_token is None
|
||||
assert entry.tools is not None
|
||||
# The lock object must survive with the entry — an in-flight
|
||||
# dispatcher's next acquire needs the same lock.
|
||||
assert key in mgr._user_pool_locks
|
||||
# Debounce stamp pruned with the transport.
|
||||
assert (key, "tools") not in mgr._last_pool_notification_refresh
|
||||
# Per-user catalog view untouched.
|
||||
assert "user-1" in mgr._user_tool_map
|
||||
assert "user-1" in mgr._user_tools
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
# Nothing changed catalog-wise → NO fan-out (a fan-out here would
|
||||
# make every live session rebuild its tool list every idle TTL).
|
||||
assert user_calls[0] == 0, f"user-keyed listener fired {user_calls[0]} times; expected 0"
|
||||
assert admin_calls[0] == 0, f"admin listener fired {admin_calls[0]} times; expected 0"
|
||||
|
||||
# A second close on the already-cooled entry is a no-op — no drop,
|
||||
# no fan-out (the eviction loop additionally skips cooled entries
|
||||
# before even getting here).
|
||||
_run_on_loop(loop, mgr._close_pool_entry_if_idle(key))
|
||||
assert key in mgr._user_pool_entries
|
||||
assert user_calls[0] == 0 and admin_calls[0] == 0
|
||||
|
||||
|
||||
def test_close_pool_entry_if_idle_drops_entry_without_live_listener(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""TTL/LRU eviction FULLY drops the entry when the user has no live
|
||||
session: entry popped, notification-debounce dict pruned, per-user
|
||||
maps rebuilt, and the (admin-only) fan-out fires — departed users'
|
||||
entries must not outlive their sessions.
|
||||
|
||||
Phase 7 round-2 review hardening (round2-1) heritage: the
|
||||
catalog-cleanup block needs an integration test driving it — the
|
||||
failure mode flagged in ``feedback_tests_through_boundaries.md``.
|
||||
Negative test: drop the ``_rebuild_user_tool_map`` /
|
||||
``_notify_user_tool_listeners`` calls from the post-pop block; this
|
||||
test fails because ``is_mcp_tool`` keeps returning ``True`` for the
|
||||
evicted name AND the admin listener never fires.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
list_tools_response=_list_tools_payload([_tool_spec("do_thing")]),
|
||||
)
|
||||
_build_mock_transport_factory(mgr, monkeypatch, handler)
|
||||
_patch_tcp_probe(mgr, monkeypatch)
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
key = ("user-1", "pool-srv")
|
||||
# Registry seeded so the DROP below is attributable to the missing
|
||||
# listener alone, not to registry-liveness.
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is True
|
||||
assert mgr._user_pool_entries[key].in_flight == 0
|
||||
# Seed the debounce dict so the perf-1 prune is observable.
|
||||
mgr._last_pool_notification_refresh[(key, "tools")] = 0.0
|
||||
|
||||
admin_calls = [0]
|
||||
other_calls = [0]
|
||||
|
||||
def _admin_cb() -> None:
|
||||
admin_calls[0] += 1
|
||||
|
||||
def _other_cb() -> None:
|
||||
other_calls[0] += 1
|
||||
|
||||
# NO user-1 tool listener — user-1 has no live session. The admin
|
||||
# (None) and unrelated-user listeners don't count as liveness.
|
||||
mgr.add_listener(_user_cb, user_id="user-1")
|
||||
mgr.add_listener(_admin_cb) # admin / None
|
||||
mgr.add_listener(_other_cb, user_id="user-2")
|
||||
|
||||
# Drive the LRU/TTL eviction path directly. ``open_lock`` is
|
||||
# uncontested (no concurrent dispatch) and ``in_flight`` is 0,
|
||||
# so the close proceeds without retry.
|
||||
_run_on_loop(loop, mgr._close_pool_entry_if_idle(key))
|
||||
|
||||
# Entry fully removed.
|
||||
# Entry fully removed (LRU eviction pops the dict — unlike
|
||||
# ``_evict_session`` which keeps the entry as a ``session=None``
|
||||
# phantom for the next dispatch to re-connect).
|
||||
assert key not in mgr._user_pool_entries
|
||||
assert key not in mgr._user_pool_last_used
|
||||
assert key not in mgr._user_pool_locks
|
||||
# perf-1 prune: debounce dict no longer carries the key.
|
||||
assert (key, "tools") not in mgr._last_pool_notification_refresh
|
||||
assert key not in mgr._last_pool_notification_refresh
|
||||
# Catalog cleanup ran in BOTH dicts (bug-1 sibling + bug-2 cleanup).
|
||||
assert "user-1" not in mgr._user_tool_map
|
||||
assert "user-1" not in mgr._user_tools
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__do_thing", user_id="user-1") is False
|
||||
# Admin fan-out fires (operator tooling observes the drop); the
|
||||
# unrelated user's listener does not.
|
||||
# Listener fan-out: matching user + admin fire, OTHER user does not.
|
||||
assert user_calls[0] == 1, f"user-keyed listener fired {user_calls[0]} times; expected 1"
|
||||
assert admin_calls[0] == 1, f"admin listener fired {admin_calls[0]} times; expected 1"
|
||||
assert other_calls[0] == 0, (
|
||||
f"unrelated user-2 listener fired {other_calls[0]} times; expected 0 — "
|
||||
@@ -688,25 +565,18 @@ def test_close_pool_entry_if_idle_drops_entry_without_live_listener(
|
||||
)
|
||||
|
||||
|
||||
def test_reconnect_after_eviction_corrects_catalog_drift(
|
||||
def test_reconnect_after_eviction_repopulates_catalog(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The retained catalog self-corrects at reconnect: the next
|
||||
``_connect_one_pool`` re-runs discovery and REPLACES the stale
|
||||
snapshot — no extra glue required (#836).
|
||||
|
||||
Between eviction and reconnect the OLD names stay visible (that is
|
||||
the point of retention — the model can still emit them, and the
|
||||
dispatch that follows performs this reconnect); a name the server
|
||||
dropped in the meantime dies at the server as a per-call error
|
||||
while the refreshed catalog fans out.
|
||||
"""After eviction, the next ``_connect_one_pool`` re-populates the
|
||||
catalog from the SDK by construction — no extra glue required.
|
||||
|
||||
Verified by reverting the discovery block in ``_connect_one_pool``:
|
||||
the reconnected entry keeps serving the stale ``tool_a`` and
|
||||
``tool_b`` never appears.
|
||||
the reconnected entry's ``tools`` stays ``None`` and the user map
|
||||
never re-emerges.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
# Sequence: first connect sees [tool_a]; the session dies; second
|
||||
# Sequence: first connect sees [tool_a]; eviction clears; second
|
||||
# connect (after backend rotates) sees [tool_b].
|
||||
handler = _make_jsonrpc_handler(
|
||||
list_tools_seq=[
|
||||
@@ -721,9 +591,7 @@ def test_reconnect_after_eviction_corrects_catalog_drift(
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__tool_a", user_id="user-1") is True
|
||||
|
||||
mgr._evict_session(("user-1", "pool-srv"))
|
||||
# Retention: the stale snapshot keeps serving the live session
|
||||
# until the reconnect refreshes it.
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__tool_a", user_id="user-1") is True
|
||||
assert mgr.is_mcp_tool("mcp__pool-srv__tool_a", user_id="user-1") is False
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
# Reconnect picked up the rotated catalog.
|
||||
@@ -1687,14 +1555,17 @@ def test_refresh_pool_server_prompts_skips_when_capability_unset(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_evict_session_keeps_resource_and_prompt_catalogs(
|
||||
def test_eviction_clears_resource_and_prompt_catalogs_and_fires_listeners(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_evict_session`` retains ``entry.resources`` / ``entry.prompts``
|
||||
and their per-user maps, firing NO listeners — symmetric with the
|
||||
tool-catalog retention (#836). The revocation flavor
|
||||
(``_evict_session_drop_catalog``) is what clears and notifies; see
|
||||
the sibling test below.
|
||||
"""``_evict_session`` clears ``entry.resources`` and ``entry.prompts``,
|
||||
rebuilds both per-user maps (drops the now-empty entries), and
|
||||
fires the matching user-keyed + admin listeners for ALL three
|
||||
catalogs (tools, resources, prompts).
|
||||
|
||||
Negative-test: drop the resource/prompt cleanup additions in
|
||||
``_evict_session``: this test fails because the user-resource map
|
||||
keeps the evicted URIs and no resource listener fires.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
@@ -1733,81 +1604,15 @@ def test_evict_session_keeps_resource_and_prompt_catalogs(
|
||||
|
||||
mgr._evict_session(("user-1", "pool-srv"))
|
||||
|
||||
entry = mgr._user_pool_entries[("user-1", "pool-srv")]
|
||||
assert entry.session is None
|
||||
# All three catalogs retained.
|
||||
assert entry.tools is not None
|
||||
assert entry.resources is not None
|
||||
assert entry.prompts is not None
|
||||
# Per-user maps keep the entries.
|
||||
assert "res://r/1" in (mgr._user_resource_map.get("user-1") or {})
|
||||
assert "mcp__pool-srv__p1" in (mgr._user_prompt_map.get("user-1") or {})
|
||||
# Nothing changed catalog-wise → NO fan-out for anyone.
|
||||
assert res_calls[0] == 0
|
||||
assert prompt_calls[0] == 0
|
||||
assert other_res_calls[0] == 0, "unrelated user-2 resource listener fired — RFC §3.3 violation"
|
||||
assert other_prompt_calls[0] == 0, "unrelated user-2 prompt listener fired — RFC §3.3 violation"
|
||||
|
||||
|
||||
def test_evict_user_session_clears_resource_and_prompt_catalogs(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``_evict_session_drop_catalog`` (explicit revocation) clears
|
||||
``entry.resources`` / ``entry.prompts``, rebuilds both per-user
|
||||
maps (drops the now-empty entries), and fires the matching
|
||||
user-keyed listeners for resources and prompts.
|
||||
|
||||
Negative-test: drop the resource/prompt cleanup in
|
||||
``_evict_session_drop_catalog``: this test fails because the
|
||||
user-resource map keeps the revoked URIs and no resource listener
|
||||
fires.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
init_response=_init_response_with_caps(resources=True, prompts=True),
|
||||
list_resources_response=_list_resources_payload([_resource_spec("res://r/1")]),
|
||||
list_prompts_response=_list_prompts_payload([_prompt_spec("p1")]),
|
||||
)
|
||||
_build_mock_transport_factory(mgr, monkeypatch, handler)
|
||||
_patch_tcp_probe(mgr, monkeypatch)
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
assert "res://r/1" in (mgr._user_resource_map.get("user-1") or {})
|
||||
assert "mcp__pool-srv__p1" in (mgr._user_prompt_map.get("user-1") or {})
|
||||
|
||||
res_calls = [0]
|
||||
prompt_calls = [0]
|
||||
other_res_calls = [0]
|
||||
other_prompt_calls = [0]
|
||||
|
||||
def _user_res_cb() -> None:
|
||||
res_calls[0] += 1
|
||||
|
||||
def _user_prompt_cb() -> None:
|
||||
prompt_calls[0] += 1
|
||||
|
||||
def _other_res_cb() -> None:
|
||||
other_res_calls[0] += 1
|
||||
|
||||
def _other_prompt_cb() -> None:
|
||||
other_prompt_calls[0] += 1
|
||||
|
||||
mgr.add_resource_listener(_user_res_cb, user_id="user-1")
|
||||
mgr.add_resource_listener(_other_res_cb, user_id="user-2")
|
||||
mgr.add_prompt_listener(_user_prompt_cb, user_id="user-1")
|
||||
mgr.add_prompt_listener(_other_prompt_cb, user_id="user-2")
|
||||
|
||||
mgr._evict_session_drop_catalog(("user-1", "pool-srv"))
|
||||
|
||||
entry = mgr._user_pool_entries[("user-1", "pool-srv")]
|
||||
assert entry.session is None
|
||||
assert entry.tools is None
|
||||
assert entry.resources is None
|
||||
assert entry.prompts is None
|
||||
# Per-user maps drop the revoked entries.
|
||||
# Per-user maps drop the evicted entries.
|
||||
assert "user-1" not in mgr._user_resource_map
|
||||
assert "user-1" not in mgr._user_prompt_map
|
||||
# Listeners fire for the revoked user but not for the unrelated user.
|
||||
# Listeners fire for the evicted user but not for the unrelated user.
|
||||
assert res_calls[0] == 1
|
||||
assert prompt_calls[0] == 1
|
||||
assert other_res_calls[0] == 0, "unrelated user-2 resource listener fired — RFC §3.3 violation"
|
||||
@@ -1818,13 +1623,8 @@ def test_close_pool_entry_if_idle_clears_resource_and_prompt_catalogs(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""LRU/TTL eviction (``_close_pool_entry_if_idle``) symmetric
|
||||
cleanup for resources & prompts on the FULL-DROP path. Bug-pair to
|
||||
the tools-only cleanup added in Phase 7 round-2.
|
||||
|
||||
Deliberately registers NO user-1 TOOL listener: the user-scoped
|
||||
tool listener is the liveness signal (#836) — resource/prompt
|
||||
listeners alone do not mark a user live (every real ChatSession
|
||||
registers the tool listener), so this drives the full drop."""
|
||||
cleanup for resources & prompts. Bug-pair to the tools-only
|
||||
cleanup added in Phase 7 round-2."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
init_response=_init_response_with_caps(resources=True, prompts=True),
|
||||
@@ -1861,63 +1661,12 @@ def test_close_pool_entry_if_idle_clears_resource_and_prompt_catalogs(
|
||||
assert prompt_calls[0] == 1
|
||||
|
||||
|
||||
def test_close_pool_entry_if_idle_cooling_keeps_resources_and_prompts(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The cooled path retains resources & prompts symmetrically with
|
||||
tools: a user-1 TOOL listener (the liveness signal, #836) makes
|
||||
TTL/LRU eviction keep the entry, both per-user maps, and fire no
|
||||
resource/prompt listener."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
init_response=_init_response_with_caps(resources=True, prompts=True),
|
||||
list_resources_response=_list_resources_payload([_resource_spec("res://r/1")]),
|
||||
list_prompts_response=_list_prompts_payload([_prompt_spec("p1")]),
|
||||
)
|
||||
_build_mock_transport_factory(mgr, monkeypatch, handler)
|
||||
_patch_tcp_probe(mgr, monkeypatch)
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
key = ("user-1", "pool-srv")
|
||||
mgr._oauth_user_server_names = {"pool-srv"}
|
||||
|
||||
res_calls = [0]
|
||||
prompt_calls = [0]
|
||||
|
||||
def _res_cb() -> None:
|
||||
res_calls[0] += 1
|
||||
|
||||
def _prompt_cb() -> None:
|
||||
prompt_calls[0] += 1
|
||||
|
||||
# The TOOL listener marks user-1 live; the resource/prompt
|
||||
# listeners observe (non-)fan-out.
|
||||
mgr.add_listener(lambda: None, user_id="user-1")
|
||||
mgr.add_resource_listener(_res_cb, user_id="user-1")
|
||||
mgr.add_prompt_listener(_prompt_cb, user_id="user-1")
|
||||
|
||||
_run_on_loop(loop, mgr._close_pool_entry_if_idle(key))
|
||||
|
||||
# Entry cooled: transport gone, catalogs and maps intact.
|
||||
entry = mgr._user_pool_entries.get(key)
|
||||
assert entry is not None
|
||||
assert entry.session is None
|
||||
assert entry.resources is not None
|
||||
assert entry.prompts is not None
|
||||
assert "res://r/1" in (mgr._user_resource_map.get("user-1") or {})
|
||||
assert "mcp__pool-srv__p1" in (mgr._user_prompt_map.get("user-1") or {})
|
||||
# No fan-out — nothing changed catalog-wise.
|
||||
assert res_calls[0] == 0
|
||||
assert prompt_calls[0] == 0
|
||||
|
||||
|
||||
def test_reconnect_after_eviction_repopulates_resource_and_prompt_catalogs(
|
||||
running_loop_mgr: Any, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""After a session eviction the next ``_connect_one_pool`` REPLACES
|
||||
the retained resource and prompt catalogs from the SDK — drift
|
||||
self-corrects for all three catalogs, not just tools (#836).
|
||||
Bug-class: an extra glue layer would only repopulate tools."""
|
||||
"""After eviction, the next ``_connect_one_pool`` re-populates the
|
||||
resource and prompt catalogs from the SDK. Bug-class: an extra
|
||||
glue layer would only repopulate tools."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
handler = _make_jsonrpc_handler(
|
||||
init_response=_init_response_with_caps(resources=True, prompts=True),
|
||||
@@ -1938,9 +1687,8 @@ def test_reconnect_after_eviction_repopulates_resource_and_prompt_catalogs(
|
||||
assert "mcp__pool-srv__p_a" in (mgr._user_prompt_map.get("user-1") or {})
|
||||
|
||||
mgr._evict_session(("user-1", "pool-srv"))
|
||||
# Retention: the stale snapshots keep serving until reconnect.
|
||||
assert "res://a" in (mgr._user_resource_map.get("user-1") or {})
|
||||
assert "mcp__pool-srv__p_a" in (mgr._user_prompt_map.get("user-1") or {})
|
||||
assert "user-1" not in mgr._user_resource_map
|
||||
assert "user-1" not in mgr._user_prompt_map
|
||||
|
||||
_connect_pool(mgr, loop, user_id="user-1", server_name="pool-srv")
|
||||
# New catalogs reflect the rotated payload.
|
||||
|
||||
+7
-1169
File diff suppressed because it is too large
Load Diff
+14
-15
@@ -60,11 +60,11 @@ class TestProbeModelEndpoint:
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_target_found(self, mock_cc: MagicMock) -> None:
|
||||
m1 = _mock_model("gpt-5.4")
|
||||
m1 = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m1)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5.4"
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
|
||||
)
|
||||
assert result["model_found"] is True
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestProbeModelEndpoint:
|
||||
mock_cc.return_value = _mock_client(m1)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5.4"
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
|
||||
)
|
||||
assert result["model_found"] is False
|
||||
assert result["available_models"] == ["model-a"]
|
||||
@@ -98,7 +98,7 @@ class TestProbeModelEndpoint:
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_server_type_openai(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("gpt-5.4")
|
||||
m = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "https://api.openai.com/v1", "sk-test")
|
||||
@@ -203,13 +203,13 @@ class TestProbeModelEndpoint:
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_context_window_openai_static_table(self, mock_cc: MagicMock) -> None:
|
||||
"""When base_url is api.openai.com and model is known, use static table."""
|
||||
m = _mock_model("gpt-5.4")
|
||||
m = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "https://api.openai.com/v1", "sk-test", target_model="gpt-5.4"
|
||||
"openai", "https://api.openai.com/v1", "sk-test", target_model="gpt-5"
|
||||
)
|
||||
assert result["context_window"] == 1050000
|
||||
assert result["context_window"] == 400000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -219,11 +219,10 @@ class TestProbeModelEndpoint:
|
||||
|
||||
class TestLookupModelCapabilities:
|
||||
def test_known_openai_model(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5.4")
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
assert caps["context_window"] == 1050000
|
||||
# 5.4 accepts temperature (applied only when effort="none")
|
||||
assert caps["supports_temperature"] is True
|
||||
assert caps["context_window"] == 400000
|
||||
assert caps["supports_temperature"] is False
|
||||
|
||||
def test_known_anthropic_model(self) -> None:
|
||||
caps = lookup_model_capabilities("anthropic", "claude-opus-4-6")
|
||||
@@ -236,13 +235,13 @@ class TestLookupModelCapabilities:
|
||||
assert caps is None
|
||||
|
||||
def test_tuples_converted_to_lists(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5.4")
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
for val in caps.values():
|
||||
assert not isinstance(val, tuple), f"Found tuple: {val}"
|
||||
|
||||
def test_reasoning_effort_values_are_list(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5.4")
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
assert isinstance(caps["reasoning_effort_values"], list)
|
||||
assert "medium" in caps["reasoning_effort_values"]
|
||||
@@ -253,7 +252,7 @@ class TestLookupModelCapabilities:
|
||||
|
||||
def test_invalid_provider_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
lookup_model_capabilities("bad-provider", "gpt-5.4")
|
||||
lookup_model_capabilities("bad-provider", "gpt-5")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -264,7 +263,7 @@ class TestLookupModelCapabilities:
|
||||
class TestListKnownModels:
|
||||
def test_openai_models(self) -> None:
|
||||
models = list_known_models("openai")
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5" in models
|
||||
assert isinstance(models, list)
|
||||
assert models == sorted(models)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import scripted_chat_client
|
||||
from turnstone.core.model_registry import (
|
||||
ModelConfig,
|
||||
ModelRegistry,
|
||||
@@ -1189,13 +1188,8 @@ class TestSessionModelCommand:
|
||||
assert session.max_tokens == 2048
|
||||
assert session.reasoning_effort == "high"
|
||||
|
||||
def test_model_switch_storeless_keeps_explicit_knobs(self) -> None:
|
||||
"""On a STORE-LESS session (the CLI), the current knobs are the
|
||||
user's explicit flags — the only authority that exists — so a
|
||||
switch to an override-free alias keeps them (mirroring the
|
||||
max_tokens fallback). With a ConfigStore the shared resolvers
|
||||
re-resolve for the new alias instead (unset → None → wire
|
||||
omission), so per-model overrides don't leak between aliases."""
|
||||
def test_model_switch_none_params_reverts_to_global(self) -> None:
|
||||
"""Switching to a model with no overrides reverts to global defaults."""
|
||||
reg = ModelRegistry(
|
||||
models={
|
||||
"hot": ModelConfig("hot", "x", "x", "hot-model", temperature=1.5),
|
||||
@@ -1204,15 +1198,11 @@ class TestSessionModelCommand:
|
||||
default="hot",
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="hot")
|
||||
session.temperature = 0.9 # user's explicit --temperature flag
|
||||
session.reasoning_effort = "high" # user's explicit /reason choice
|
||||
session.temperature = 1.5 # as set by per-model override
|
||||
# Without a config_store, fallback keeps current value (CLI sessions).
|
||||
# With a config_store, it would revert to the global default.
|
||||
session.handle_command("/model plain")
|
||||
assert session.temperature == 0.9
|
||||
assert session.reasoning_effort == "high"
|
||||
# A per-model override on the TARGET alias still wins over the
|
||||
# carried knob.
|
||||
session.handle_command("/model hot")
|
||||
assert session.temperature == 1.5
|
||||
assert session.temperature == 1.5 # no config_store → keeps current
|
||||
|
||||
def test_model_switch_unknown_alias(self) -> None:
|
||||
reg = ModelRegistry(
|
||||
@@ -1300,8 +1290,18 @@ class TestSessionAgentModel:
|
||||
)
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
|
||||
# Scripted client records kwargs; read the model off its calls.
|
||||
fake_create = scripted_chat_client({"content": "done"})
|
||||
# Mock the API to capture what model was used
|
||||
captured_model = None
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "done"
|
||||
mock_response.choices[0].message.tool_calls = None
|
||||
mock_response.choices[0].finish_reason = "stop"
|
||||
|
||||
def fake_create(**kwargs: Any) -> Any:
|
||||
nonlocal captured_model
|
||||
captured_model = kwargs.get("model")
|
||||
return mock_response
|
||||
|
||||
# Get the agent client from the registry and patch it
|
||||
agent_client = reg.get_client("agent")
|
||||
@@ -1312,21 +1312,21 @@ class TestSessionAgentModel:
|
||||
Turn.user("Do something."),
|
||||
]
|
||||
session._run_agent(agent_msgs)
|
||||
assert fake_create.calls[-1].get("model") == "agent-model"
|
||||
assert captured_model == "agent-model"
|
||||
|
||||
@staticmethod
|
||||
def _capture_on(client: Any) -> dict[str, Any]:
|
||||
"""Patch *client* (registry-resolved or session.client) to capture kwargs.
|
||||
|
||||
Rides the shared scripted client; the returned dict mirrors the
|
||||
LAST call's kwargs (existing reader contract).
|
||||
"""
|
||||
"""Patch *client* (registry-resolved or session.client) to capture kwargs."""
|
||||
captured: dict[str, Any] = {}
|
||||
scripted = scripted_chat_client({"content": "done"})
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "done"
|
||||
mock_response.choices[0].message.tool_calls = None
|
||||
mock_response.choices[0].finish_reason = "stop"
|
||||
|
||||
def fake_create(**kwargs: Any) -> Any:
|
||||
captured.update(kwargs)
|
||||
return scripted(**kwargs)
|
||||
return mock_response
|
||||
|
||||
client.chat.completions.create = fake_create
|
||||
return captured
|
||||
@@ -1467,22 +1467,16 @@ class TestSessionAgentModel:
|
||||
silently dropped on the agent path."""
|
||||
reg = self._three_model_registry() # no agent_model / plan_model set
|
||||
session = _make_session(registry=reg, model_alias="main")
|
||||
# Probe the lane resolution: extra_params now resolve INSIDE
|
||||
# resolve_lane (single config fetch) rather than via the session's
|
||||
# pre-resolution wrapper, so spy on the module seam; capability
|
||||
# resolution still routes through the session wrapper.
|
||||
from unittest.mock import patch
|
||||
|
||||
import turnstone.core.model_turn as mt
|
||||
|
||||
captured_lane_alias: list[str | None] = []
|
||||
# Probe what _run_agent passes to _provider_extra_params and
|
||||
# _resolve_capabilities by recording the model_alias on each call.
|
||||
captured_extra_alias: list[str | None] = []
|
||||
captured_resolve_alias: list[str | None] = []
|
||||
original_lane = mt.resolve_lane
|
||||
original_extra = session._provider_extra_params
|
||||
original_resolve = session._resolve_capabilities
|
||||
|
||||
def spy_lane(*args: Any, **kwargs: Any) -> Any:
|
||||
captured_lane_alias.append(kwargs.get("alias"))
|
||||
return original_lane(*args, **kwargs)
|
||||
def spy_extra(*args: Any, **kwargs: Any) -> Any:
|
||||
captured_extra_alias.append(kwargs.get("model_alias"))
|
||||
return original_extra(*args, **kwargs)
|
||||
|
||||
def spy_resolve(*args: Any, **kwargs: Any) -> Any:
|
||||
# _resolve_capabilities(provider, model, alias)
|
||||
@@ -1490,15 +1484,15 @@ class TestSessionAgentModel:
|
||||
captured_resolve_alias.append(alias)
|
||||
return original_resolve(*args, **kwargs)
|
||||
|
||||
session._provider_extra_params = spy_extra # type: ignore[method-assign]
|
||||
session._resolve_capabilities = spy_resolve # type: ignore[method-assign]
|
||||
|
||||
self._capture_on(session.client) # patch client.chat.completions.create
|
||||
with patch("turnstone.core.session.resolve_lane", side_effect=spy_lane):
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
session._run_agent([Turn.user("x")], label="plan")
|
||||
|
||||
assert captured_lane_alias and captured_lane_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for the lane: "
|
||||
f"{captured_lane_alias!r}"
|
||||
assert captured_extra_alias and captured_extra_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for extra_params: "
|
||||
f"{captured_extra_alias!r}"
|
||||
)
|
||||
assert captured_resolve_alias and captured_resolve_alias[-1] == "main", (
|
||||
f"agent fallback path did not inherit primary alias for caps: "
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
"""Unit tests for the ``model_turn`` plant-call primitive (#827).
|
||||
|
||||
The agent-path tests in ``test_session.py`` exercise ``model_turn`` through
|
||||
``_run_agent`` (native-lane replay, blank-id gate, minted-id nesting); these
|
||||
pin the module's own contract directly so the judges (phase 1b) and the
|
||||
single-shot lanes (phase 2) can build on it without re-deriving semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import turnstone.core.model_turn as model_turn_mod
|
||||
from tests._session_helpers import as_stream
|
||||
from turnstone.core.model_turn import (
|
||||
ModelLane,
|
||||
finalize_provider_blocks,
|
||||
maybe_attach_vllm_chat_reasoning,
|
||||
model_turn,
|
||||
resolve_lane,
|
||||
synth_reasoning_block,
|
||||
)
|
||||
from turnstone.core.providers._protocol import (
|
||||
CompletionResult,
|
||||
IncompleteStreamError,
|
||||
ModelCapabilities,
|
||||
StreamChunk,
|
||||
UsageInfo,
|
||||
)
|
||||
from turnstone.core.trajectory import Role, ToolCall, Turn
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
"""Records every ``create_streaming`` call; replays scripted results
|
||||
as single-chunk streams via the shared ``as_stream`` adapter
|
||||
(multi-chunk accumulation is pinned by the dedicated ``drain_stream``
|
||||
unit tests)."""
|
||||
|
||||
provider_name = "openai-compatible"
|
||||
|
||||
def __init__(self, results: list[CompletionResult]) -> None:
|
||||
self.results = list(results)
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def get_capabilities(self, model: str) -> ModelCapabilities:
|
||||
return ModelCapabilities()
|
||||
|
||||
def create_streaming(self, **kwargs: Any) -> list[StreamChunk]:
|
||||
self.calls.append(kwargs)
|
||||
return as_stream(self.results.pop(0))
|
||||
|
||||
|
||||
def _fake_registry(
|
||||
*,
|
||||
capabilities: dict[str, Any] | None = None,
|
||||
server_compat: dict[str, Any] | None = None,
|
||||
replay: bool = False,
|
||||
temperature: float | None = None,
|
||||
) -> MagicMock:
|
||||
cfg = SimpleNamespace(
|
||||
capabilities=capabilities or {},
|
||||
server_compat=server_compat or {},
|
||||
replay_reasoning_to_model=replay,
|
||||
temperature=temperature,
|
||||
)
|
||||
reg = MagicMock()
|
||||
reg.get_config.return_value = cfg
|
||||
return reg
|
||||
|
||||
|
||||
def _lane(provider: _FakeProvider, **kw: Any) -> ModelLane:
|
||||
return ModelLane(provider=provider, client=object(), model="m", **kw)
|
||||
|
||||
|
||||
class _FlakyProvider:
|
||||
"""Scripted drain-time deaths: each script entry is either a
|
||||
``CompletionResult`` (streamed normally) or an exception instance
|
||||
(raised mid-iteration — AFTER ``create_streaming`` returned, exactly
|
||||
where a real mid-body wire death surfaces)."""
|
||||
|
||||
provider_name = "openai-compatible"
|
||||
retryable_error_names: frozenset[str] = frozenset({"IncompleteStreamError"})
|
||||
|
||||
def __init__(self, script: list[Any]) -> None:
|
||||
self.script = list(script)
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def get_capabilities(self, model: str) -> ModelCapabilities:
|
||||
return ModelCapabilities()
|
||||
|
||||
def create_streaming(self, **kwargs: Any) -> Any:
|
||||
self.calls.append(kwargs)
|
||||
item = self.script.pop(0)
|
||||
|
||||
def _iter() -> Any:
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
yield from as_stream(item)
|
||||
|
||||
return _iter()
|
||||
|
||||
|
||||
def test_model_turn_retries_transient_mid_stream_death(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# The retired non-streaming transport read the whole body inside the
|
||||
# SDK's retried request, so single-shot lanes never saw a mid-body wire
|
||||
# blip — the drain-scoped loop is that retry's new home.
|
||||
monkeypatch.setattr("turnstone.core.model_turn._DRAIN_RETRY_BASE_DELAY", 0.0)
|
||||
provider = _FlakyProvider(
|
||||
[
|
||||
IncompleteStreamError("stream died mid-response"),
|
||||
CompletionResult(content="second try"),
|
||||
]
|
||||
)
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
|
||||
result = model_turn(lane, [Turn.user("x")])
|
||||
|
||||
assert result.content == "second try"
|
||||
assert len(provider.calls) == 2
|
||||
|
||||
|
||||
def test_model_turn_gives_up_after_retry_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("turnstone.core.model_turn._DRAIN_RETRY_BASE_DELAY", 0.0)
|
||||
provider = _FlakyProvider([IncompleteStreamError(f"death {i}") for i in range(5)])
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
|
||||
with pytest.raises(IncompleteStreamError):
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
|
||||
# One initial issue + _DRAIN_RETRIES re-issues, then it propagates.
|
||||
assert len(provider.calls) == 3
|
||||
|
||||
|
||||
def test_model_turn_retry_backs_off_between_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Instant re-issues are guaranteed to re-hit a still-active rate
|
||||
# limit/overload — the loop paces like the SDK request retry it
|
||||
# replaces: 0.5s base, doubling, ±50% jitter.
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(model_turn_mod, "time", SimpleNamespace(sleep=sleeps.append))
|
||||
provider = _FlakyProvider(
|
||||
[
|
||||
IncompleteStreamError("death 1"),
|
||||
IncompleteStreamError("death 2"),
|
||||
CompletionResult(content="ok"),
|
||||
]
|
||||
)
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
|
||||
result = model_turn(lane, [Turn.user("x")])
|
||||
|
||||
assert result.content == "ok"
|
||||
assert len(sleeps) == 2
|
||||
assert 0.25 <= sleeps[0] <= 0.75 # 0.5 * jitter[0.5, 1.5)
|
||||
assert 0.5 <= sleeps[1] <= 1.5 # 1.0 * jitter[0.5, 1.5)
|
||||
|
||||
|
||||
def test_model_turn_does_not_retry_unrecognized_errors() -> None:
|
||||
provider = _FlakyProvider([RuntimeError("schema violation")])
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
|
||||
with pytest.raises(RuntimeError, match="schema violation"):
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
|
||||
assert len(provider.calls) == 1
|
||||
|
||||
|
||||
def test_model_turn_abort_during_backoff_suppresses_reissue(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# The deadline can abandon the worker while it sleeps between
|
||||
# attempts — the wake-up must die with the original failure, not
|
||||
# issue one more full request from an abandoned thread.
|
||||
from turnstone.core.deadline import StreamAbortRef
|
||||
|
||||
ref = StreamAbortRef()
|
||||
monkeypatch.setattr(model_turn_mod, "time", SimpleNamespace(sleep=lambda _delay: ref.abort()))
|
||||
provider = _FlakyProvider(
|
||||
[IncompleteStreamError("transient death"), CompletionResult(content="never")]
|
||||
)
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
|
||||
with pytest.raises(IncompleteStreamError, match="transient death"):
|
||||
model_turn(lane, [Turn.user("x")], cancel_ref=ref)
|
||||
|
||||
assert len(provider.calls) == 1
|
||||
|
||||
|
||||
def test_model_turn_does_not_retry_after_abort() -> None:
|
||||
# A deadline that closed the stream must not have the request
|
||||
# resurrected behind its back: the closed stream dies with an error
|
||||
# that LOOKS retryable, but the aborted cancel_ref gates the re-issue.
|
||||
from turnstone.core.deadline import StreamAbortRef
|
||||
|
||||
provider = _FlakyProvider(
|
||||
[IncompleteStreamError("closed by abort"), CompletionResult(content="never")]
|
||||
)
|
||||
lane = ModelLane(provider=provider, client=object(), model="m")
|
||||
ref = StreamAbortRef()
|
||||
ref.abort()
|
||||
|
||||
with pytest.raises(IncompleteStreamError):
|
||||
model_turn(lane, [Turn.user("x")], cancel_ref=ref)
|
||||
|
||||
assert len(provider.calls) == 1
|
||||
|
||||
|
||||
def _real_semantics_store(**stored: Any) -> SimpleNamespace:
|
||||
"""A ConfigStore fake with the REAL ``get()`` semantics.
|
||||
|
||||
A stored key returns its value; a never-stored key returns the
|
||||
SETTINGS registry default — which for the sampling keys IS the unset
|
||||
sentinel (``None`` / ``""``). The old fakes returned ``None`` on any
|
||||
miss, which masked the default-on-miss collision the round-2 review
|
||||
caught: never fake a store rung more forgiving than the real one.
|
||||
"""
|
||||
from turnstone.core.settings_registry import SETTINGS
|
||||
|
||||
def _get(key: str, default: Any = ...) -> Any:
|
||||
if key in stored:
|
||||
return stored[key]
|
||||
if default is not ...:
|
||||
return default
|
||||
defn = SETTINGS.get(key)
|
||||
return defn.default if defn else None
|
||||
|
||||
return SimpleNamespace(get=_get)
|
||||
|
||||
|
||||
def test_model_turn_lowers_turns_and_threads_lane_config() -> None:
|
||||
caps = ModelCapabilities(max_output_tokens=1234)
|
||||
extra = {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
provider = _FakeProvider([CompletionResult(content="hi")])
|
||||
lane = _lane(provider, capabilities=caps, extra_params=extra)
|
||||
|
||||
result = model_turn(
|
||||
lane,
|
||||
[Turn.user("x")],
|
||||
tools=[{"type": "function", "function": {"name": "f", "parameters": {}}}],
|
||||
max_tokens=99,
|
||||
temperature=0.1,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
(call,) = provider.calls
|
||||
assert call["messages"][0]["role"] == "user"
|
||||
assert call["messages"][0]["content"] == "x"
|
||||
assert call["capabilities"] is caps
|
||||
assert call["extra_params"] is extra
|
||||
assert call["max_tokens"] == 99
|
||||
assert call["temperature"] == 0.1
|
||||
assert call["reasoning_effort"] == "low"
|
||||
# No registry on the lane → the operator replay flag resolves False.
|
||||
assert call["replay_reasoning_to_model"] is False
|
||||
assert result.turn.role is Role.ASSISTANT
|
||||
assert result.content == "hi"
|
||||
assert result.finish_reason == "stop"
|
||||
|
||||
|
||||
def test_model_turn_returns_usage_verbatim() -> None:
|
||||
usage = UsageInfo(prompt_tokens=9, completion_tokens=1, total_tokens=10)
|
||||
provider = _FakeProvider([CompletionResult(content="", usage=usage)])
|
||||
result = model_turn(_lane(provider), [Turn.user("x")])
|
||||
# Value equality, not identity: ``drain_stream`` max-merges usage across
|
||||
# chunks into its own instance so it never mutates the provider's object.
|
||||
assert result.usage == usage
|
||||
|
||||
|
||||
def test_mint_rewrites_mirror_records_map_and_native_keeps_original() -> None:
|
||||
provider = _FakeProvider(
|
||||
[
|
||||
CompletionResult(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_0",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
provider_blocks=[{"type": "tool_use", "id": "call_0", "name": "f"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
wire_id_map: dict[str, str] = {}
|
||||
result = model_turn(
|
||||
_lane(provider),
|
||||
[Turn.user("x")],
|
||||
mint=lambda original: f"parent::r1s1::{original}",
|
||||
wire_id_map=wire_id_map,
|
||||
)
|
||||
|
||||
# The mirror (execution view) and the Turn both carry the minted id …
|
||||
assert result.tool_calls[0]["id"] == "parent::r1s1::call_0"
|
||||
assert result.turn.tool_calls[0].id == "parent::r1s1::call_0"
|
||||
# … the map records the recovery path …
|
||||
assert wire_id_map == {"parent::r1s1::call_0": "call_0"}
|
||||
# … and the native block keeps the provider-original id verbatim (it may
|
||||
# sit under a reasoning signature and is never rewritten).
|
||||
assert result.turn.native is not None
|
||||
assert result.turn.native.blocks[0]["id"] == "call_0"
|
||||
assert result.turn.native.producer == "openai-compatible"
|
||||
|
||||
|
||||
def test_restore_maps_minted_ids_back_on_the_wire() -> None:
|
||||
minted = "parent::r1s1::call_0"
|
||||
provider = _FakeProvider([CompletionResult(content="done")])
|
||||
turns = [
|
||||
Turn.user("go"),
|
||||
Turn.assistant("", tool_calls=(ToolCall(id=minted, name="f", arguments="{}"),)),
|
||||
Turn.tool(minted, "result"),
|
||||
]
|
||||
|
||||
model_turn(_lane(provider), turns, wire_id_map={minted: "call_0"})
|
||||
|
||||
(call,) = provider.calls
|
||||
assistant = next(m for m in call["messages"] if m["role"] == "assistant")
|
||||
tool = next(m for m in call["messages"] if m["role"] == "tool")
|
||||
assert assistant["tool_calls"][0]["id"] == "call_0"
|
||||
assert tool["tool_call_id"] == "call_0"
|
||||
|
||||
|
||||
def test_blank_ids_repair_native_lane_pairwise() -> None:
|
||||
# Google-compat shape: blank id on BOTH the mirror and the raw fidelity
|
||||
# block. The manufactured uuid lands in both (positional pairing), so
|
||||
# the thought_signature-bearing block SURVIVES instead of the turn
|
||||
# degrading to loose reasoning text — the unblock for the Gemini judge
|
||||
# evidence loop on blank-id compat responses.
|
||||
provider = _FakeProvider(
|
||||
[
|
||||
CompletionResult(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": "", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||
],
|
||||
provider_blocks=[
|
||||
{
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": "{}"},
|
||||
"thought_signature": "sig123",
|
||||
}
|
||||
],
|
||||
reasoning="thought",
|
||||
)
|
||||
]
|
||||
)
|
||||
result = model_turn(_lane(provider), [Turn.user("x")])
|
||||
|
||||
manufactured = result.tool_calls[0]["id"]
|
||||
assert manufactured.startswith("call_")
|
||||
assert result.turn.native is not None
|
||||
blocks = list(result.turn.native.blocks)
|
||||
# The fidelity block survives, id-agreeing with the mirror, signature
|
||||
# untouched; the loose reasoning still synthesizes alongside it.
|
||||
assert blocks[0]["id"] == manufactured
|
||||
assert blocks[0]["thought_signature"] == "sig123"
|
||||
assert blocks[-1]["type"] == "reasoning_text"
|
||||
|
||||
|
||||
def test_blank_id_repair_never_rewrites_nonblank_ids() -> None:
|
||||
provider = _FakeProvider(
|
||||
[
|
||||
CompletionResult(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": "call_7", "type": "function", "function": {"name": "a"}},
|
||||
{"id": "", "type": "function", "function": {"name": "b"}},
|
||||
],
|
||||
provider_blocks=[
|
||||
{"id": "call_7", "type": "function", "function": {"name": "a"}},
|
||||
{"id": "", "type": "function", "function": {"name": "b"}},
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
result = model_turn(_lane(provider), [Turn.user("x")])
|
||||
assert result.turn.native is not None
|
||||
blocks = list(result.turn.native.blocks)
|
||||
# Provider-assigned id untouched (it may sit under a signature) …
|
||||
assert blocks[0]["id"] == "call_7"
|
||||
# … only the blank one was manufactured, agreeing with its mirror twin.
|
||||
assert blocks[1]["id"] == result.tool_calls[1]["id"]
|
||||
assert blocks[1]["id"].startswith("call_")
|
||||
|
||||
|
||||
def test_blank_id_pairing_mismatch_falls_back_to_reasoning_text_drop() -> None:
|
||||
# Two mirror calls but only one client block: no trustworthy pairing —
|
||||
# the total drop rule (the #825-converged fallback) keeps only the
|
||||
# loose-text reasoning synth.
|
||||
provider = _FakeProvider(
|
||||
[
|
||||
CompletionResult(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{"id": "", "type": "function", "function": {"name": "a"}},
|
||||
{"id": "", "type": "function", "function": {"name": "b"}},
|
||||
],
|
||||
provider_blocks=[{"type": "function", "id": "", "function": {"name": "a"}}],
|
||||
reasoning="thought",
|
||||
)
|
||||
]
|
||||
)
|
||||
result = model_turn(_lane(provider), [Turn.user("x")])
|
||||
assert result.tool_calls[0]["id"].startswith("call_")
|
||||
assert result.turn.native is not None
|
||||
assert [b["type"] for b in result.turn.native.blocks] == ["reasoning_text"]
|
||||
assert result.turn.native.blocks[0]["text"] == "thought"
|
||||
|
||||
|
||||
def test_orphan_client_tool_blocks_stripped_when_no_tool_calls() -> None:
|
||||
provider = _FakeProvider(
|
||||
[
|
||||
CompletionResult(
|
||||
content="truncated",
|
||||
tool_calls=None,
|
||||
provider_blocks=[{"type": "tool_use", "id": "x", "name": "f"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
result = model_turn(_lane(provider), [Turn.user("x")])
|
||||
# A tool_use with no mirrored call would replay with no matching
|
||||
# tool_result — the finalize gate strips it, leaving no lane at all.
|
||||
assert result.turn.native is None
|
||||
|
||||
|
||||
def test_live_operator_flags_reresolve_per_call() -> None:
|
||||
registry = _fake_registry(replay=False)
|
||||
provider = _FakeProvider([CompletionResult(content="a"), CompletionResult(content="b")])
|
||||
lane = _lane(provider, alias="ali", registry=registry)
|
||||
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
# Operator flips the toggle mid-session (admin write → registry reload).
|
||||
registry.get_config.return_value.replay_reasoning_to_model = True
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
|
||||
first, second = provider.calls
|
||||
assert first["replay_reasoning_to_model"] is False
|
||||
assert second["replay_reasoning_to_model"] is True
|
||||
|
||||
|
||||
def test_resolve_lane_respects_preresolved_values() -> None:
|
||||
provider = _FakeProvider([])
|
||||
caps = ModelCapabilities(max_output_tokens=7)
|
||||
lane = resolve_lane(provider, object(), "m", capabilities=caps, extra_params={"k": "v"})
|
||||
assert lane.capabilities is caps
|
||||
assert lane.extra_params == {"k": "v"}
|
||||
# Explicit None is a valid resolved value, distinct from "resolve for me".
|
||||
lane_none = resolve_lane(provider, object(), "m", capabilities=caps, extra_params=None)
|
||||
assert lane_none.extra_params is None
|
||||
|
||||
|
||||
def test_resolve_lane_merges_registry_capability_overrides() -> None:
|
||||
provider = _FakeProvider([])
|
||||
registry = _fake_registry(capabilities={"max_output_tokens": 42, "not_a_field": 1})
|
||||
lane = resolve_lane(provider, object(), "m", alias="ali", registry=registry)
|
||||
assert lane.capabilities is not None
|
||||
assert lane.capabilities.max_output_tokens == 42
|
||||
|
||||
|
||||
def test_vllm_attach_gates() -> None:
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
msgs = [
|
||||
{"role": "user", "content": "q"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "a",
|
||||
"_provider_content": [{"type": "reasoning_text", "text": "cot"}],
|
||||
},
|
||||
]
|
||||
# Non-Chat-Completions provider: untouched (identity).
|
||||
assert maybe_attach_vllm_chat_reasoning(msgs, _FakeProvider([]), None, "ali") is msgs # type: ignore[arg-type]
|
||||
|
||||
chat = OpenAIChatCompletionsProvider()
|
||||
# All three gates open → reasoning field attached.
|
||||
on = _fake_registry(server_compat={"server_type": "vllm"}, replay=True)
|
||||
out = maybe_attach_vllm_chat_reasoning(msgs, chat, on, "ali")
|
||||
assert out[1]["reasoning"] == "cot"
|
||||
# Operator flag off → untouched.
|
||||
off = _fake_registry(server_compat={"server_type": "vllm"}, replay=False)
|
||||
assert maybe_attach_vllm_chat_reasoning(msgs, chat, off, "ali") is msgs
|
||||
# Wrong server type → untouched.
|
||||
sglang = _fake_registry(server_compat={"server_type": "sglang"}, replay=True)
|
||||
assert maybe_attach_vllm_chat_reasoning(msgs, chat, sglang, "ali") is msgs
|
||||
|
||||
|
||||
def test_synth_reasoning_block_appends_with_source_and_skips_native() -> None:
|
||||
registry = _fake_registry(server_compat={"server_type": "vllm"})
|
||||
fidelity = [{"type": "tool_calls", "raw": True}]
|
||||
out = synth_reasoning_block(fidelity, ["thought"], registry=registry, alias="ali")
|
||||
# Appends (Google fidelity blocks survive) and tags the source server.
|
||||
assert out[0] is fidelity[0]
|
||||
assert out[1] == {"type": "reasoning_text", "text": "thought", "source": "vllm"}
|
||||
# A native reasoning-bearing block suppresses synthesis (identity return).
|
||||
native = [{"type": "thinking", "thinking": "t", "signature": "s"}]
|
||||
assert synth_reasoning_block(native, ["thought"]) is native
|
||||
|
||||
|
||||
def test_finalize_keeps_full_lane_with_tool_calls_and_clean_ids() -> None:
|
||||
blocks = [
|
||||
{"type": "thinking", "thinking": "t", "signature": "s"},
|
||||
{"type": "tool_use", "id": "toolu_1", "name": "f"},
|
||||
]
|
||||
out = finalize_provider_blocks(blocks, [""], has_tool_calls=True)
|
||||
assert out == blocks
|
||||
|
||||
|
||||
def test_mint_without_wire_id_map_raises() -> None:
|
||||
provider = _FakeProvider([])
|
||||
with pytest.raises(ValueError, match="wire_id_map"):
|
||||
model_turn(_lane(provider), [Turn.user("x")], mint=lambda o: f"p::{o}")
|
||||
# Nothing reached the provider — the guard fires before lowering.
|
||||
assert provider.calls == []
|
||||
|
||||
|
||||
def test_temperature_inherits_lane_value_when_caller_omits() -> None:
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
lane = _lane(provider, temperature=1.3)
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
assert provider.calls[0]["temperature"] == 1.3
|
||||
|
||||
|
||||
def test_temperature_caller_value_wins_over_lane() -> None:
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
lane = _lane(provider, temperature=1.3)
|
||||
model_turn(lane, [Turn.user("x")], temperature=0.9)
|
||||
assert provider.calls[0]["temperature"] == 0.9
|
||||
|
||||
|
||||
def test_temperature_unresolved_passes_none_and_wire_omits_it() -> None:
|
||||
# No caller value, no lane value → model_turn passes temperature=None,
|
||||
# and the PROVIDER layer omits the field from the wire so the server
|
||||
# default applies (house rule: code never pins one). Both halves are
|
||||
# pinned: a Python-signature default of 0.5 anywhere on this path is a
|
||||
# hidden universal pin — the exact bug the second xhigh review caught.
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(_lane(provider), [Turn.user("x")])
|
||||
assert provider.calls[0]["temperature"] is None
|
||||
|
||||
from turnstone.core.providers._openai_common import apply_temperature
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
apply_temperature(kwargs, ModelCapabilities(), None, "medium")
|
||||
assert "temperature" not in kwargs # None never reaches the wire
|
||||
apply_temperature(kwargs, ModelCapabilities(), 1.0, "medium")
|
||||
assert kwargs["temperature"] == 1.0 # a real value still does
|
||||
|
||||
|
||||
def test_resolve_lane_global_config_store_rung() -> None:
|
||||
# The global rung fires only when the operator actually STORED a
|
||||
# value; the registry default is the unset sentinel (None), so an
|
||||
# untouched install resolves None → the wire omits the field.
|
||||
provider = _FakeProvider([])
|
||||
registry = _fake_registry(temperature=None)
|
||||
store = _real_semantics_store(**{"model.temperature": 1.0})
|
||||
lane = resolve_lane(provider, object(), "m", alias="ali", registry=registry, config_store=store)
|
||||
assert lane.temperature == 1.0
|
||||
# The per-model value wins over the global rung.
|
||||
registry2 = _fake_registry(temperature=0.3)
|
||||
lane2 = resolve_lane(
|
||||
provider, object(), "m", alias="ali", registry=registry2, config_store=store
|
||||
)
|
||||
assert lane2.temperature == 0.3
|
||||
# Never-stored global → None (the round-2 headline: ConfigStore.get
|
||||
# must NOT manufacture a wire value on a miss).
|
||||
lane3 = resolve_lane(
|
||||
provider,
|
||||
object(),
|
||||
"m",
|
||||
alias="ali",
|
||||
registry=_fake_registry(temperature=None),
|
||||
config_store=_real_semantics_store(),
|
||||
)
|
||||
assert lane3.temperature is None
|
||||
|
||||
|
||||
def test_resolve_lane_reasoning_effort_operator_rungs() -> None:
|
||||
# The lane carries the OPERATOR rungs only: per-model config → stored
|
||||
# global setting → None. The in-code model definition (caps default)
|
||||
# applies at the model_turn call, so an operator-silent lane stays
|
||||
# None — the assignment scheme's "if not set, we don't send it".
|
||||
provider = _FakeProvider([])
|
||||
# Per-model config wins.
|
||||
reg = _fake_registry()
|
||||
reg.get_config.return_value.reasoning_effort = "high"
|
||||
lane = resolve_lane(provider, object(), "m", alias="ali", registry=reg)
|
||||
assert lane.reasoning_effort == "high"
|
||||
# Stored global setting rung.
|
||||
reg2 = _fake_registry()
|
||||
reg2.get_config.return_value.reasoning_effort = None
|
||||
store = _real_semantics_store(**{"model.reasoning_effort": "low"})
|
||||
lane2 = resolve_lane(provider, object(), "m", alias="ali", registry=reg2, config_store=store)
|
||||
assert lane2.reasoning_effort == "low"
|
||||
# Empty string at any rung is the unset sentinel (a valid settings
|
||||
# choice meaning fall through) — with a never-stored global (registry
|
||||
# default IS "") the lane stays operator-silent.
|
||||
reg3 = _fake_registry()
|
||||
reg3.get_config.return_value.reasoning_effort = ""
|
||||
lane3 = resolve_lane(
|
||||
provider, object(), "m", alias="ali", registry=reg3, config_store=_real_semantics_store()
|
||||
)
|
||||
assert lane3.reasoning_effort is None
|
||||
# Bare lane (no registry, no store): None.
|
||||
assert resolve_lane(provider, object(), "m").reasoning_effort is None
|
||||
|
||||
|
||||
def test_model_turn_effort_lower_rungs() -> None:
|
||||
# Below the lane's operator rungs, model_turn applies exactly one more
|
||||
# rung — the in-code model definition (caps) — then None (wire
|
||||
# omission). There is deliberately NO caller-default rung: a
|
||||
# code-chosen effort is an unvetted token on local vocabularies
|
||||
# (effort_passthrough forwards verbatim) and flips template thinking
|
||||
# toggles the operator never engaged.
|
||||
# Bare hand-built lane: nothing anywhere → the provider receives None.
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(_lane(provider), [Turn.user("x")])
|
||||
assert provider.calls[0]["reasoning_effort"] is None
|
||||
|
||||
# In-code model definition rung: a declared caps default applies…
|
||||
caps = ModelCapabilities(default_reasoning_effort="high")
|
||||
provider2 = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(_lane(provider2, capabilities=caps), [Turn.user("x")])
|
||||
assert provider2.calls[0]["reasoning_effort"] == "high"
|
||||
|
||||
# …loses to an operator value on the lane…
|
||||
provider3 = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(
|
||||
_lane(provider3, capabilities=caps, reasoning_effort="xhigh"),
|
||||
[Turn.user("x")],
|
||||
)
|
||||
assert provider3.calls[0]["reasoning_effort"] == "xhigh"
|
||||
|
||||
# …and to an explicit relay (the "none" knob stays distinct from unset).
|
||||
provider4 = _FakeProvider([CompletionResult(content="")])
|
||||
model_turn(
|
||||
_lane(provider4, capabilities=caps),
|
||||
[Turn.user("x")],
|
||||
reasoning_effort="none",
|
||||
)
|
||||
assert provider4.calls[0]["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def test_model_turn_fetches_config_once_per_call() -> None:
|
||||
# ONE get_config per plant call feeds both live flags (replay + vLLM
|
||||
# attach) — a hot-reload between them cannot mix config generations
|
||||
# within a single request.
|
||||
registry = _fake_registry(replay=True)
|
||||
provider = _FakeProvider([CompletionResult(content="")])
|
||||
lane = _lane(provider, alias="ali", registry=registry)
|
||||
model_turn(lane, [Turn.user("x")])
|
||||
assert registry.get_config.call_count == 1
|
||||
|
||||
|
||||
def test_resolve_lane_inherits_config_temperature() -> None:
|
||||
provider = _FakeProvider([])
|
||||
registry = _fake_registry(temperature=0.7)
|
||||
lane = resolve_lane(provider, object(), "m", alias="ali", registry=registry)
|
||||
assert lane.temperature == 0.7
|
||||
# Exactly ONE config fetch feeds caps + extra_params + temperature —
|
||||
# no cross-generation mixing on a registry hot-reload.
|
||||
assert registry.get_config.call_count == 1
|
||||
|
||||
|
||||
def test_resolve_lane_survives_get_config_raise() -> None:
|
||||
provider = _FakeProvider([])
|
||||
registry = MagicMock()
|
||||
registry.get_config.side_effect = ValueError("Unknown model alias")
|
||||
lane = resolve_lane(provider, object(), "m", alias="gone", registry=registry)
|
||||
# Every facet degrades to its miss behavior instead of raising into a
|
||||
# caller's constructor (the judge alias-resolution abort case).
|
||||
assert lane.capabilities is not None
|
||||
assert lane.extra_params is None
|
||||
assert lane.temperature is None
|
||||
|
||||
|
||||
def test_resolve_capabilities_survives_get_config_raise() -> None:
|
||||
from turnstone.core.model_turn import resolve_capabilities
|
||||
|
||||
provider = _FakeProvider([])
|
||||
registry = MagicMock()
|
||||
registry.get_config.side_effect = KeyError("gone")
|
||||
caps = resolve_capabilities(provider, "m", "gone", registry)
|
||||
assert caps == ModelCapabilities()
|
||||
+1
-301
@@ -8,9 +8,8 @@ import dataclasses
|
||||
import hashlib
|
||||
import types
|
||||
import urllib.parse
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
@@ -127,84 +126,6 @@ class TestLoadOIDCConfig:
|
||||
|
||||
assert cfg.allow_private_network is False
|
||||
|
||||
def test_load_oidc_config_capture_user_credential_env(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", "true")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.capture_user_credential is True
|
||||
|
||||
def test_load_oidc_config_capture_user_credential_toml(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"capture_user_credential": True},
|
||||
):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.capture_user_credential is True
|
||||
|
||||
def test_load_oidc_config_capture_default_off(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.capture_user_credential is False
|
||||
|
||||
def test_capture_appends_offline_access_to_scopes(self, monkeypatch):
|
||||
"""Enabling capture requests offline_access without operator scope edits."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"capture_user_credential": True},
|
||||
):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.scopes == "openid email profile offline_access"
|
||||
|
||||
def test_capture_scope_append_is_idempotent(self, monkeypatch):
|
||||
"""An operator who already lists offline_access doesn't get it twice."""
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_SCOPES", "openid offline_access email")
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"capture_user_credential": True},
|
||||
):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.scopes == "openid offline_access email"
|
||||
|
||||
def test_no_capture_leaves_scopes_untouched(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_SCOPES", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CAPTURE_USER_CREDENTIAL", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.scopes == "openid email profile"
|
||||
|
||||
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
||||
@@ -910,40 +831,6 @@ class TestValidateDiscoveredEndpoint:
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_accepts_entra_userinfo_on_graph(self):
|
||||
"""discover_oidc accepts Entra's cross-host userinfo on graph.microsoft.com
|
||||
via the built-in allow-list, so Azure AD OIDC works out of the box with no
|
||||
trusted_endpoint_hosts override."""
|
||||
tenant = "11111111-1111-1111-1111-111111111111"
|
||||
config = _make_config(
|
||||
issuer=f"https://login.microsoftonline.com/{tenant}/v2.0",
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
discovery_doc = {
|
||||
"authorization_endpoint": f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
||||
"token_endpoint": f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
||||
"userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo",
|
||||
"jwks_uri": f"https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
assert result.enabled is True
|
||||
assert result.userinfo_endpoint == "https://graph.microsoft.com/oidc/userinfo"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_rejects_http_endpoint(self):
|
||||
"""discover_oidc returns enabled=False when an endpoint is http:// in prod."""
|
||||
config = _make_config(
|
||||
@@ -2493,193 +2380,6 @@ class TestDiscoverOIDC:
|
||||
assert result.enabled is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime re-discovery (boot-time transient failure self-heal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRuntimeRediscovery:
|
||||
"""A node that boots during a transient IdP outage keeps enabled=False
|
||||
forever without a runtime retry — OIDC login stays dark and every
|
||||
oauth_obo mint on the node fails "transient" until an operator restarts
|
||||
it. ``maybe_rediscover_oidc`` heals that, cooldown-gated; config-caused
|
||||
discovery failures (bad issuer, SSRF rejection) are NOT retried."""
|
||||
|
||||
_PUBLIC_ADDR = [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
|
||||
def test_fetch_failure_marks_config_retryable(self):
|
||||
"""The transient branch (IdP unreachable) sets discovery_retryable."""
|
||||
config = _make_config(
|
||||
authorization_endpoint="", token_endpoint="", userinfo_endpoint="", jwks_uri=""
|
||||
)
|
||||
|
||||
async def _raise_connect(url):
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(_raise_connect)
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
return await discover_oidc(config)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert result.enabled is False
|
||||
assert result.discovery_retryable is True
|
||||
|
||||
def test_config_rejection_is_not_retryable(self):
|
||||
"""An SSRF-rejected issuer is a config problem — retrying is pointless."""
|
||||
config = _make_config(
|
||||
issuer="https://auth.internal.example",
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
return await discover_oidc(config)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert result.enabled is False
|
||||
assert result.discovery_retryable is False
|
||||
|
||||
def _disabled_retryable_state(self) -> SimpleNamespace:
|
||||
cfg = _make_config(
|
||||
enabled=False,
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
cfg = dataclasses.replace(cfg, discovery_retryable=True)
|
||||
return SimpleNamespace(oidc_config=cfg)
|
||||
|
||||
def test_rediscover_swaps_enabled_config_on_success(self):
|
||||
"""Drives the REAL discover_oidc through a mocked HTTP discovery GET
|
||||
(NOT a mock of discover_oidc itself): discover_oidc preserves the input
|
||||
config's ``enabled`` on success and only clears it on failure, so a
|
||||
probe started from the disabled boot config must first force enabled=True
|
||||
or the recovered config never installs. An earlier version of this test
|
||||
mocked discover_oidc to return enabled=True and so masked exactly that
|
||||
dead-code bug."""
|
||||
from turnstone.core.oidc import maybe_rediscover_oidc
|
||||
|
||||
state = self._disabled_retryable_state() # issuer=https://idp.example.com
|
||||
discovery_doc = {
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
await maybe_rediscover_oidc(state)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
# The real discover_oidc succeeded and the recovered config was installed.
|
||||
assert state.oidc_config.enabled is True
|
||||
assert state.oidc_config.token_endpoint == "https://idp.example.com/token"
|
||||
# The healed config no longer advertises a retryable failure.
|
||||
assert state.oidc_config.discovery_retryable is False
|
||||
|
||||
def test_rediscover_latches_terminal_on_config_error_and_stops_probing(self):
|
||||
"""Review finding: probing with enabled forced True carries the
|
||||
retryable boot flag into discover_oidc, whose config-error branches must
|
||||
latch discovery_retryable=False (terminal) — and maybe_rediscover must
|
||||
INSTALL that terminal config — or a config-invalid IdP (endpoint failing
|
||||
SSRF/same-origin) re-probes every cooldown window forever. Drives the
|
||||
real discover_oidc: the discovered token_endpoint is on a foreign host,
|
||||
so validation rejects it as a config error."""
|
||||
from turnstone.core.oidc import maybe_rediscover_oidc
|
||||
|
||||
state = self._disabled_retryable_state() # issuer=https://idp.example.com
|
||||
bad_doc = {
|
||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
||||
"token_endpoint": "https://attacker.example/token", # foreign host
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = bad_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
probes = {"n": 0}
|
||||
|
||||
async def _get(url):
|
||||
probes["n"] += 1
|
||||
return mock_response
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(_get)
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
await maybe_rediscover_oidc(state)
|
||||
# Same window: cooldown already gates a second probe.
|
||||
await maybe_rediscover_oidc(state)
|
||||
# Force the cooldown open — but the config is now terminal, so
|
||||
# the retryable guard should short-circuit before any probe.
|
||||
state.oidc_rediscover_last = None
|
||||
await maybe_rediscover_oidc(state)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
# Still disabled, but LATCHED terminal (not retryable) — one probe only.
|
||||
assert state.oidc_config.enabled is False
|
||||
assert state.oidc_config.discovery_retryable is False
|
||||
assert probes["n"] == 1
|
||||
|
||||
def test_rediscover_cooldown_gates_repeat_probes(self):
|
||||
from turnstone.core.oidc import maybe_rediscover_oidc
|
||||
|
||||
state = self._disabled_retryable_state()
|
||||
still_down = state.oidc_config # discover keeps returning disabled
|
||||
with patch(
|
||||
"turnstone.core.oidc.discover_oidc", new=AsyncMock(return_value=still_down)
|
||||
) as disc:
|
||||
asyncio.run(maybe_rediscover_oidc(state))
|
||||
asyncio.run(maybe_rediscover_oidc(state))
|
||||
asyncio.run(maybe_rediscover_oidc(state))
|
||||
# One IdP probe per cooldown window, however many callers ask.
|
||||
disc.assert_awaited_once()
|
||||
assert state.oidc_config.enabled is False
|
||||
|
||||
def test_rediscover_noop_when_not_retryable_or_enabled(self):
|
||||
from turnstone.core.oidc import maybe_rediscover_oidc
|
||||
|
||||
# Operator-disabled (retryable False): never probes.
|
||||
state = SimpleNamespace(
|
||||
oidc_config=_make_config(
|
||||
enabled=False,
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
)
|
||||
with patch("turnstone.core.oidc.discover_oidc", new=AsyncMock()) as disc:
|
||||
asyncio.run(maybe_rediscover_oidc(state))
|
||||
disc.assert_not_awaited()
|
||||
# Already enabled: never probes.
|
||||
state2 = SimpleNamespace(oidc_config=_make_config(enabled=True))
|
||||
with patch("turnstone.core.oidc.discover_oidc", new=AsyncMock()) as disc2:
|
||||
asyncio.run(maybe_rediscover_oidc(state2))
|
||||
disc2.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan integration: initialize_oidc_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Storage CRUD tests for ``oidc_user_credentials`` (single-credential MCP minting, #551).
|
||||
|
||||
Validates the storage-protocol additions for the captured per-(user, issuer)
|
||||
IdP refresh token:
|
||||
|
||||
- ``upsert_oidc_user_credential`` (create-or-replace semantics)
|
||||
- ``get_oidc_user_credential``
|
||||
- ``update_oidc_user_credential_refresh`` (rotation write-back)
|
||||
- ``delete_oidc_user_credential``
|
||||
- ``delete_user`` cascade
|
||||
|
||||
plus the ``MCPTokenStore`` encrypt/decrypt wrappers over the same rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.conftest import make_mcp_token_cipher
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
|
||||
ISS = "https://login.example.test/tenant-1/v2.0"
|
||||
|
||||
|
||||
class TestUpsertAndGet:
|
||||
def test_round_trip(self, backend) -> None:
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1")
|
||||
row = backend.get_oidc_user_credential("u1", ISS)
|
||||
assert row is not None
|
||||
assert row["user_id"] == "u1"
|
||||
assert row["issuer"] == ISS
|
||||
assert row["refresh_token_ct"] == b"ct-1"
|
||||
assert row["created"] == row["last_refreshed"]
|
||||
|
||||
def test_get_missing_returns_none(self, backend) -> None:
|
||||
assert backend.get_oidc_user_credential("nobody", ISS) is None
|
||||
|
||||
def test_keyed_by_user_and_issuer(self, backend) -> None:
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1")
|
||||
assert backend.get_oidc_user_credential("u1", "https://other.test") is None
|
||||
assert backend.get_oidc_user_credential("u2", ISS) is None
|
||||
|
||||
def test_upsert_replaces_on_conflict(self, backend) -> None:
|
||||
"""A fresh login must overwrite a stale credential; ``created`` survives.
|
||||
|
||||
Plants a distinctly-past ``created`` via direct SQL so the assertion
|
||||
actually detects a reset (comparing two upserts milliseconds apart would
|
||||
pass at second granularity even if the on-conflict clause reset created).
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import oidc_user_credentials
|
||||
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-old")
|
||||
planted = "2020-01-01T00:00:00"
|
||||
with backend._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.update(oidc_user_credentials)
|
||||
.where(
|
||||
(oidc_user_credentials.c.user_id == "u1")
|
||||
& (oidc_user_credentials.c.issuer == ISS)
|
||||
)
|
||||
.values(created=planted)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-new")
|
||||
second = backend.get_oidc_user_credential("u1", ISS)
|
||||
assert second is not None
|
||||
assert second["refresh_token_ct"] == b"ct-new"
|
||||
# created is PRESERVED across the replace (not reset to now).
|
||||
assert second["created"] == planted
|
||||
# last_refreshed, by contrast, advances off the planted-past value.
|
||||
assert second["last_refreshed"] != planted
|
||||
|
||||
|
||||
class TestRotationWriteBack:
|
||||
def test_update_rewrites_token(self, backend) -> None:
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1")
|
||||
assert backend.update_oidc_user_credential_refresh("u1", ISS, refresh_token_ct=b"ct-2")
|
||||
row = backend.get_oidc_user_credential("u1", ISS)
|
||||
assert row is not None
|
||||
assert row["refresh_token_ct"] == b"ct-2"
|
||||
|
||||
def test_update_missing_returns_false(self, backend) -> None:
|
||||
assert not backend.update_oidc_user_credential_refresh(
|
||||
"nobody", ISS, refresh_token_ct=b"ct"
|
||||
)
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_existing(self, backend) -> None:
|
||||
backend.upsert_oidc_user_credential("u1", ISS, refresh_token_ct=b"ct-1")
|
||||
assert backend.delete_oidc_user_credential("u1", ISS)
|
||||
assert backend.get_oidc_user_credential("u1", ISS) is None
|
||||
|
||||
def test_delete_missing_returns_false(self, backend) -> None:
|
||||
assert not backend.delete_oidc_user_credential("nobody", ISS)
|
||||
|
||||
def test_delete_user_cascades_credential(self, backend) -> None:
|
||||
backend.upsert_oidc_user_credential("u-doomed", ISS, refresh_token_ct=b"ct-1")
|
||||
backend.delete_user("u-doomed")
|
||||
assert backend.get_oidc_user_credential("u-doomed", ISS) is None
|
||||
|
||||
|
||||
class TestTokenStoreWrappers:
|
||||
def test_encrypt_decrypt_round_trip(self, backend) -> None:
|
||||
store = MCPTokenStore(backend, make_mcp_token_cipher())
|
||||
store.upsert_oidc_credential("u1", ISS, refresh_token="rt-plaintext")
|
||||
plain = store.get_oidc_credential("u1", ISS)
|
||||
assert plain is not None
|
||||
assert plain["refresh_token"] == "rt-plaintext"
|
||||
# Ciphertext at rest — the raw row must not contain the plaintext.
|
||||
raw = backend.get_oidc_user_credential("u1", ISS)
|
||||
assert raw is not None
|
||||
assert b"rt-plaintext" not in raw["refresh_token_ct"]
|
||||
|
||||
def test_redeem_write_back_round_trip(self, backend) -> None:
|
||||
store = MCPTokenStore(backend, make_mcp_token_cipher())
|
||||
store.upsert_oidc_credential("u1", ISS, refresh_token="rt-first")
|
||||
# CAS matches the stored value → write lands.
|
||||
assert store.update_oidc_credential_after_redeem(
|
||||
"u1", ISS, refresh_token="rt-rotated", expected_current="rt-first"
|
||||
)
|
||||
plain = store.get_oidc_credential("u1", ISS)
|
||||
assert plain is not None
|
||||
assert plain["refresh_token"] == "rt-rotated"
|
||||
|
||||
def test_redeem_write_back_cas_skips_when_credential_changed(self, backend) -> None:
|
||||
"""The rotation write is a no-op when the stored credential no longer
|
||||
matches what the mint read (a concurrent login capture refreshed it) —
|
||||
so a stale rotation can't clobber a fresh login token."""
|
||||
store = MCPTokenStore(backend, make_mcp_token_cipher())
|
||||
store.upsert_oidc_credential("u1", ISS, refresh_token="rt-login-fresh")
|
||||
# Mint read "rt-old", but the stored value is now the fresh login token.
|
||||
assert not store.update_oidc_credential_after_redeem(
|
||||
"u1", ISS, refresh_token="rt-rotated-from-old", expected_current="rt-old"
|
||||
)
|
||||
# The fresh login token survived.
|
||||
plain = store.get_oidc_credential("u1", ISS)
|
||||
assert plain is not None
|
||||
assert plain["refresh_token"] == "rt-login-fresh"
|
||||
|
||||
def test_get_missing_returns_none(self, backend) -> None:
|
||||
store = MCPTokenStore(backend, make_mcp_token_cipher())
|
||||
assert store.get_oidc_credential("nobody", ISS) is None
|
||||
|
||||
def test_delete_via_store(self, backend) -> None:
|
||||
store = MCPTokenStore(backend, make_mcp_token_cipher())
|
||||
store.upsert_oidc_credential("u1", ISS, refresh_token="rt")
|
||||
assert store.delete_oidc_credential("u1", ISS)
|
||||
assert store.get_oidc_credential("u1", ISS) is None
|
||||
@@ -8,7 +8,6 @@ on the HTTP handler logic, request/response wiring, and storage side-effects.
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -191,23 +190,6 @@ class TestOIDCAuthorize:
|
||||
resp = client.get("/v1/api/auth/oidc/authorize")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_disabled_retryable_triggers_login_self_heal(self, storage: SQLiteBackend) -> None:
|
||||
"""Review finding: the LOGIN path must trigger runtime rediscovery too,
|
||||
not just the obo mint path — otherwise a single-node install whose node
|
||||
booted during a transient IdP outage stays login-dark forever. A
|
||||
disabled+retryable config makes authorize call maybe_rediscover_oidc."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config(enabled=False, discovery_retryable=True)
|
||||
app.state.auth_storage = storage
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
with patch("turnstone.core.auth.maybe_rediscover_oidc", new=AsyncMock()) as heal:
|
||||
client.get("/v1/api/auth/oidc/authorize")
|
||||
heal.assert_awaited_once()
|
||||
|
||||
def test_no_storage_returns_503(self) -> None:
|
||||
app = Starlette(
|
||||
routes=[Mount("/v1", routes=[Route("/api/auth/oidc/authorize", _oidc_authorize)])]
|
||||
@@ -745,323 +727,6 @@ class TestOIDCCallback:
|
||||
mock_fetch_jwks.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-credential capture tests (issue #551)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOIDCCallbackCapture:
|
||||
"""Capture of the IdP refresh token at login (``capture_user_credential``)."""
|
||||
|
||||
def _capture_client(
|
||||
self,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
*,
|
||||
capture: bool = True,
|
||||
with_store: bool = True,
|
||||
) -> tuple[TestClient, Any, OIDCConfig]:
|
||||
"""Client wired like ``authorize_client`` plus a real MCPTokenStore."""
|
||||
import dataclasses
|
||||
|
||||
from tests.conftest import make_mcp_token_cipher
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
|
||||
cfg = dataclasses.replace(oidc_config, capture_user_credential=capture)
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount("/v1", routes=[Route("/api/auth/oidc/callback", _oidc_callback)]),
|
||||
],
|
||||
)
|
||||
app.state.oidc_config = cfg
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
store = MCPTokenStore(storage, make_mcp_token_cipher()) if with_store else None
|
||||
app.state.mcp_token_store = store
|
||||
return TestClient(app, raise_server_exceptions=False), store, cfg
|
||||
|
||||
def _login(
|
||||
self,
|
||||
client: TestClient,
|
||||
storage: SQLiteBackend,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
*,
|
||||
tokens: dict[str, Any],
|
||||
state: str = "valid-state",
|
||||
) -> Any:
|
||||
storage.create_oidc_pending_state(state, "test-nonce", "test-verifier", "test-audience")
|
||||
mock_exchange.return_value = tokens
|
||||
mock_validate.return_value = {
|
||||
"sub": "user123",
|
||||
"email": "u@example.com",
|
||||
"nonce": "test-nonce",
|
||||
}
|
||||
mock_provision.return_value = {"user_id": "test-admin", "username": "testadmin"}
|
||||
return client.get(
|
||||
f"/v1/api/auth/oidc/callback?code=authcode&state={state}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_capture_persists_credential(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "fake.jwt.token", "access_token": "at", "refresh_token": "rt-1"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
assert store is not None
|
||||
plain = store.get_oidc_credential("test-admin", cfg.issuer)
|
||||
assert plain is not None
|
||||
assert plain["refresh_token"] == "rt-1"
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_capture_success_primes_user_pools(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
"""Re-login is the OBO restore moment (#836): a successful
|
||||
credential capture for a user with a LIVE session schedules a
|
||||
pool prime so a previously dropped obo catalog returns to their
|
||||
open workstreams — obo has no consent flow, so nothing else
|
||||
re-primes them after re-login."""
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
primed: list[str] = []
|
||||
client.app.state.mcp_client = SimpleNamespace( # type: ignore[attr-defined]
|
||||
prime_user_pools=primed.append,
|
||||
has_live_session_listener=lambda _uid: True,
|
||||
)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "fake.jwt.token", "access_token": "at", "refresh_token": "rt-1"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert primed == ["test-admin"]
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_no_capture_no_prime(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
"""No refresh token in the response → no capture → no prime
|
||||
(the prime is gated on a persisted credential, not on login)."""
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
primed: list[str] = []
|
||||
client.app.state.mcp_client = SimpleNamespace( # type: ignore[attr-defined]
|
||||
prime_user_pools=primed.append,
|
||||
has_live_session_listener=lambda _uid: True,
|
||||
)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "fake.jwt.token", "access_token": "at"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert primed == []
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_capture_without_live_session_does_not_prime(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
"""Routine SSO re-login with nothing open must not fan out pool
|
||||
warms — the prime exists to heal LIVE sessions only."""
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
primed: list[str] = []
|
||||
client.app.state.mcp_client = SimpleNamespace( # type: ignore[attr-defined]
|
||||
prime_user_pools=primed.append,
|
||||
has_live_session_listener=lambda _uid: False,
|
||||
)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "fake.jwt.token", "access_token": "at", "refresh_token": "rt-1"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
# Credential captured, but no live session → no prime.
|
||||
assert store is not None
|
||||
assert store.get_oidc_credential("test-admin", cfg.issuer) is not None
|
||||
assert primed == []
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_second_login_replaces_credential(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "t", "refresh_token": "rt-old"},
|
||||
state="s1",
|
||||
)
|
||||
self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "t", "refresh_token": "rt-new"},
|
||||
state="s2",
|
||||
)
|
||||
assert store is not None
|
||||
plain = store.get_oidc_credential("test-admin", cfg.issuer)
|
||||
assert plain is not None
|
||||
assert plain["refresh_token"] == "rt-new"
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_no_refresh_token_logs_and_login_succeeds(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, store, cfg = self._capture_client(storage, oidc_config)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "fake.jwt.token", "access_token": "at"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
assert store is not None
|
||||
assert store.get_oidc_credential("test-admin", cfg.issuer) is None
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_capture_disabled_persists_nothing(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, store, cfg = self._capture_client(storage, oidc_config, capture=False)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "t", "refresh_token": "rt-present"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert store is not None
|
||||
assert store.get_oidc_credential("test-admin", cfg.issuer) is None
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_missing_store_login_still_succeeds(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, _store, _cfg = self._capture_client(storage, oidc_config, with_store=False)
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "t", "refresh_token": "rt-1"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
|
||||
@patch("turnstone.core.auth.provision_oidc_user")
|
||||
@patch("turnstone.core.auth.validate_id_token")
|
||||
@patch("turnstone.core.auth.exchange_code", new_callable=AsyncMock)
|
||||
def test_store_failure_does_not_block_login(
|
||||
self,
|
||||
mock_exchange: AsyncMock,
|
||||
mock_validate: Any,
|
||||
mock_provision: Any,
|
||||
storage: SQLiteBackend,
|
||||
oidc_config: OIDCConfig,
|
||||
) -> None:
|
||||
client, store, _cfg = self._capture_client(storage, oidc_config)
|
||||
assert store is not None
|
||||
with patch.object(store, "upsert_oidc_credential", side_effect=RuntimeError("boom")):
|
||||
resp = self._login(
|
||||
client,
|
||||
storage,
|
||||
mock_exchange,
|
||||
mock_validate,
|
||||
mock_provision,
|
||||
tokens={"id_token": "t", "refresh_token": "rt-1"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert "oidc_success=1" in resp.headers["location"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin OIDC identity endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1115,67 +780,6 @@ class TestAdminOIDCIdentities:
|
||||
# Verify it's gone
|
||||
assert storage.get_oidc_identity("https://idp.example.com", "sub-456") is None
|
||||
|
||||
def test_delete_identity_revokes_credential_and_purges_obo_cache(
|
||||
self, storage: SQLiteBackend
|
||||
) -> None:
|
||||
"""#551 follow-up: unlinking an identity must revoke the captured
|
||||
credential AND purge the user's already-minted oauth_obo cache rows —
|
||||
deleting only the credential leaves live cached bearers authorizing
|
||||
dispatch until TTL. The response/audit report what was actually cut."""
|
||||
from tests.conftest import make_mcp_token_cipher
|
||||
from turnstone.core.mcp_crypto import MCPTokenStore
|
||||
|
||||
issuer = "https://idp.example.com"
|
||||
store = MCPTokenStore(storage, make_mcp_token_cipher(), node_id="test")
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
Route(
|
||||
"/api/admin/oidc-identities",
|
||||
admin_delete_oidc_identity,
|
||||
methods=["DELETE"],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
app.state.mcp_token_store = store
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
storage.create_oidc_identity(issuer, "sub-1", "user-x", "x@example.com")
|
||||
store.upsert_oidc_credential("user-x", issuer, refresh_token="rt-live")
|
||||
storage.create_mcp_server(
|
||||
server_id="srv-obo",
|
||||
name="obo-srv",
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.com/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience="api://mcp-a",
|
||||
)
|
||||
store.create_user_token(
|
||||
"user-x",
|
||||
"obo-srv",
|
||||
access_token="minted-at",
|
||||
refresh_token=None,
|
||||
expires_at="2026-12-31T00:00:00",
|
||||
scopes=None,
|
||||
as_issuer=issuer,
|
||||
audience="api://mcp-a",
|
||||
)
|
||||
|
||||
resp = client.delete(f"/v1/api/admin/oidc-identities?issuer={issuer}&subject=sub-1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["obo_credential_revoked"] is True
|
||||
assert body["obo_cache_rows_purged"] == 1
|
||||
# Credential gone → no future mints; cache row gone → no live cached bearer.
|
||||
assert store.get_oidc_credential("user-x", issuer) is None
|
||||
assert storage.get_mcp_user_token("user-x", "obo-srv") is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, admin_client: TestClient) -> None:
|
||||
resp = admin_client.delete(
|
||||
"/v1/api/admin/oidc-identities?issuer=https://no.such&subject=nope",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user