mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -45,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 }}
|
||||
|
||||
|
||||
-217
@@ -14,223 +14,6 @@ experimental line:
|
||||
|
||||
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.)
|
||||
|
||||
## [1.7.3]
|
||||
|
||||
A small feature and maintenance patch for the 1.7 line. No schema migrations
|
||||
and no new configuration knobs.
|
||||
|
||||
### Added
|
||||
|
||||
- **OpenAI GPT-5.6 (Sol/Terra/Luna) support** — the Responses provider
|
||||
understands the GPT-5.6 family: the `reasoning.mode` control, the new
|
||||
`max` effort tier, and `text.verbosity`, with golden wire payloads pinning
|
||||
the request shapes. The `openai` dependency floor moves to `>=2.44`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Engineer base prompt hardened with process discipline** — the default
|
||||
base prompt for non-coordinator sessions now works in phases scaled to the
|
||||
size of the change, defaults to red-green for testable work, scopes to the
|
||||
smallest sufficient diff, stops to report after repeated failed attempts
|
||||
instead of thrashing, reports only observed results, and delegates
|
||||
exploration to `task_agent`. Persona prompts freeze into the workstream
|
||||
stamp at creation, so this reaches new workstreams only.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unknown reasoning-mode warnings name the allowed modes** — a model
|
||||
definition with an unrecognized reasoning mode now logs the valid options
|
||||
instead of leaving the operator to guess.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **HYPOTHESIS.md / PRIMER.md** — the control normal form is tightened and
|
||||
the factored Q_E reading is carried into the glossary; the plain-language
|
||||
PRIMER stays in sync.
|
||||
|
||||
## [1.7.2]
|
||||
|
||||
A feature-bearing patch for the 1.7 line. Rather than hold this work for the
|
||||
|
||||
+21
-21
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ One sentence to keep: **the model proposes; the gate disposes.** The model's out
|
||||
|
||||
| Plain name | What it does | In the formal doc |
|
||||
|---|---|---|
|
||||
| The owner | The human — or sign-off group — the run acts for; the only place new permissions can come from | the trusted principal |
|
||||
| The owner | The human or account the run acts for; the only party who can grant new permissions | the trusted principal |
|
||||
| The memory | Everything the run knows: task, plan, transcript, and the ledger of what has been done | the state, *s* |
|
||||
| The prompt builder | Decides which slice of memory the model gets to see this step | the lowering, π |
|
||||
| The model | The black box that reads the prompt and writes a proposal | the plant, M_W |
|
||||
@@ -67,7 +67,7 @@ Three consequences people miss:
|
||||
|
||||
**Validation must not act.** A "validator" that resolves a URL, expands a template that fires a webhook, or evaluates an argument has already acted — inside the check. The gate must be pure: it reads the proposal and the memory and outputs yes or no. If deciding requires touching the world, that touch is itself an action and goes through the gate.
|
||||
|
||||
**Anything irreversible is decided at the gate.** The verifier can reject a bad *result*; it cannot unsend the email. So the question "can we take this back, and until when?" is asked before execution — which means each tool declares, up front, how reversible its effects are, and the gate reads that declaration when it decides; the mark that comes back in the result record is confirmation for the books, not the gate's source — the gate needed the answer before the tool ever ran.
|
||||
**Anything irreversible is decided at the gate.** The verifier can reject a bad *result*; it cannot unsend the email. So the question "can we take this back, and until when?" is asked before execution — which means each tool's effect record has to carry a reversibility mark, or the gate can't ask it.
|
||||
|
||||
Two honest asterisks. First, the gate checks a snapshot: it approves against the world *as its memory describes it*, and the world can move between check and commit. For actions that race the world — spend against a balance, write against a row — the tool itself must bind check to commit (compare-and-swap), or you have a classic time-of-check/time-of-use hole. The gate decides; for those effects, the tool enforces. Second, a gate is only as binding as the authority behind the tools. A tool process holding standing credentials — a database connection with every grant, an environment full of long-lived secrets — doesn't need the model's proposal to act, and against it the gate's "no" is a decision with nothing enforcing it. **A gate in front of an omnipotent tool is a suggestion.** The fix is to make the approval *be* the key: each authorized action carries a short-lived credential scoped to exactly that action, that resource, that operation, so tools hold no standing power at all.
|
||||
|
||||
@@ -85,13 +85,13 @@ A measurement is a risk metric. A proof is a certificate. Keeping those two word
|
||||
|
||||
Formally, security here is a *reach-avoid* problem: reach a good stop, never touch the danger zone, **while an adversary picks the worst tool outputs your setup permits**. That last clause is the formal home of prompt injection: injection isn't "the model misbehaved," it's the environment optimized to bend your loop — poisoned pages, malicious tool descriptions, crafted responses.
|
||||
|
||||
Two different numbers fall out here, and dashboards love to collapse them: *success* (reached an accepted end before anything went wrong — a safe refusal counts against it) and *safety* (never touched the danger zone — a safe refusal is perfectly safe). Track both. They move independently. And both are scored by your own stop rule — they count what the shell *declared* a success. Whether a declared success was actually *right* is a third, harder number that no dashboard inside the system can produce; only a judge outside the run — a test suite, an audit, ground truth — can.
|
||||
Two different numbers fall out here, and dashboards love to collapse them: *success* (reached the right end before anything went wrong — a safe refusal counts against it) and *safety* (never touched the danger zone — a safe refusal is perfectly safe). Track both. They move independently.
|
||||
|
||||
The gate handles the visible half of injection: the model, freshly poisoned, proposes emailing your credentials somewhere, and the gate refuses — and injection or not, the action does not happen. But the deeper attack doesn't propose a bad action today. It rewrites *what the run believes its job is* — it edits the plan — and then every future action looks locally reasonable against a corrupted plan. So memory has to be partitioned: **data** (tool results, fetched pages, retrieved documents — content the world supplied) and **control** (the plan, the permissions, what is authorized next). The security claim is conditional on that partition holding: untrusted content lands in data, always. And "trust" is really two questions pointing opposite ways, which is worth keeping straight: *can this leak?* (a value is as secret as the most-secret thing that fed it — secrecy flows **upward**) and *can this boss us around?* (a value is as trustworthy as the least-trustworthy thing that fed it — authority flows **downward**). Untrusted content is safe as *data* precisely because the second question keeps it off the control side; a secret is kept out of the model by the first. Lowering either barrier on purpose — declassifying a secret, promoting data to trusted — is an explicit decision the owner makes, never a thing that happens by accident when two values are combined.
|
||||
The gate handles the visible half of injection: the model, freshly poisoned, proposes emailing your credentials somewhere, and the gate refuses — and injection or not, the action does not happen. But the deeper attack doesn't propose a bad action today. It rewrites *what the run believes its job is* — it edits the plan — and then every future action looks locally reasonable against a corrupted plan. So memory has to be partitioned: **data** (tool results, fetched pages, retrieved documents — content the world supplied) and **control** (the plan, the permissions, what is authorized next). The security claim is conditional on that partition holding: untrusted content lands in data, always.
|
||||
|
||||
Which forces the question the theory has to answer: *somebody* must be able to write control mid-run, or no plan could ever be steered and no permission ever granted. The answer is a small hierarchy with a top the model can't reach. The simplest top is one owner — but it needn't be a single person: a two-person sign-off, a quorum, several authenticated people each holding different scopes all work equally well, because the one property that matters is the same for all of them — the thing that can grant new power is a *human decision*, never a model:
|
||||
Which forces the question the theory has to answer: *somebody* must be able to write control mid-run, or no plan could ever be steered and no permission ever granted. The answer is a small hierarchy with exactly one party at the top:
|
||||
|
||||
- **The top alone widens.** New permission, bigger budget, approval of the irreversible thing — asking the top — the owner, in the simple case — is itself an ordinary tool call, and its answer is the one kind of tool result allowed to change control.
|
||||
- **The owner alone widens.** New permission, bigger budget, approval of the irreversible thing — asking the owner is itself an ordinary tool call, and the owner's answer is the one kind of tool result allowed to change control.
|
||||
- **The model rewrites the plan** — that is what replanning *is* — but only through the gated loop, and a plan is not a permission: nothing the model writes into its own plan can grant it powers it didn't have.
|
||||
- **Everything else is data.** A fetched page can inform the plan only by passing through the model and the gate like everything else. It can suggest. It cannot promote itself to boss.
|
||||
- **AI judges only tighten.** Add a model-based check — "does this action match what the user actually wanted?" — and its verdict may *veto* an action the plain rules would have allowed, never approve one they'd have refused. A judge that can approve is a tricked judge that can open the vault. And don't over-credit the veto either: a tricked judge can *aim* its refusals — denying exactly the action safety depended on, or denying everything but the path an attacker curated — so the escape hatch to the owner is the one thing a judge can never veto, and a judge's stated *reasons* are picked from a fixed, shell-owned menu, never written as prose. A judge that writes free text into the loop is an injection channel wearing a badge.
|
||||
@@ -102,9 +102,9 @@ One more rule closes the loop: transformations don't launder trust. A *summary*
|
||||
|
||||
The formal document's appendix works the operational cases in full; here they are at speed.
|
||||
|
||||
**The ledger, and the three-way distinction that keeps it honest.** Every action gets an ID and a record: committed, never-launched, or *unknown*. "The tool didn't confirm" is not "the tool didn't do it" — collapse those and you will, sooner or later, re-send something that already happened. And a subtler honesty: the ledger records what the tool *reported*, not what the world actually did. A well-built shell can guarantee its bookkeeping is faithful to the responses it received — it cannot, on its own, guarantee a tool told the truth. A tool that returns a clean "done!" for something it never did puts a clean "done!" in your ledger. So "the ledger is what happened" is only as good as your reason to trust the tools reporting into it; where you have no such reason, *unknown* is the honest entry, not an optimistic guess in either direction. The double-send bug has one reliable cure: **journal before dispatch.** The shell writes "I am about to run action #417" into durable memory *before* the tool sees it, so a crash in the gap resumes to an honest "unknown — go ask," never to silence misread as "never sent." Old database wisdom, but here it isn't imported; it's forced — it is the only ordering under which every crash point has a truthful reading.
|
||||
**The ledger, and the three-way distinction that keeps it honest.** Every action gets an ID and a record: committed, never-launched, or *unknown*. "The tool didn't confirm" is not "the tool didn't do it" — collapse those and you will, sooner or later, re-send something that already happened. The double-send bug has one reliable cure: **journal before dispatch.** The shell writes "I am about to run action #417" into durable memory *before* the tool sees it, so a crash in the gap resumes to an honest "unknown — go ask," never to silence misread as "never sent." Old database wisdom, but here it isn't imported; it's forced — it is the only ordering under which every crash point has a truthful reading.
|
||||
|
||||
**Crashes aren't finishes.** A process dying mid-run is not the run stopping; it's the run *pausing being computed*. Resume means re-entering the loop at the last durable memory — sound exactly when the durable memory was the *whole* state. Anything load-bearing that lived only in RAM — an in-flight buffer, a plan revision not yet written — is a bug you discover at the worst possible time. Recovery is where you find out whether your state was really your state. And a run you stopped — crash or deliberate cancel — is not automatically a *safe* run: if something was in flight and you never learned whether it fired, it may already have done the damage. "We stopped in time" is only true when everything in flight resolved to something safe; an outstanding *unknown* has to be treated as possibly-bad, the same optimism the ledger warns against, one level up.
|
||||
**Crashes aren't finishes.** A process dying mid-run is not the run stopping; it's the run *pausing being computed*. Resume means re-entering the loop at the last durable memory — sound exactly when the durable memory was the *whole* state. Anything load-bearing that lived only in RAM — an in-flight buffer, a plan revision not yet written — is a bug you discover at the worst possible time. Recovery is where you find out whether your state was really your state.
|
||||
|
||||
**Two innocent actions can be guilty together.** Models emit several tool calls per turn. "Read the secret" passes review. "Post to the web" passes review. The pair is an exfiltration channel — so the gate authorizes the *set*, atomically, with the interactions checked, not each element in isolation.
|
||||
|
||||
@@ -140,7 +140,7 @@ This is a hypothesis, and it says out loud what would kill it. The tests, in pla
|
||||
- **The red-team test.** Swap sampled tool outputs for worst-case ones: injected pages, poisoned metadata, malformed replies. The design must survive the worst permitted world, not the average one.
|
||||
- **Gates versus begging.** The theory predicts deterministic gating beats prompt-level pleading. If "please be careful" alone matches real gates on security outcomes, the controller-versus-model story is wrong.
|
||||
- **The compression hunt.** Exhibit a compact, provably sound progress certificate for a frontier-scale model on a nontrivial task family, and the central conjecture falls — constructively.
|
||||
- **The desk probe.** Take a task family with a *proven* memory floor — so "it needed the whole picture at once" is someone else's theorem, not our excuse — scale it past the window, and watch: the wall predicts a *ceiling*, not a cliff — past the boundary, a success rate that stays capped no matter how many retries you buy. A family solved reliably out there, without new shell tricks for splitting the work, kills the wall.
|
||||
- **The desk probe.** Take a task family with a *proven* memory floor — so "it needed the whole picture at once" is someone else's theorem, not our excuse — scale it past the window, and watch: the wall predicts collapse at the boundary, not graceful degradation.
|
||||
|
||||
## Who else landed here
|
||||
|
||||
@@ -148,7 +148,7 @@ The formal document keeps three honesty tiers. **Borrowed**: real theorems, cite
|
||||
|
||||
## What to remember
|
||||
|
||||
The model proposes; the gate disposes. No is the default, and a refusal must be safe. Only the top of the trust hierarchy widens permissions — a human decision, never the model, a tool result, a summary, or a judge. "Didn't confirm" is not "didn't happen." The desk is finite and the proof doesn't compress, so you measure — and you say *measurement* when you mean measurement. A robot that never stops leaks safety slowly, so it needs scheduled resets — and when it can't reach you, it must be able to stop. A loop that runs robots for you is just a bigger robot with the same rules and a further-away owner. And all of it is a hypothesis wearing its own kill-conditions on its sleeve.
|
||||
The model proposes; the gate disposes. No is the default, and a refusal must be safe. Exactly one party widens permissions — and it is not the model, a tool result, a summary, or a judge. "Didn't confirm" is not "didn't happen." The desk is finite and the proof doesn't compress, so you measure — and you say *measurement* when you mean measurement. A robot that never stops leaks safety slowly, so it needs scheduled resets — and when it can't reach you, it must be able to stop. A loop that runs robots for you is just a bigger robot with the same rules and a further-away owner. And all of it is a hypothesis wearing its own kill-conditions on its sleeve.
|
||||
|
||||
The formal version — the objects, the certificates, the falsifiers, the citations — is [HYPOTHESIS.md](HYPOTHESIS.md). It wins every disagreement with this file, including this sentence.
|
||||
|
||||
|
||||
@@ -458,7 +458,7 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
||||
| `context_window` | int | Total context window size in tokens |
|
||||
| `pct` | float | Percentage of context window used |
|
||||
| `effort` | string | Reasoning effort level (`low`/`medium`/`high`) |
|
||||
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic + OpenAI) |
|
||||
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
|
||||
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
|
||||
|
||||
**`info`** -- an informational message (e.g. command output).
|
||||
|
||||
+25
-35
@@ -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 |
|
||||
@@ -621,19 +622,19 @@ LLMProvider (protocol)
|
||||
|------|--------|
|
||||
| `StreamChunk` | `content_delta`, `reasoning_delta`, `tool_call_deltas`, `info_delta`, `usage`, `finish_reason`, `provider_blocks` |
|
||||
| `CompletionResult` | `content`, `tool_calls`, `finish_reason`, `usage`, `provider_blocks` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay`, `supports_verbosity`, `verbosity`, `supports_pro_mode`, `reasoning_mode` |
|
||||
| `ModelCapabilities` | `context_window`, `max_output_tokens`, `supports_temperature`, `token_param`, `thinking_mode`, `supports_effort`, `supports_web_search`, `supports_tool_search`, `supports_vision`, `supports_reasoning_replay` |
|
||||
| `UsageInfo` | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
|
||||
**OpenAIProvider** (`_openai.py`): passes messages through unchanged (they are
|
||||
already in OpenAI format), including multi-part content blocks (text + images)
|
||||
in tool results. Model capability lookup covers GPT-5 through GPT-5.6,
|
||||
in tool results. Model capability lookup table covers GPT-5/5.1/5.2/5.3/5.4,
|
||||
O-series, and search models (`gpt-5-search-api`) — all with `supports_vision`.
|
||||
For search models, injects `web_search_options` and removes the `web_search`
|
||||
function tool (the model always searches). Citations from `url_citation`
|
||||
annotations are formatted as footnotes. Pre-5.6 GPT-5 models request extended
|
||||
prompt-cache retention (`prompt_cache_retention: "24h"`); GPT-5.6 uses
|
||||
`prompt_cache_options.ttl: "30m"`. Cache reads and writes are extracted from
|
||||
`cached_tokens` and `cache_write_tokens`. Unknown models get permissive
|
||||
annotations are formatted as footnotes. Extended prompt cache retention
|
||||
(`prompt_cache_retention: "24h"`) is enabled for GPT-5.x models at no
|
||||
additional cost. Cached token counts are extracted from
|
||||
`usage.prompt_tokens_details.cached_tokens`. Unknown models get permissive
|
||||
defaults with `supports_vision=False` and use SearxNG for web search. The
|
||||
`openai-compatible` lane never consults this table at all — on either API
|
||||
surface (the responses pin is served by a compat-mode
|
||||
@@ -641,9 +642,8 @@ surface (the responses pin is served by a compat-mode
|
||||
local server serves whatever the operator named it (vLLM
|
||||
`--served-model-name` is a free string), so a prefix collision with a cloud
|
||||
model id must not inherit that model's sampling/effort contract — every
|
||||
local model gets the plain defaults, commercial prompt-cache controls are not
|
||||
injected by model-name prefix, and anything beyond those defaults is declared
|
||||
on the model definition (capabilities JSON + `server_compat`), matching the
|
||||
local model gets the plain defaults, and anything beyond them is declared on
|
||||
the model definition (capabilities JSON + `server_compat`), matching the
|
||||
`anthropic-compatible` lane.
|
||||
|
||||
**AnthropicProvider** (`_anthropic.py`): converts OpenAI-format messages to
|
||||
@@ -662,7 +662,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 +1149,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 +1175,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()
|
||||
|
||||
+3
-6
@@ -131,12 +131,9 @@ Per-LLM-request token and tool call metrics:
|
||||
LLM response with prompt/completion tokens, cache tokens, tool call count,
|
||||
model, ws_id
|
||||
- **Prompt caching**: Anthropic automatic caching (`cache_control: ephemeral`)
|
||||
and OpenAI caching are enabled by default. Pre-5.6 GPT-5 models request
|
||||
`prompt_cache_retention: 24h`; GPT-5.6 uses
|
||||
`prompt_cache_options: {"ttl": "30m"}`. GPT-5.6 cache writes use the
|
||||
provider's 1.25× input-token rate. `cache_creation_tokens` and
|
||||
`cache_read_tokens` are tracked per request in `usage_events` and surfaced
|
||||
in the Usage admin tab
|
||||
and OpenAI extended retention (`prompt_cache_retention: 24h` for GPT-5.x)
|
||||
are enabled by default. `cache_creation_tokens` and `cache_read_tokens` are
|
||||
tracked per request in `usage_events` and surfaced in the Usage admin tab
|
||||
- **Querying**: `GET /v1/api/admin/usage` with `group_by` (day/hour/model/user)
|
||||
and time range filtering — includes cache token aggregates
|
||||
- **Prometheus**: `turnstone_tokens_total{type="cache_creation|cache_read"}`
|
||||
|
||||
+2
-8
@@ -249,14 +249,8 @@ are withheld from the live surfaces (a reused call_id must never ride a stale
|
||||
`approve` into Smart Approvals) but still persist with
|
||||
`user_decision = "superseded"` so the audit trail records the judge's answer.
|
||||
|
||||
Sub-agent (task agent) tool calls are judge-gated too. Each runs the same
|
||||
intent pipeline as its own `agent_gate` generation, grounded in that sub-agent's
|
||||
own trajectory -- its task prompt is the delegation contract the operator
|
||||
approved, so "does this call serve the task" is the right local question.
|
||||
Agent-gate generations never occupy the main loop's supersede slot (parallel
|
||||
siblings would otherwise make each other's verdicts look stale); per-cycle
|
||||
generation checks enforce staleness instead, and `judge.cancel_on_approval`
|
||||
fires per gate exactly like the main loop.
|
||||
Sub-agents (plan agent, task agent) are exempt from intent validation -- they
|
||||
always get full tool visibility without judge evaluation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
@@ -54,23 +54,6 @@ When a per-model override is `NULL` (empty in the UI), the global default is
|
||||
used. Switching models via `/model <alias>` re-resolves sampling parameters
|
||||
from the new model's overrides or global defaults.
|
||||
|
||||
### Responses output controls (per-model)
|
||||
|
||||
Models whose capability table declares Responses output controls expose two
|
||||
additional fields in the Models create/edit shelf:
|
||||
|
||||
| Field | Stored capability | Values | Effect |
|
||||
|-------|-------------------|--------|--------|
|
||||
| Output verbosity | `verbosity` | `low`, `medium`, `high` | Controls answer length independently of reasoning effort. |
|
||||
| Reasoning mode | `reasoning_mode` | `standard`, `pro` | Selects standard or higher-compute Pro execution without changing the model ID. |
|
||||
|
||||
An empty selection means provider default and omits the capability key. Known
|
||||
GPT-5.6 models inherit support from the built-in table without persisting
|
||||
redundant support flags. An OpenAI-compatible model pinned to the Responses API
|
||||
can opt in with the `supports_verbosity` and `supports_pro_mode` capability
|
||||
tiles. Chat Completions and non-Responses providers do not surface or submit
|
||||
these controls.
|
||||
|
||||
**Removed settings:** `model.name` and `model.context_window` have been removed
|
||||
from ConfigStore. Model names and context windows are now configured per-model
|
||||
in the Models tab. A startup warning is logged if these keys appear in
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.8.0a1"
|
||||
version = "1.7.2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
dependencies = [
|
||||
"openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens
|
||||
"openai>=2.37",
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
|
||||
#
|
||||
# Autodetects your distro — Ubuntu/Debian, Fedora/RHEL, Arch, their common
|
||||
# derivatives (Mint, Pop!_OS, Nobara, AlmaLinux, …), and WSL on any of them —
|
||||
# and:
|
||||
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
|
||||
# them) and:
|
||||
# 1. ensures git is installed, then clones the repo
|
||||
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
|
||||
# 3. asks how many server nodes to run (1-10)
|
||||
@@ -66,18 +65,12 @@ ask() {
|
||||
|
||||
# -- distro / package manager detection --------------------------------------
|
||||
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
|
||||
# Extra os-release fields, captured only to pick Docker's upstream repo when
|
||||
# get.docker.com refuses a derivative it doesn't recognize (see install_docker).
|
||||
OS_PLATFORM_ID=""; OS_CODENAME=""; OS_UBUNTU_CODENAME=""
|
||||
|
||||
detect_os() {
|
||||
if [ -r /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
|
||||
OS_PLATFORM_ID="${PLATFORM_ID:-}"
|
||||
OS_CODENAME="${VERSION_CODENAME:-}"
|
||||
OS_UBUNTU_CODENAME="${UBUNTU_CODENAME:-}"
|
||||
fi
|
||||
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
|
||||
IS_WSL=1
|
||||
@@ -137,83 +130,11 @@ clone_repo() {
|
||||
# -- docker -------------------------------------------------------------------
|
||||
DOCKER="docker"
|
||||
|
||||
# Fallback when get.docker.com won't install here. That script keys off $ID alone
|
||||
# (never ID_LIKE), so it aborts with "Unsupported distribution '<id>'" on every
|
||||
# derivative — Nobara, Linux Mint, Pop!_OS, AlmaLinux, Oracle Linux, … — even
|
||||
# though the family is clear. We already know the family from detect_os, so we add
|
||||
# Docker's official CE repo for the matching upstream and install the same
|
||||
# packages get.docker.com would (including the compose plugin the rest of run.sh
|
||||
# relies on).
|
||||
install_docker_ce_repo() {
|
||||
local up
|
||||
case "$PKG" in
|
||||
apt)
|
||||
local codename arch
|
||||
# UBUNTU_CODENAME is set by Ubuntu and every Ubuntu-derived distro
|
||||
# (Mint/Pop!_OS/Zorin/…) and never by pure Debian, so it both routes
|
||||
# the family and gives the exact codename Docker's repo expects.
|
||||
if [ -n "$OS_UBUNTU_CODENAME" ]; then
|
||||
up=ubuntu; codename="$OS_UBUNTU_CODENAME"
|
||||
else
|
||||
up=debian; codename="$OS_CODENAME"
|
||||
fi
|
||||
[ -n "$codename" ] || die "couldn't determine the $up release codename for Docker's repo — install Docker manually and re-run."
|
||||
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
|
||||
info "Adding Docker's $up repository ($codename)."
|
||||
$SUDO install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/$up/gpg" | $SUDO tee /etc/apt/keyrings/docker.asc >/dev/null
|
||||
$SUDO chmod a+r /etc/apt/keyrings/docker.asc
|
||||
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
|
||||
"$arch" "$up" "$codename" | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
$SUDO apt-get update -y
|
||||
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
;;
|
||||
dnf|yum)
|
||||
# A Fedora spin and a RHEL clone can both carry "fedora" in ID_LIKE
|
||||
# (Nobara's is "rhel centos fedora"), so ID_LIKE can't separate them.
|
||||
# PLATFORM_ID can: Fedora is platform:fNN, Enterprise Linux platform:elN.
|
||||
case "$OS_PLATFORM_ID" in
|
||||
platform:f*) up=fedora ;;
|
||||
platform:el*) up=centos ;;
|
||||
*) if [ -e /etc/fedora-release ]; then up=fedora; else up=centos; fi ;;
|
||||
esac
|
||||
info "Adding Docker's $up repository."
|
||||
$SUDO curl -fsSL "https://download.docker.com/linux/$up/docker-ce.repo" \
|
||||
-o /etc/yum.repos.d/docker-ce.repo \
|
||||
|| die "couldn't add Docker's $up repository — install Docker manually and re-run."
|
||||
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The distro IDs get.docker.com installs directly: it matches $ID against this
|
||||
# exact set (ignoring ID_LIKE) and aborts on anything else. Mirrors the dispatch
|
||||
# in get.docker.com, including its fedora-asahi-remix -> fedora alias.
|
||||
get_docker_com_supports() {
|
||||
case "$1" in
|
||||
ubuntu|debian|raspbian|centos|fedora|rhel|rocky|sles|fedora-asahi-remix) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
case "$PKG" in
|
||||
apt|dnf|yum)
|
||||
# Decide up front which installer applies, rather than treating every
|
||||
# get.docker.com failure as "unsupported distro": for an ID it knows,
|
||||
# let it run and surface any real failure (network, apt lock, EOL) via
|
||||
# die instead of masking it with the repo path. Only unrecognized
|
||||
# derivatives (Nobara, Mint, …) — which it would just abort on — skip
|
||||
# straight to adding Docker's repo ourselves.
|
||||
if [ -n "$OS_ID" ] && ! get_docker_com_supports "$OS_ID"; then
|
||||
info "get.docker.com doesn't support '$OS_ID' — using Docker's official repository directly."
|
||||
install_docker_ce_repo
|
||||
else
|
||||
info "Installing Docker via the official get.docker.com script"
|
||||
curl -fsSL https://get.docker.com | $SUDO sh \
|
||||
|| die "get.docker.com failed to install Docker (see the output above). Fix the issue and re-run — the script resumes."
|
||||
fi
|
||||
;;
|
||||
info "Installing Docker via the official get.docker.com script"
|
||||
curl -fsSL https://get.docker.com | $SUDO sh ;;
|
||||
pacman)
|
||||
pkg_install docker docker-compose ;;
|
||||
esac
|
||||
@@ -445,15 +366,12 @@ ${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT"
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
|
||||
|
||||
Finish setup
|
||||
1. Open ${BOLD}${url}${RESET} and create the admin account when prompted —
|
||||
the first user created there gets full admin access.
|
||||
2. Log in, then add a model backend in the ${BOLD}Models${RESET} tab —
|
||||
1. Create the first admin user:
|
||||
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
|
||||
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
|
||||
a local server (vLLM / llama.cpp) or an OpenAI / Anthropic / Gemini key.
|
||||
Nodes boot without a model and pick it up live; no restart needed.
|
||||
|
||||
${DIM}No browser? Create the admin from the CLI instead:
|
||||
cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-admin --username admin --name "Admin"${RESET}
|
||||
|
||||
Scale Running ${scale}
|
||||
|
||||
Manage ${DIM}cd $INSTALL_DIR${RESET}
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Shared process/polling helpers for the bash + background-shell suites.
|
||||
|
||||
One copy instead of three: ``test_bash_tool_background_hang``,
|
||||
``test_background_shells`` and ``test_bash_background_tool`` all assert on
|
||||
process liveness and poll for asynchronous state. Leading underscore so
|
||||
pytest doesn't collect it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
|
||||
def pid_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def kill_pid(pid: int) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
|
||||
|
||||
def poll_until(predicate, timeout=10.0, interval=0.05):
|
||||
"""Poll ``predicate`` until truthy or ``timeout``; RETURNS the last value
|
||||
(falsy on timeout — assert at the call site). Deliberately named apart
|
||||
from ``tests/_helpers.wait_until``, which RAISES on timeout: two
|
||||
same-named helpers with opposite failure semantics invite silently-green
|
||||
tests."""
|
||||
deadline = time.monotonic() + timeout
|
||||
value = predicate()
|
||||
while not value and time.monotonic() < deadline:
|
||||
time.sleep(interval)
|
||||
value = predicate()
|
||||
return value
|
||||
@@ -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 []),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"reasoning.encrypted_content"
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"content": "Hi there.",
|
||||
"role": "user",
|
||||
"type": "message"
|
||||
},
|
||||
{
|
||||
"content": "Hello! How can I help?",
|
||||
"role": "assistant",
|
||||
"type": "message"
|
||||
},
|
||||
{
|
||||
"content": "What's the weather in Paris?",
|
||||
"role": "user",
|
||||
"type": "message"
|
||||
}
|
||||
],
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5.6-sol",
|
||||
"prompt_cache_options": {
|
||||
"ttl": "30m"
|
||||
},
|
||||
"reasoning": {
|
||||
"effort": "max"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"reasoning.encrypted_content"
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"content": "Weather in Paris?",
|
||||
"role": "user",
|
||||
"type": "message"
|
||||
},
|
||||
{
|
||||
"arguments": "{\"city\": \"Paris\"}",
|
||||
"call_id": "call_1",
|
||||
"name": "get_weather",
|
||||
"type": "function_call"
|
||||
},
|
||||
{
|
||||
"call_id": "call_1",
|
||||
"output": "18C, clear.",
|
||||
"type": "function_call_output"
|
||||
},
|
||||
{
|
||||
"content": "It's 18C and clear in Paris.",
|
||||
"role": "assistant",
|
||||
"type": "message"
|
||||
}
|
||||
],
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5.6-sol",
|
||||
"prompt_cache_options": {
|
||||
"ttl": "30m"
|
||||
},
|
||||
"reasoning": {
|
||||
"effort": "max"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"city"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"strict": false,
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"include": [
|
||||
"reasoning.encrypted_content"
|
||||
],
|
||||
"input": [
|
||||
{
|
||||
"content": "Hi there.",
|
||||
"role": "user",
|
||||
"type": "message"
|
||||
},
|
||||
{
|
||||
"content": "Hello! How can I help?",
|
||||
"role": "assistant",
|
||||
"type": "message"
|
||||
},
|
||||
{
|
||||
"content": "What's the weather in Paris?",
|
||||
"role": "user",
|
||||
"type": "message"
|
||||
}
|
||||
],
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5.6-sol",
|
||||
"prompt_cache_options": {
|
||||
"ttl": "30m"
|
||||
},
|
||||
"reasoning": {
|
||||
"effort": "high",
|
||||
"mode": "pro"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"text": {
|
||||
"verbosity": "low"
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Tests for ``turnstone-admin create-admin`` (issue #824).
|
||||
|
||||
``create-user`` creates a role-less user; the web UI derives a login's scopes
|
||||
purely from assigned roles, so that account logs in read-only and hits
|
||||
"Forbidden: token lacks 'approve' scope" on any admin action. ``create-admin``
|
||||
assigns the built-in admin role — mirroring the web setup wizard
|
||||
(``POST /api/auth/setup``) — and promotes an existing role-less user, which is
|
||||
the recovery path for anyone already stuck.
|
||||
|
||||
Each test drives the real ``_cmd_create_admin`` handler against a real,
|
||||
fully-migrated SQLite DB: the ``builtin-admin`` role is seeded by migration
|
||||
008, so the DB must be migrated (not just ``create_all``-built) for the role
|
||||
to exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.admin import _cmd_create_admin, _cmd_create_user
|
||||
from turnstone.core.auth import _load_user_permissions, _permissions_to_scopes
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_storage_singleton() -> Iterator[None]:
|
||||
"""Keep the module-global storage singleton from leaking across tests."""
|
||||
reset_storage()
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
def _db_args(db_path: str, **overrides: Any) -> argparse.Namespace:
|
||||
"""Build the Namespace ``_cmd_create_admin`` (and ``_cmd_create_user``) expect.
|
||||
|
||||
Pins every DB field so ``_get_storage`` resolves to the tmp sqlite file and
|
||||
never leaks a ``TURNSTONE_DB_*`` env var (it only falls back when the attr
|
||||
``is None``). ``token``/``scopes`` are only read by ``_cmd_create_user``.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"username": "admin",
|
||||
"name": "",
|
||||
"password": "",
|
||||
"token": False,
|
||||
"scopes": "read,write,approve",
|
||||
"db_backend": "sqlite",
|
||||
"db_path": db_path,
|
||||
"db_url": "",
|
||||
"db_pool_size": 2,
|
||||
"db_sslmode": "",
|
||||
"db_sslrootcert": "",
|
||||
"db_sslcert": "",
|
||||
"db_sslkey": "",
|
||||
}
|
||||
base.update(overrides)
|
||||
return argparse.Namespace(**base)
|
||||
|
||||
|
||||
def _migrated_storage(db_path: str) -> Any:
|
||||
"""Return a fully-migrated storage singleton (seeds the ``builtin-admin`` role)."""
|
||||
return init_storage("sqlite", path=db_path, run_migrations=True)
|
||||
|
||||
|
||||
def _has_admin_role(storage: Any, user_id: str) -> bool:
|
||||
return any(r.get("role_id") == "builtin-admin" for r in storage.list_user_roles(user_id))
|
||||
|
||||
|
||||
def _login_scopes(storage: Any, user_id: str) -> frozenset[str]:
|
||||
"""Scopes a password login would grant this user — the real lockout surface."""
|
||||
return _permissions_to_scopes(_load_user_permissions(storage, user_id))
|
||||
|
||||
|
||||
def test_create_admin_fresh_user_gets_approve_scope(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
storage = _migrated_storage(db_path)
|
||||
|
||||
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
|
||||
|
||||
user = storage.get_user_by_username("admin")
|
||||
assert user is not None
|
||||
assert _has_admin_role(storage, user["user_id"])
|
||||
# The exact bug surface: a web login for this account must carry `approve`.
|
||||
assert "approve" in _login_scopes(storage, user["user_id"])
|
||||
|
||||
|
||||
def test_create_admin_defaults_display_name_to_username(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
storage = _migrated_storage(db_path)
|
||||
|
||||
_cmd_create_admin(_db_args(db_path, username="root", name="", password="hunter2!pw"))
|
||||
|
||||
user = storage.get_user_by_username("root")
|
||||
assert user is not None
|
||||
assert user["display_name"] == "root"
|
||||
|
||||
|
||||
def test_create_admin_promotes_existing_read_only_user(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Issue #824 recovery path: a role-less create-user account, then create-admin."""
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
storage = _migrated_storage(db_path)
|
||||
|
||||
# Reproduce the locked-out account exactly (role-less create-user).
|
||||
_cmd_create_user(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
|
||||
user = storage.get_user_by_username("admin")
|
||||
assert user is not None
|
||||
assert not _has_admin_role(storage, user["user_id"])
|
||||
assert "approve" not in _login_scopes(storage, user["user_id"]) # locked out
|
||||
|
||||
# Unstick without recreating the user.
|
||||
_cmd_create_admin(_db_args(db_path, username="admin"))
|
||||
|
||||
assert _has_admin_role(storage, user["user_id"])
|
||||
assert "approve" in _login_scopes(storage, user["user_id"])
|
||||
assert "Granted the admin role" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_create_admin_already_admin_is_idempotent(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
storage = _migrated_storage(db_path)
|
||||
|
||||
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="hunter2!pw"))
|
||||
capsys.readouterr() # drop first-run output
|
||||
|
||||
_cmd_create_admin(_db_args(db_path, username="admin"))
|
||||
|
||||
user = storage.get_user_by_username("admin")
|
||||
assert user is not None
|
||||
admin_rows = [
|
||||
r for r in storage.list_user_roles(user["user_id"]) if r.get("role_id") == "builtin-admin"
|
||||
]
|
||||
assert len(admin_rows) == 1 # not duplicated
|
||||
assert "already an admin" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_create_admin_short_password_rejected(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
storage = _migrated_storage(db_path)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_cmd_create_admin(_db_args(db_path, username="admin", name="Admin", password="short"))
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "at least 8" in capsys.readouterr().err
|
||||
assert storage.get_user_by_username("admin") is None # nothing created
|
||||
|
||||
|
||||
def test_create_admin_invalid_username_rejected(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
db_path = str(tmp_path / "admin.db")
|
||||
_migrated_storage(db_path)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_cmd_create_admin(_db_args(db_path, username="bad user!", name="X", password="hunter2!pw"))
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "invalid username" in capsys.readouterr().err
|
||||
@@ -677,82 +677,6 @@ def test_audio_roles_gated_to_openai_sdk_providers() -> None:
|
||||
assert '_providerCarriesAudio((md && md.provider) || "openai")' in body
|
||||
|
||||
|
||||
def test_model_response_controls_are_capability_driven_and_sparse() -> None:
|
||||
"""The model shelf surfaces Responses-only scalar controls without
|
||||
hard-coding GPT-5.6 IDs or pinning inherited capability-table values."""
|
||||
html = _CONSOLE_INDEX.read_text(encoding="utf-8")
|
||||
admin = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="model-response-controls"' in html
|
||||
assert 'aria-labelledby="model-response-controls-title"' in html
|
||||
assert 'id="model-output-verbosity"' in html
|
||||
assert 'for="model-output-verbosity"' in html
|
||||
assert 'id="model-reasoning-mode"' in html
|
||||
assert 'for="model-reasoning-mode"' in html
|
||||
for value in ("low", "medium", "high"):
|
||||
assert f'<option value="{value}">' in html
|
||||
for value in ("standard", "pro"):
|
||||
assert f'<option value="{value}">' in html
|
||||
assert 'data-cap="supports_verbosity"' in html
|
||||
assert 'data-cap="supports_pro_mode"' in html
|
||||
|
||||
assert '"supports_verbosity"' in admin
|
||||
assert '"supports_pro_mode"' in admin
|
||||
surface = _slice_function_body(admin, "_modelUsesResponsesSurface")
|
||||
assert surface is not None
|
||||
assert 'provider === "openai"' in surface
|
||||
assert 'provider === "openai-compatible"' in surface
|
||||
assert 'value === "responses"' in surface
|
||||
visibility = _slice_function_body(admin, "_updateModelResponseControls")
|
||||
assert visibility is not None
|
||||
assert "_modelGetTile(spec.supportKey)" in visibility
|
||||
assert 'supportKey: "supports_verbosity"' in admin
|
||||
assert 'supportKey: "supports_pro_mode"' in admin
|
||||
assert "gpt-5.6" not in visibility, "visibility must come from capabilities, not model IDs"
|
||||
|
||||
assert "function _captureModelResponseControls(" in admin
|
||||
assert "function _mergeModelResponseControls(" in admin
|
||||
assert "_captureModelResponseControls(capsObj)" in admin
|
||||
assert "_mergeModelResponseControls(caps)" in admin
|
||||
assert "let _modelResponseCaptured = {};" in admin
|
||||
assert "let _modelResponseDirty = {};" in admin
|
||||
assert "_modelResponseCaptured[spec.key] = value" in admin
|
||||
assert "nextIdentity === _modelResponseInitialIdentity" in admin
|
||||
identity = _slice_function_body(admin, "_modelIdentity")
|
||||
assert identity is not None
|
||||
assert 'provider === "openai-compatible"' in identity
|
||||
assert ': ""' in identity
|
||||
merge = _slice_function_body(admin, "_mergeModelResponseControls")
|
||||
assert merge is not None
|
||||
# The dirty flag (select touched) may only override Advanced JSON for
|
||||
# the identity that made it dirty — a stale flag from a renamed row
|
||||
# must not delete a hand-typed JSON key.
|
||||
assert "if (_modelResponseDirty[spec.key] && sameIdentity) delete caps[spec.key]" in merge
|
||||
# The captured-value fallback is load-bearing, not a gating bug: a value
|
||||
# lifted out of the row JSON on edit-open must stay visible and re-save
|
||||
# for the same identity even when the baseline table says unsupported.
|
||||
# The baseline arrives async (or never, on the compat lane); yielding to
|
||||
# it would silently drop the pinned value on an unrelated edit-save.
|
||||
# Wire safety lives server-side (emission gates on merged supports_*).
|
||||
for body in (visibility, merge):
|
||||
assert "_modelGetTile(spec.supportKey) || capturedFallback" in body
|
||||
assert "sameIdentity" in body
|
||||
assert "!(spec.supportKey in _modelCapsExplicit)" in body
|
||||
|
||||
create = _slice_function_body(admin, "showCreateModelModal")
|
||||
assert create is not None
|
||||
assert "_modelCapsSeq++" in create, "a fresh shelf must invalidate prior lookups"
|
||||
|
||||
assert "displayCaps.supports_verbosity !== false" in admin
|
||||
assert "displayCaps.supports_pro_mode !== false" in admin
|
||||
|
||||
change = _slice_function_body(admin, "_onModelFieldChange")
|
||||
assert change is not None
|
||||
assert "_modelCapsSeq++" in change, "model changes must invalidate in-flight baselines"
|
||||
assert "_modelCapsBaseline = {}" in change
|
||||
assert 'apiSurfEl.addEventListener("change", _onModelFieldChange)' in admin
|
||||
|
||||
|
||||
def test_shared_utils_defines_set_markdown_helper() -> None:
|
||||
"""The ``setMarkdown`` helper in ``shared/utils.js`` is the single
|
||||
audited entry point for rendering markdown content into a DOM
|
||||
@@ -876,30 +800,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
|
||||
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Unit tests for the per-session background-shell registry (#817).
|
||||
|
||||
The registry backs the ``bash(run_in_background=true)`` / ``bash_output`` /
|
||||
``kill_shell`` tool surface: it spawns detached shells (``bash_N`` handles),
|
||||
buffers their merged output in a capped rolling buffer, serves delta reads
|
||||
(only lines since the last read), and reaps whole session groups on kill /
|
||||
owner reap / close — the #816 rule (the tracked command defines the lifetime,
|
||||
nothing escapes its process group) extended to explicit backgrounding.
|
||||
|
||||
Pure registry tests — no ChatSession. Session wiring is covered in
|
||||
``test_bash_background_tool.py``.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._proc_helpers import kill_pid as _kill_pid
|
||||
from tests._proc_helpers import pid_alive as _pid_alive
|
||||
from tests._proc_helpers import poll_until as _wait_until
|
||||
|
||||
# Module alias (from-style, matching the symbol imports below) for tests
|
||||
# that monkeypatch module attributes (os.killpg, subprocess.Popen, ...).
|
||||
from turnstone.core import background_shells as bg_mod
|
||||
from turnstone.core.background_shells import (
|
||||
BackgroundShellRegistry,
|
||||
FilterExecError,
|
||||
FilterTimeoutError,
|
||||
TooManyShellsError,
|
||||
UnknownShellError,
|
||||
)
|
||||
|
||||
|
||||
def _wait_status(shell, status, timeout=10.0):
|
||||
return _wait_until(lambda: shell.status == status, timeout=timeout)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
reg = BackgroundShellRegistry()
|
||||
yield reg
|
||||
reg.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handles + spawning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spawn_returns_incrementing_bash_handles(registry):
|
||||
s1 = registry.spawn("sleep 30")
|
||||
s2 = registry.spawn("sleep 30")
|
||||
assert s1.shell_id == "bash_1"
|
||||
assert s2.shell_id == "bash_2"
|
||||
|
||||
|
||||
def test_spawned_shell_is_running_with_live_pid(registry):
|
||||
shell = registry.spawn("sleep 30")
|
||||
assert shell.status == "running"
|
||||
assert _pid_alive(shell.pid)
|
||||
|
||||
|
||||
def test_spawn_records_command(registry):
|
||||
shell = registry.spawn("sleep 30")
|
||||
assert shell.command == "sleep 30"
|
||||
|
||||
|
||||
def test_spawn_after_close_is_refused():
|
||||
reg = BackgroundShellRegistry()
|
||||
reg.close()
|
||||
with pytest.raises(RuntimeError):
|
||||
reg.spawn("echo hi")
|
||||
|
||||
|
||||
def test_max_live_shells_cap():
|
||||
reg = BackgroundShellRegistry(max_shells=2)
|
||||
try:
|
||||
reg.spawn("sleep 30")
|
||||
s2 = reg.spawn("sleep 30")
|
||||
with pytest.raises(TooManyShellsError):
|
||||
reg.spawn("sleep 30")
|
||||
# Cap counts LIVE shells: killing one frees a slot.
|
||||
reg.kill(s2.shell_id)
|
||||
s3 = reg.spawn("sleep 30")
|
||||
assert s3.status == "running"
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_completed_shells_do_not_count_toward_cap():
|
||||
reg = BackgroundShellRegistry(max_shells=1)
|
||||
try:
|
||||
s1 = reg.spawn("true")
|
||||
assert _wait_status(s1, "completed")
|
||||
s2 = reg.spawn("sleep 30")
|
||||
assert s2.status == "running"
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_natural_exit_sets_completed_and_exit_code(registry):
|
||||
shell = registry.spawn("exit 7")
|
||||
assert _wait_status(shell, "completed")
|
||||
assert shell.exit_code == 7
|
||||
|
||||
|
||||
def test_output_is_complete_once_completed(registry):
|
||||
"""Status flips to completed only after the drains finish: a read at
|
||||
completed must see everything the command wrote."""
|
||||
shell = registry.spawn("echo alpha; echo beta")
|
||||
assert _wait_status(shell, "completed")
|
||||
read = registry.read(shell.shell_id)
|
||||
assert [ln.strip() for ln in read.lines] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_leader_exit_reaps_backgrounded_grandchild(registry, tmp_path):
|
||||
"""#816 consistency: the tracked command defines the lifetime. When the
|
||||
leader exits, the whole session group is killed — a child the command
|
||||
backgrounded does not outlive it."""
|
||||
pidfile = tmp_path / "bg.pid"
|
||||
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; echo done")
|
||||
bg_pid = None
|
||||
try:
|
||||
assert _wait_status(shell, "completed")
|
||||
bg_pid = int(pidfile.read_text().strip())
|
||||
assert _wait_until(lambda: not _pid_alive(bg_pid)), (
|
||||
f"grandchild {bg_pid} leaked past leader exit"
|
||||
)
|
||||
read = registry.read(shell.shell_id)
|
||||
assert "done" in "".join(read.lines)
|
||||
finally:
|
||||
if bg_pid is not None:
|
||||
_kill_pid(bg_pid)
|
||||
|
||||
|
||||
def test_stderr_lines_are_tagged_inline(registry):
|
||||
shell = registry.spawn("echo out; echo err >&2")
|
||||
assert _wait_status(shell, "completed")
|
||||
lines = [ln.strip() for ln in registry.read(shell.shell_id).lines]
|
||||
assert "out" in lines
|
||||
assert "[stderr] err" in lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delta reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_returns_only_new_lines_since_last_read(registry):
|
||||
"""The load-bearing convention: consecutive reads never overlap and never
|
||||
drop a line — collecting across polls yields each line exactly once."""
|
||||
shell = registry.spawn("echo one; echo two; sleep 0.4; echo three; sleep 30")
|
||||
collected: list[str] = []
|
||||
|
||||
def _collect():
|
||||
collected.extend(ln.strip() for ln in registry.read(shell.shell_id).lines)
|
||||
return "three" in collected
|
||||
|
||||
assert _wait_until(_collect)
|
||||
assert collected == ["one", "two", "three"]
|
||||
registry.kill(shell.shell_id)
|
||||
|
||||
|
||||
def test_read_after_exit_then_again_reports_no_new_output(registry):
|
||||
shell = registry.spawn("echo hi")
|
||||
assert _wait_status(shell, "completed")
|
||||
first = registry.read(shell.shell_id)
|
||||
assert [ln.strip() for ln in first.lines] == ["hi"]
|
||||
second = registry.read(shell.shell_id)
|
||||
assert second.lines == []
|
||||
assert second.status == "completed"
|
||||
assert second.exit_code == 0
|
||||
|
||||
|
||||
def test_read_reports_status_and_exit_code(registry):
|
||||
shell = registry.spawn("sleep 30")
|
||||
read = registry.read(shell.shell_id)
|
||||
assert read.shell_id == shell.shell_id
|
||||
assert read.status == "running"
|
||||
assert read.exit_code is None
|
||||
registry.kill(shell.shell_id)
|
||||
|
||||
|
||||
def test_read_unknown_id_raises_with_live_ids(registry):
|
||||
registry.spawn("sleep 30")
|
||||
with pytest.raises(UnknownShellError) as excinfo:
|
||||
registry.read("bash_99")
|
||||
assert "bash_99" in str(excinfo.value)
|
||||
assert "bash_1" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_read_unknown_id_when_registry_empty(registry):
|
||||
with pytest.raises(UnknownShellError):
|
||||
registry.read("bash_1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filter_selects_matching_lines_only(registry):
|
||||
shell = registry.spawn("echo match-a; echo skip-b; echo match-c")
|
||||
assert _wait_status(shell, "completed")
|
||||
read = registry.read(shell.shell_id, filter_pattern="^match")
|
||||
assert [ln.strip() for ln in read.lines] == ["match-a", "match-c"]
|
||||
|
||||
|
||||
def test_filter_is_display_only_and_consumes_the_delta(registry):
|
||||
"""Filtered-out lines are consumed, not deferred — the cursor advances
|
||||
past the whole delta (Claude Code ``BashOutput`` semantics)."""
|
||||
shell = registry.spawn("echo match-a; echo skip-b")
|
||||
assert _wait_status(shell, "completed")
|
||||
first = registry.read(shell.shell_id, filter_pattern="^match")
|
||||
assert [ln.strip() for ln in first.lines] == ["match-a"]
|
||||
assert first.new_line_count == 2 # both lines were new, one shown
|
||||
second = registry.read(shell.shell_id)
|
||||
assert second.lines == []
|
||||
assert second.new_line_count == 0
|
||||
|
||||
|
||||
def test_filter_uses_search_not_match(registry):
|
||||
shell = registry.spawn("echo prefix-needle-suffix")
|
||||
assert _wait_status(shell, "completed")
|
||||
read = registry.read(shell.shell_id, filter_pattern="needle")
|
||||
assert len(read.lines) == 1
|
||||
|
||||
|
||||
def test_invalid_filter_regex_raises(registry):
|
||||
shell = registry.spawn("echo hi")
|
||||
assert _wait_status(shell, "completed")
|
||||
with pytest.raises(re.error):
|
||||
registry.read(shell.shell_id, filter_pattern="[unclosed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Buffer cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_buffer_cap_drops_oldest_and_reports_gap():
|
||||
reg = BackgroundShellRegistry(max_buffer_chars=200)
|
||||
try:
|
||||
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
|
||||
assert _wait_status(shell, "completed")
|
||||
read = reg.read(shell.shell_id)
|
||||
assert read.dropped_lines > 0
|
||||
# Newest output survives; the tail is intact.
|
||||
assert read.lines, "cap must retain the newest lines, not drop everything"
|
||||
assert read.lines[-1].strip() == "line-50-padded-to-length"
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_unread_lines_excludes_buffer_evicted():
|
||||
"""The exit notice's line count must not promise evicted output."""
|
||||
reg = BackgroundShellRegistry(max_buffer_chars=200)
|
||||
try:
|
||||
shell = reg.spawn('for i in $(seq 1 50); do echo "line-$i-padded-to-length"; done')
|
||||
assert _wait_status(shell, "completed")
|
||||
with shell.lock:
|
||||
retained = len(shell._buffer)
|
||||
assert shell.unread_lines == retained
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_buffer_gap_is_relative_to_cursor():
|
||||
"""Lines dropped BEFORE being read are a reported gap; lines already
|
||||
read and then dropped are not."""
|
||||
reg = BackgroundShellRegistry(max_buffer_chars=10_000)
|
||||
try:
|
||||
shell = reg.spawn("echo early; sleep 30")
|
||||
# Each poll consumes whatever has arrived; stop once something did.
|
||||
assert _wait_until(lambda: bool(reg.read(shell.shell_id).lines))
|
||||
# Everything emitted so far is read; nothing has been dropped.
|
||||
read = reg.read(shell.shell_id)
|
||||
assert read.dropped_lines == 0
|
||||
reg.kill(shell.shell_id)
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kill / reap / close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_marks_killed_and_reaps_group(registry, tmp_path):
|
||||
pidfile = tmp_path / "bg.pid"
|
||||
shell = registry.spawn(f"sleep 60 & echo $! > {pidfile}; sleep 60")
|
||||
assert _wait_until(pidfile.exists)
|
||||
bg_pid = int(pidfile.read_text().strip())
|
||||
try:
|
||||
killed = registry.kill(shell.shell_id)
|
||||
assert killed.status == "killed"
|
||||
assert _wait_until(lambda: not _pid_alive(shell.pid))
|
||||
assert _wait_until(lambda: not _pid_alive(bg_pid)), "grandchild survived kill"
|
||||
finally:
|
||||
_kill_pid(bg_pid)
|
||||
|
||||
|
||||
def test_kill_unknown_id_raises(registry):
|
||||
with pytest.raises(UnknownShellError):
|
||||
registry.kill("bash_7")
|
||||
|
||||
|
||||
def test_killed_shell_output_remains_readable(registry, tmp_path):
|
||||
"""Output that arrived before the kill survives it: the record keeps its
|
||||
buffer, and ``kill`` returns only after the drains have flushed."""
|
||||
sentinel = tmp_path / "started"
|
||||
shell = registry.spawn(f"echo before-kill; touch {sentinel}; sleep 60")
|
||||
assert _wait_until(sentinel.exists)
|
||||
registry.kill(shell.shell_id)
|
||||
read = registry.read(shell.shell_id)
|
||||
assert read.status == "killed"
|
||||
assert "before-kill" in "".join(read.lines)
|
||||
|
||||
|
||||
def test_signal_all_kills_live_shells_without_closing(registry):
|
||||
"""signal_all is the instant half of teardown: every live group dies,
|
||||
but the registry stays open (records intact, spawns still allowed) —
|
||||
close() remains the complete teardown."""
|
||||
s1 = registry.spawn("sleep 60")
|
||||
s2 = registry.spawn("sleep 60")
|
||||
registry.signal_all()
|
||||
assert _wait_until(lambda: not _pid_alive(s1.pid))
|
||||
assert _wait_until(lambda: not _pid_alive(s2.pid))
|
||||
assert registry.has(s1.shell_id), "signal_all must not drop records"
|
||||
s3 = registry.spawn("true")
|
||||
assert _wait_status(s3, "completed"), "registry must remain usable after signal_all"
|
||||
|
||||
|
||||
def test_close_kills_everything_and_is_idempotent():
|
||||
reg = BackgroundShellRegistry()
|
||||
s1 = reg.spawn("sleep 60")
|
||||
s2 = reg.spawn("sleep 60")
|
||||
reg.close()
|
||||
assert not _pid_alive(s1.pid)
|
||||
assert not _pid_alive(s2.pid)
|
||||
reg.close() # second close is a no-op
|
||||
|
||||
|
||||
def test_reap_owner_kills_only_that_owners_shells(registry):
|
||||
mine = registry.spawn("sleep 60", owner="agent-1")
|
||||
other = registry.spawn("sleep 60", owner="agent-2")
|
||||
main = registry.spawn("sleep 60")
|
||||
registry.reap(owner="agent-1")
|
||||
assert _wait_until(lambda: not _pid_alive(mine.pid))
|
||||
assert _pid_alive(other.pid)
|
||||
assert _pid_alive(main.pid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner scoping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_scoped_lookup_isolates_shells(registry):
|
||||
agent_shell = registry.spawn("sleep 30", owner="agent-1")
|
||||
main_shell = registry.spawn("sleep 30")
|
||||
# Main scope cannot see the agent's shell...
|
||||
with pytest.raises(UnknownShellError):
|
||||
registry.read(agent_shell.shell_id)
|
||||
# ...and the agent scope cannot see the main shell.
|
||||
with pytest.raises(UnknownShellError):
|
||||
registry.read(main_shell.shell_id, owner="agent-1")
|
||||
# Each side reads its own.
|
||||
assert registry.read(agent_shell.shell_id, owner="agent-1").status == "running"
|
||||
assert registry.read(main_shell.shell_id).status == "running"
|
||||
|
||||
|
||||
def test_shells_snapshot_is_owner_scoped(registry):
|
||||
registry.spawn("sleep 30", owner="agent-1")
|
||||
registry.spawn("sleep 30")
|
||||
assert [s.owner for s in registry.shells(owner="agent-1")] == ["agent-1"]
|
||||
assert [s.owner for s in registry.shells()] == [None]
|
||||
|
||||
|
||||
def test_handles_are_unique_across_owners(registry):
|
||||
a = registry.spawn("sleep 30", owner="agent-1")
|
||||
b = registry.spawn("sleep 30")
|
||||
assert a.shell_id != b.shell_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit callback (the notice hook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_on_exit_fires_once_on_natural_exit():
|
||||
fired = threading.Event()
|
||||
seen = []
|
||||
|
||||
def _on_exit(shell):
|
||||
seen.append(shell)
|
||||
fired.set()
|
||||
|
||||
reg = BackgroundShellRegistry(on_exit=_on_exit)
|
||||
try:
|
||||
shell = reg.spawn("echo done")
|
||||
assert fired.wait(10)
|
||||
assert len(seen) == 1
|
||||
assert seen[0].shell_id == shell.shell_id
|
||||
assert seen[0].exit_code == 0
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_on_exit_not_fired_for_kill():
|
||||
seen = []
|
||||
reg = BackgroundShellRegistry(on_exit=seen.append)
|
||||
try:
|
||||
shell = reg.spawn("sleep 60")
|
||||
reg.kill(shell.shell_id)
|
||||
assert _wait_until(lambda: not _pid_alive(shell.pid))
|
||||
time.sleep(0.2) # give a buggy late callback a chance to land
|
||||
assert seen == []
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_on_exit_not_fired_for_close():
|
||||
seen = []
|
||||
reg = BackgroundShellRegistry(on_exit=seen.append)
|
||||
shell = reg.spawn("sleep 60")
|
||||
reg.close()
|
||||
assert not _pid_alive(shell.pid)
|
||||
time.sleep(0.2)
|
||||
assert seen == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Review-hardening regressions (#817 code review)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_on_completed_shell_does_not_signal_group(registry, monkeypatch):
|
||||
"""A completed shell's pgid is a stale snapshot the OS may have recycled
|
||||
to an unrelated process group — kill() must not signal it (the waiter's
|
||||
own group kill already ran at exit, when the pgid was fresh)."""
|
||||
shell = registry.spawn("true")
|
||||
assert _wait_status(shell, "completed")
|
||||
calls = []
|
||||
monkeypatch.setattr(bg_mod.os, "killpg", lambda *a: calls.append(a))
|
||||
killed = registry.kill(shell.shell_id)
|
||||
assert calls == [], "killpg must not fire for an already-exited shell"
|
||||
assert killed.status == "completed", "a natural exit must not be relabelled 'killed'"
|
||||
|
||||
|
||||
def test_close_is_time_bounded_with_pipe_holding_escapee(registry, tmp_path):
|
||||
"""An escaped-group grandchild that holds the output pipes wedges the
|
||||
drain threads. close() must still return within its total budget —
|
||||
it can run under the server's async close route, where an unbounded
|
||||
join would freeze the whole node's event loop."""
|
||||
pidfile = tmp_path / "holder.pid"
|
||||
# ``setsid`` puts the sleep in a NEW session (outside our kill group)
|
||||
# while it still inherits our stdout/stderr pipes — the accepted
|
||||
# leaked-daemon case from the module docstring.
|
||||
shell = registry.spawn(f"setsid sleep 60 & echo $! > {pidfile}; echo started")
|
||||
assert _wait_until(pidfile.exists)
|
||||
holder_pid = int(pidfile.read_text().strip())
|
||||
try:
|
||||
start = time.monotonic()
|
||||
registry.close()
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed < 8, f"close() took {elapsed:.1f}s — teardown must be budget-bounded"
|
||||
finally:
|
||||
_kill_pid(holder_pid)
|
||||
# The holder is dead, so the wedged drains EOF promptly; wait for
|
||||
# them here so the conftest leak guard sees a clean teardown.
|
||||
assert _wait_until(lambda: not any(t.is_alive() for t in shell._threads))
|
||||
|
||||
|
||||
def test_exited_records_are_pruned_at_cap():
|
||||
reg = BackgroundShellRegistry(max_exited_records=2)
|
||||
try:
|
||||
shells = [reg.spawn(f"echo job-{i}") for i in range(3)]
|
||||
for s in shells:
|
||||
assert _wait_status(s, "completed")
|
||||
# Eviction happens on each exit; poll until the oldest is gone
|
||||
# (waiter threads race, prune runs per-exit).
|
||||
assert _wait_until(lambda: not reg.has(shells[0].shell_id))
|
||||
assert reg.has(shells[1].shell_id)
|
||||
assert reg.has(shells[2].shell_id)
|
||||
with pytest.raises(UnknownShellError):
|
||||
reg.read(shells[0].shell_id)
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_catastrophic_filter_times_out_without_consuming(registry):
|
||||
"""A backtracking-bomb filter must error within the bound and consume
|
||||
NOTHING — the retry without a filter still gets the output. The match
|
||||
runs in a killable child process: sre holds the GIL, so an in-process
|
||||
bomb would freeze the whole interpreter, watchdogs included."""
|
||||
# One ~3000-char line of a's ending in 'b' — the classic (a+)+$ bomb
|
||||
# subject — followed by a sentinel line.
|
||||
shell = registry.spawn("printf 'a%.0s' $(seq 1 3000); echo b; echo tail-line")
|
||||
assert _wait_status(shell, "completed")
|
||||
start = time.monotonic()
|
||||
with pytest.raises(FilterTimeoutError):
|
||||
registry.read(shell.shell_id, filter_pattern=r"(a+)+$")
|
||||
assert time.monotonic() - start < 10, "filter timeout must be bounded"
|
||||
# Nothing was consumed: an unfiltered read sees the whole delta.
|
||||
read = registry.read(shell.shell_id)
|
||||
assert any("tail-line" in ln for ln in read.lines)
|
||||
|
||||
|
||||
def test_overlong_filter_pattern_is_rejected(registry):
|
||||
shell = registry.spawn("echo hi")
|
||||
assert _wait_status(shell, "completed")
|
||||
with pytest.raises(re.error):
|
||||
registry.read(shell.shell_id, filter_pattern="x" * 600)
|
||||
|
||||
|
||||
def test_cap_error_is_owner_scope_honest():
|
||||
"""The cap is registry-wide, but the advice must only name shells the
|
||||
caller can actually kill — kill_shell is owner-scoped."""
|
||||
reg = BackgroundShellRegistry(max_shells=1)
|
||||
try:
|
||||
reg.spawn("sleep 30") # main scope fills the cap
|
||||
with pytest.raises(TooManyShellsError) as excinfo:
|
||||
reg.spawn("sleep 30", owner="agent-1")
|
||||
msg = str(excinfo.value)
|
||||
assert "bash_1" not in msg, "must not advise killing another scope's shell"
|
||||
assert "other agents" in msg
|
||||
# The same-scope variant names the killable shell.
|
||||
with pytest.raises(TooManyShellsError) as excinfo2:
|
||||
reg.spawn("sleep 30")
|
||||
assert "bash_1" in str(excinfo2.value)
|
||||
assert "kill_shell" in str(excinfo2.value)
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_prune_evicts_by_exit_order_not_spawn_order():
|
||||
"""A long-lived first-spawned server must never be evicted by its OWN
|
||||
exit's prune once enough later jobs have finished — eviction follows
|
||||
exit order, so the just-exited shell is always the newest record."""
|
||||
reg = BackgroundShellRegistry(max_exited_records=2)
|
||||
try:
|
||||
server = reg.spawn("sleep 30") # bash_1, exits LAST
|
||||
jobs = [reg.spawn(f"echo job-{i}") for i in range(3)]
|
||||
for job in jobs:
|
||||
assert _wait_status(job, "completed")
|
||||
reg.kill(server.shell_id)
|
||||
assert reg.has(server.shell_id), "the just-exited shell must survive its own exit's prune"
|
||||
# The earliest-EXITED job is the eviction victim, not bash_1.
|
||||
assert _wait_until(lambda: len(reg.shells()) <= 3)
|
||||
assert reg.read(server.shell_id).status == "killed"
|
||||
finally:
|
||||
reg.close()
|
||||
|
||||
|
||||
def test_thread_start_failure_leaves_no_orphan_record(registry, monkeypatch, tmp_path):
|
||||
"""If Thread.start raises (thread exhaustion), the record must be
|
||||
unregistered and the fresh group reaped — an orphan with never-started
|
||||
Thread objects would make every later close()/reap() join raise and
|
||||
abort session teardown."""
|
||||
pidfile = tmp_path / "leader.pid"
|
||||
real_thread = bg_mod.threading.Thread
|
||||
|
||||
class FailingWaiterThread(real_thread):
|
||||
def start(self):
|
||||
if "bg-shell-wait" in (self.name or ""):
|
||||
raise RuntimeError("can't start new thread")
|
||||
super().start()
|
||||
|
||||
monkeypatch.setattr(bg_mod.threading, "Thread", FailingWaiterThread)
|
||||
with pytest.raises(RuntimeError):
|
||||
registry.spawn(f"echo $$ > {pidfile}; sleep 60")
|
||||
assert registry.shells() == [], "failed spawn must not strand a record"
|
||||
if pidfile.exists():
|
||||
leader_pid = int(pidfile.read_text().strip())
|
||||
assert _wait_until(lambda: not _pid_alive(leader_pid)), "fresh group leaked"
|
||||
monkeypatch.undo()
|
||||
registry.close() # must not raise on the (empty) registry
|
||||
|
||||
|
||||
def test_filter_helper_failure_reports_exec_error_not_timeout(registry, monkeypatch):
|
||||
"""A crashed helper must not tell the model its (fine) pattern was too
|
||||
slow — and must not consume the delta."""
|
||||
shell = registry.spawn("echo hello")
|
||||
assert _wait_status(shell, "completed")
|
||||
monkeypatch.setattr(bg_mod.sys, "executable", "/bin/false")
|
||||
with pytest.raises(FilterExecError) as excinfo:
|
||||
registry.read(shell.shell_id, filter_pattern="hello")
|
||||
assert "not a problem with your pattern" in str(excinfo.value)
|
||||
monkeypatch.undo()
|
||||
read = registry.read(shell.shell_id)
|
||||
assert [ln.strip() for ln in read.lines] == ["hello"]
|
||||
|
||||
|
||||
def test_filter_matches_only_within_line_cap_and_reports_clipping(registry):
|
||||
"""Lines are truncated parent-side before shipping to the helper: a
|
||||
match beyond the per-line cap is not found (a filter targets log
|
||||
lines), and a huge retained line cannot burn the time budget on I/O.
|
||||
The clipping is NEVER silent — the read reports how many lines were
|
||||
only partially visible to the pattern."""
|
||||
shell = registry.spawn("printf 'x%.0s' $(seq 1 5000); echo needle-suffix")
|
||||
assert _wait_status(shell, "completed")
|
||||
read = registry.read(shell.shell_id, filter_pattern="needle")
|
||||
assert read.lines == []
|
||||
assert read.new_line_count == 1
|
||||
assert read.clipped_lines == 1
|
||||
|
||||
|
||||
def test_concurrent_reads_never_double_deliver(registry):
|
||||
"""Two simultaneous reads of one shell must SPLIT the delta between
|
||||
them, never both return it — the whole pass (snapshot → commit)
|
||||
serializes per shell. Without that, a parallel tool batch reading the
|
||||
same handle gets every line twice."""
|
||||
shell = registry.spawn("seq 1 200")
|
||||
assert _wait_status(shell, "completed")
|
||||
results: list[list[str]] = [[], []]
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def _reader(slot: int) -> None:
|
||||
barrier.wait()
|
||||
results[slot] = [ln.strip() for ln in registry.read(shell.shell_id).lines]
|
||||
|
||||
threads = [threading.Thread(target=_reader, args=(i,)) for i in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
combined = results[0] + results[1]
|
||||
assert len(combined) == 200, f"expected each line exactly once, got {len(combined)}"
|
||||
assert sorted(combined, key=int) == [str(i) for i in range(1, 201)]
|
||||
|
||||
|
||||
def test_filter_helper_spawn_failure_is_exec_error(registry, monkeypatch):
|
||||
"""A helper that fails to LAUNCH (fork pressure) must land in the same
|
||||
honest FilterExecError as a crashed helper — not escape as a raw
|
||||
OSError blaming nothing — and must not consume the delta."""
|
||||
shell = registry.spawn("echo hello")
|
||||
assert _wait_status(shell, "completed")
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise BlockingIOError("Resource temporarily unavailable")
|
||||
|
||||
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
|
||||
with pytest.raises(FilterExecError):
|
||||
registry.read(shell.shell_id, filter_pattern="hello")
|
||||
monkeypatch.undo()
|
||||
read = registry.read(shell.shell_id)
|
||||
assert [ln.strip() for ln in read.lines] == ["hello"]
|
||||
|
||||
|
||||
def test_on_exit_exception_does_not_wedge_the_shell():
|
||||
def _boom(shell):
|
||||
raise RuntimeError("callback bug")
|
||||
|
||||
reg = BackgroundShellRegistry(on_exit=_boom)
|
||||
try:
|
||||
shell = reg.spawn("echo hi")
|
||||
# The waiter thread must survive the callback raising: status still
|
||||
# lands and output is still readable.
|
||||
assert _wait_status(shell, "completed")
|
||||
assert [ln.strip() for ln in reg.read(shell.shell_id).lines] == ["hi"]
|
||||
finally:
|
||||
reg.close()
|
||||
@@ -1,801 +0,0 @@
|
||||
"""Session-level tests for the background-shell tool surface (#817).
|
||||
|
||||
Covers the wiring around :class:`BackgroundShellRegistry`:
|
||||
|
||||
* ``bash`` gains ``run_in_background: true`` (alias ``is_background``) —
|
||||
same approval gate, returns immediately with a ``bash_N`` handle.
|
||||
* ``bash_output`` — auto-approved delta reader (status + exit code + only
|
||||
new output since the last call, optional ``filter`` regex).
|
||||
* ``kill_shell`` — auto-approved kill of a registered shell's whole group.
|
||||
* Exit notices ride the NudgeQueue on channel ``"any"`` (the watch rail) so
|
||||
they drain at the next seam and can wake an idle workstream.
|
||||
* Lifecycle: ``close()`` reaps everything; generation-``cancel()`` does NOT
|
||||
(a deliberately-detached server survives a stopped turn); shells spawned
|
||||
inside a task_agent are owner-scoped and reaped when the agent finishes.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._proc_helpers import pid_alive as _pid_alive
|
||||
from tests._proc_helpers import poll_until as _wait_until
|
||||
from tests._session_helpers import make_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
s = make_session()
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
def _start_background(session, command, call_id="bg1", **extra_args):
|
||||
"""Prepare + execute a backgrounded bash call; return the result text."""
|
||||
args = {"command": command, "run_in_background": True, **extra_args}
|
||||
prepared = session._prepare_bash(call_id, args)
|
||||
assert "error" not in prepared, prepared.get("error")
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
return output
|
||||
|
||||
|
||||
def _only_shell(session):
|
||||
shells = session._background_shells.shells()
|
||||
assert len(shells) == 1
|
||||
return shells[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bash: run_in_background routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_bash_background_keeps_approval_gate(session):
|
||||
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
|
||||
assert prepared["needs_approval"] is True
|
||||
assert prepared["approval_label"] == "bash"
|
||||
|
||||
|
||||
def test_prepare_bash_background_header_says_background(session):
|
||||
prepared = session._prepare_bash("c1", {"command": "sleep 30", "run_in_background": True})
|
||||
assert "background" in prepared["header"]
|
||||
|
||||
|
||||
def test_background_bash_returns_immediately_with_handle(session):
|
||||
start = time.monotonic()
|
||||
output = _start_background(session, "sleep 30")
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed < 5, f"backgrounded call blocked for {elapsed:.1f}s"
|
||||
assert "bash_1" in output
|
||||
shell = _only_shell(session)
|
||||
assert shell.status == "running"
|
||||
assert _pid_alive(shell.pid)
|
||||
|
||||
|
||||
def test_background_start_mentions_reader_and_killer(session):
|
||||
"""The immediate result must teach the follow-up tools — weak-prior
|
||||
models (GPT-5.6) only reach for the poll pattern if the result names it."""
|
||||
output = _start_background(session, "sleep 30")
|
||||
assert "bash_output" in output
|
||||
assert "kill_shell" in output
|
||||
|
||||
|
||||
def test_is_background_alias_accepted(session):
|
||||
output = _start_background(session, "sleep 30", is_background=True)
|
||||
assert "bash_1" in output
|
||||
assert _only_shell(session).status == "running"
|
||||
|
||||
|
||||
def test_foreground_bash_routing_unchanged(session):
|
||||
prepared = session._prepare_bash("c1", {"command": "echo hi"})
|
||||
assert prepared["execute"] == session._exec_bash
|
||||
prepared_false = session._prepare_bash("c2", {"command": "echo hi", "run_in_background": False})
|
||||
assert prepared_false["execute"] == session._exec_bash
|
||||
|
||||
|
||||
def test_background_respects_command_blocklist(session):
|
||||
prepared = session._prepare_bash("c1", {"command": "shutdown now", "run_in_background": True})
|
||||
assert "error" in prepared
|
||||
assert session._background_shells.shells() == []
|
||||
|
||||
|
||||
def test_background_ignores_timeout(session):
|
||||
"""No bounded wait exists to time out — a 1s timeout must not kill the
|
||||
detached shell."""
|
||||
_start_background(session, "sleep 30", timeout=1)
|
||||
shell = _only_shell(session)
|
||||
time.sleep(1.5)
|
||||
assert shell.status == "running"
|
||||
assert _pid_alive(shell.pid)
|
||||
|
||||
|
||||
def test_background_spawn_failure_reports_error(session, monkeypatch):
|
||||
from turnstone.core import background_shells as bg_mod
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("cannot fork")
|
||||
|
||||
monkeypatch.setattr(bg_mod.subprocess, "Popen", _boom)
|
||||
prepared = session._prepare_bash("c1", {"command": "echo hi", "run_in_background": True})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "cannot fork" in output
|
||||
|
||||
|
||||
def test_too_many_background_shells_reports_error(session, monkeypatch):
|
||||
monkeypatch.setattr(session._background_shells, "_max_shells", 1)
|
||||
_start_background(session, "sleep 30", call_id="bg1")
|
||||
output = _start_background(session, "sleep 30", call_id="bg2")
|
||||
assert "bash_1" in output # the live shell is named so the model can kill it
|
||||
assert len(session._background_shells.shells()) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bash_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bash_output_is_auto_approved(session):
|
||||
prepared = session._prepare_bash_output("c1", {"id": "bash_1"})
|
||||
assert prepared["needs_approval"] is False
|
||||
|
||||
|
||||
def test_bash_output_missing_id_errors(session):
|
||||
prepared = session._prepare_bash_output("c1", {})
|
||||
assert "error" in prepared
|
||||
|
||||
|
||||
def test_bash_output_returns_delta_then_no_new_output(session):
|
||||
_start_background(session, "echo hello; sleep 30")
|
||||
shell = _only_shell(session)
|
||||
assert _wait_until(lambda: shell.status == "running")
|
||||
|
||||
def _read():
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
|
||||
assert "error" not in prepared
|
||||
return prepared["execute"](prepared)[1]
|
||||
|
||||
assert _wait_until(lambda: "hello" in _read())
|
||||
again = _read()
|
||||
assert "hello" not in again
|
||||
assert "no new output" in again.lower()
|
||||
assert "running" in again.lower()
|
||||
|
||||
|
||||
def test_bash_output_reports_exit_code_when_completed(session):
|
||||
_start_background(session, "exit 3")
|
||||
shell = _only_shell(session)
|
||||
assert _wait_until(lambda: shell.status == "completed")
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "completed" in output.lower()
|
||||
assert "3" in output
|
||||
|
||||
|
||||
def test_bash_output_filter_applies(session):
|
||||
_start_background(session, "echo match-a; echo skip-b")
|
||||
shell = _only_shell(session)
|
||||
assert _wait_until(lambda: shell.status == "completed")
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "^match"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "match-a" in output
|
||||
assert "skip-b" not in output
|
||||
|
||||
|
||||
def test_bash_output_invalid_filter_reports_error(session):
|
||||
_start_background(session, "sleep 30")
|
||||
shell = _only_shell(session)
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "[bad"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "regex" in output.lower() or "filter" in output.lower()
|
||||
|
||||
|
||||
def test_bash_output_unknown_id_lists_live_shells(session):
|
||||
_start_background(session, "sleep 30")
|
||||
prepared = session._prepare_bash_output("r", {"id": "bash_42"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "bash_42" in output
|
||||
assert "bash_1" in output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kill_shell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_shell_is_auto_approved(session):
|
||||
prepared = session._prepare_kill_shell("c1", {"id": "bash_1"})
|
||||
assert prepared["needs_approval"] is False
|
||||
|
||||
|
||||
def test_kill_shell_missing_id_errors(session):
|
||||
prepared = session._prepare_kill_shell("c1", {})
|
||||
assert "error" in prepared
|
||||
|
||||
|
||||
def test_kill_shell_kills_and_reports(session):
|
||||
_start_background(session, "sleep 60")
|
||||
shell = _only_shell(session)
|
||||
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "killed" in output.lower()
|
||||
assert _wait_until(lambda: not _pid_alive(shell.pid))
|
||||
# The schema promises the exit code for ANY exited state, killed included.
|
||||
read_prepared = session._prepare_bash_output("r", {"id": shell.shell_id})
|
||||
_cid, read_output = read_prepared["execute"](read_prepared)
|
||||
assert "exit code" in read_output
|
||||
|
||||
|
||||
def test_kill_shell_unknown_id_reports_error(session):
|
||||
prepared = session._prepare_kill_shell("k", {"id": "bash_9"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "bash_9" in output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit notices (NudgeQueue, channel "any", wake)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_natural_exit_enqueues_any_channel_notice(session):
|
||||
_start_background(session, "echo done")
|
||||
assert _wait_until(
|
||||
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
)
|
||||
entries = session._nudge_queue.pending(channel="any")
|
||||
texts = [text for t, text in entries if t == "background_shell_exit"]
|
||||
assert texts, "notice must ride channel 'any' so it can wake an idle workstream"
|
||||
assert "bash_1" in texts[0]
|
||||
assert "bash_output" in texts[0]
|
||||
|
||||
|
||||
def test_exit_notice_carries_metadata(session):
|
||||
_start_background(session, "exit 5")
|
||||
assert _wait_until(
|
||||
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
)
|
||||
metadata = [
|
||||
meta
|
||||
for t, _text, meta in session._nudge_queue.pending_with_metadata()
|
||||
if t == "background_shell_exit"
|
||||
][0]
|
||||
assert metadata["shell_id"] == "bash_1"
|
||||
assert metadata["exit_code"] == 5
|
||||
|
||||
|
||||
def test_exit_notice_triggers_wake_fn(session):
|
||||
wakes = []
|
||||
session._watch_wake_fn = lambda: wakes.append(1)
|
||||
_start_background(session, "echo done")
|
||||
assert _wait_until(lambda: wakes), "natural exit must wake an idle workstream"
|
||||
|
||||
|
||||
def test_kill_shell_suppresses_exit_notice(session):
|
||||
_start_background(session, "sleep 60")
|
||||
shell = _only_shell(session)
|
||||
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
|
||||
prepared["execute"](prepared)
|
||||
assert _wait_until(lambda: not _pid_alive(shell.pid))
|
||||
time.sleep(0.3) # a buggy late notice would land within this window
|
||||
assert not any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
|
||||
|
||||
def test_close_drops_pending_exit_notice_via_valid_until(session):
|
||||
"""A notice for a shell that no longer exists (registry closed) must not
|
||||
deliver — the valid_until predicate drops it at drain time."""
|
||||
_start_background(session, "echo done")
|
||||
assert _wait_until(
|
||||
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
)
|
||||
session.close()
|
||||
from turnstone.core.nudge_queue import USER_DRAIN
|
||||
|
||||
drained = session._nudge_queue.drain(USER_DRAIN)
|
||||
assert not any(t == "background_shell_exit" for t, _text, _m in drained)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_close_reaps_background_shells(session):
|
||||
_start_background(session, "sleep 60")
|
||||
shell = _only_shell(session)
|
||||
session.close()
|
||||
assert not _pid_alive(shell.pid)
|
||||
|
||||
|
||||
def test_generation_cancel_does_not_reap_background_shells(session):
|
||||
"""cancel() fires on mere stop-generation — a deliberately-detached
|
||||
server must survive it. Only close()/kill_shell end it."""
|
||||
_start_background(session, "sleep 60")
|
||||
shell = _only_shell(session)
|
||||
session.cancel()
|
||||
time.sleep(0.3)
|
||||
assert _pid_alive(shell.pid), "generation cancel must not kill detached shells"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Review-hardening regressions (#817 code review)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_string_typed_background_flag_is_honored(session):
|
||||
"""Providers intermittently send booleans as strings; 'true' must not
|
||||
silently fall through to the foreground executor (where the group kill
|
||||
would reap the server the model believed it detached)."""
|
||||
for call_id, args in (
|
||||
("s1", {"command": "sleep 30", "run_in_background": "true"}),
|
||||
("s2", {"command": "sleep 30", "is_background": "True"}),
|
||||
):
|
||||
prepared = session._prepare_bash(call_id, args)
|
||||
assert prepared["execute"] == session._exec_bash_background, args
|
||||
|
||||
|
||||
def test_kill_shell_on_completed_shell_reports_already_exited(session):
|
||||
_start_background(session, "true")
|
||||
shell = _only_shell(session)
|
||||
assert _wait_until(lambda: shell.status == "completed")
|
||||
prepared = session._prepare_kill_shell("k", {"id": shell.shell_id})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "already exited" in output.lower()
|
||||
|
||||
|
||||
def test_exit_notice_survives_generation_abandon_without_waking(session):
|
||||
"""cancel/interrupt/exception clear generation-scoped advisories, but an
|
||||
external event (a background shell exited) still happened — its notice
|
||||
must survive to the next seam or the model keeps talking to a dead
|
||||
server. It survives DEMOTED to 'quiet': still deliverable, but no
|
||||
longer wake-eligible, so the workstream the user just stopped cannot
|
||||
resume itself over it."""
|
||||
from turnstone.core.nudge_queue import USER_DRAIN, WAKE_PENDING
|
||||
|
||||
_start_background(session, "echo done")
|
||||
assert _wait_until(
|
||||
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
)
|
||||
session._queue_tool_advisory("tool_error", "3 consecutive tool errors")
|
||||
session._drain_pending_advisories()
|
||||
kinds = [t for t, _ in session._nudge_queue.pending()]
|
||||
assert "background_shell_exit" in kinds
|
||||
assert "tool_error" not in kinds
|
||||
# Post-cancel quiescence: nothing is wake-eligible...
|
||||
assert not session._nudge_queue.has_pending(WAKE_PENDING)
|
||||
# ...yet the notice still delivers at the next legitimate seam.
|
||||
drained = session._nudge_queue.drain(USER_DRAIN)
|
||||
assert any(t == "background_shell_exit" for t, _x, _m in drained)
|
||||
|
||||
|
||||
def test_int_typed_background_flag_is_honored(session):
|
||||
prepared = session._prepare_bash("i1", {"command": "sleep 30", "run_in_background": 1})
|
||||
assert prepared["execute"] == session._exec_bash_background
|
||||
prepared_zero = session._prepare_bash("i2", {"command": "echo hi", "run_in_background": 0})
|
||||
assert prepared_zero["execute"] == session._exec_bash
|
||||
|
||||
|
||||
def test_bash_output_non_string_filter_errors_without_consuming(session):
|
||||
_start_background(session, "echo hello; sleep 30")
|
||||
shell = _only_shell(session)
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": 123})
|
||||
assert "error" in prepared
|
||||
assert "filter" in prepared["error"].lower()
|
||||
# Nothing was consumed by the refused call.
|
||||
assert _wait_until(lambda: shell.unread_lines > 0)
|
||||
|
||||
|
||||
def test_filter_timeout_reports_error_without_consuming(session, monkeypatch):
|
||||
from turnstone.core.background_shells import FilterTimeoutError
|
||||
|
||||
_start_background(session, "sleep 30")
|
||||
shell = _only_shell(session)
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise FilterTimeoutError("filter regex took longer than 2s to run")
|
||||
|
||||
monkeypatch.setattr(session._background_shells, "read", _boom)
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "(a+)+$"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "filter" in output.lower()
|
||||
assert "error" in output.lower()
|
||||
|
||||
|
||||
def test_registries_are_isolated_per_session():
|
||||
"""Workstream isolation: a handle from one session must be unresolvable
|
||||
from another — buffers, ids, and kills never cross ChatSessions."""
|
||||
session_a = make_session()
|
||||
session_b = make_session()
|
||||
try:
|
||||
_start_background(session_a, "sleep 30")
|
||||
shell_a = _only_shell(session_a)
|
||||
read_b = session_b._prepare_bash_output("r", {"id": shell_a.shell_id})
|
||||
_cid, output = read_b["execute"](read_b)
|
||||
assert "no background shell" in output.lower()
|
||||
kill_b = session_b._prepare_kill_shell("k", {"id": shell_a.shell_id})
|
||||
_cid, kill_output = kill_b["execute"](kill_b)
|
||||
assert "no background shell" in kill_output.lower()
|
||||
assert _pid_alive(shell_a.pid), "another session must not be able to kill the shell"
|
||||
finally:
|
||||
session_a.close()
|
||||
session_b.close()
|
||||
|
||||
|
||||
def test_bash_output_polling_is_repeat_exempt(session):
|
||||
"""Repeated identical bash_output calls ARE the documented monitoring
|
||||
pattern — the repeat detector must not brand them 'identical repeat'
|
||||
(the delta result differs by construction) nor queue a repeat nudge."""
|
||||
import json as _json
|
||||
|
||||
_start_background(session, "sleep 30")
|
||||
shell = _only_shell(session)
|
||||
args = _json.dumps({"id": shell.shell_id})
|
||||
for i in range(5):
|
||||
tool_calls = [{"id": f"t{i}", "function": {"name": "bash_output", "arguments": args}}]
|
||||
results = [(f"t{i}", "bash_1 (running)\nNo new output since the last read.")]
|
||||
session._apply_post_execute_advisories(tool_calls, results)
|
||||
assert "identical repeat" not in results[0][1]
|
||||
assert not any(t == "repeat" for t, _ in session._nudge_queue.pending())
|
||||
|
||||
|
||||
def test_repeat_exempt_calls_still_break_other_streaks(session):
|
||||
"""The exemption suppresses the WARNING, not the recording: a
|
||||
bash_output poll interleaved between identical bash calls must reset
|
||||
the bash streak — otherwise the documented monitor-and-probe loop
|
||||
(poll, curl health, poll, curl health…) draws a false 'identical
|
||||
repeat' on the probe."""
|
||||
import json as _json
|
||||
|
||||
_start_background(session, "sleep 30")
|
||||
shell = _only_shell(session)
|
||||
poll_args = _json.dumps({"id": shell.shell_id})
|
||||
probe_args = _json.dumps({"command": "curl -s localhost:8080/health"})
|
||||
for i in range(6):
|
||||
probe = [{"id": f"p{i}", "function": {"name": "bash", "arguments": probe_args}}]
|
||||
probe_results = [(f"p{i}", "ok")]
|
||||
session._apply_post_execute_advisories(probe, probe_results)
|
||||
assert "identical repeat" not in probe_results[0][1], (
|
||||
"interleaved probes are not a stuck loop"
|
||||
)
|
||||
poll = [{"id": f"q{i}", "function": {"name": "bash_output", "arguments": poll_args}}]
|
||||
session._apply_post_execute_advisories(poll, [(f"q{i}", "no new output")])
|
||||
|
||||
|
||||
def test_bash_repeats_still_warn(session):
|
||||
"""The exemption is bash_output-specific: a genuinely stuck identical
|
||||
bash loop still gets the warning."""
|
||||
import json as _json
|
||||
|
||||
args = _json.dumps({"command": "echo test"})
|
||||
warned = False
|
||||
for i in range(5):
|
||||
tool_calls = [{"id": f"b{i}", "function": {"name": "bash", "arguments": args}}]
|
||||
results = [(f"b{i}", "test")]
|
||||
session._apply_post_execute_advisories(tool_calls, results)
|
||||
warned = warned or "identical repeat" in results[0][1]
|
||||
assert warned
|
||||
|
||||
|
||||
def test_quiet_only_entries_do_not_trigger_wake_delivery(session, monkeypatch):
|
||||
"""A dispatched wake whose wake-eligible entries all evaporated must be
|
||||
a no-op: quiet entries alone never resume a stopped workstream, and
|
||||
they stay queued for the next legitimate seam."""
|
||||
calls = []
|
||||
monkeypatch.setattr(session, "send", lambda *a, **k: calls.append(1))
|
||||
session._nudge_queue.enqueue("background_shell_exit", "old news", "quiet")
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
assert calls == []
|
||||
assert session._nudge_queue.pending(channel="quiet") == [("background_shell_exit", "old news")]
|
||||
|
||||
|
||||
def test_wake_delivers_quiet_alongside_eligible_in_insertion_order(session, monkeypatch):
|
||||
"""Quiet entries ride the wake AND cross-channel chronology holds: an
|
||||
older demoted notice renders before the newer fire that earned the
|
||||
wake (a poll counter must never run backwards)."""
|
||||
seen = {}
|
||||
|
||||
def _fake_send(*a, **k):
|
||||
seen["reminders"] = list(session._wake_drained_reminders or [])
|
||||
session._wake_drained_reminders = None # emulate emission consuming
|
||||
|
||||
monkeypatch.setattr(session, "send", _fake_send)
|
||||
session._nudge_queue.enqueue("background_shell_exit", "old", "quiet")
|
||||
session._nudge_queue.enqueue("watch_triggered", "new", "any")
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
types = [e["type"] for e in seen["reminders"]]
|
||||
assert types == ["background_shell_exit", "watch_triggered"], (
|
||||
"older quiet entry must precede the newer wake-eligible one"
|
||||
)
|
||||
assert session._nudge_queue.pending() == []
|
||||
|
||||
|
||||
def test_failed_wake_reenqueue_preserves_valid_until(session, monkeypatch):
|
||||
"""The re-enqueued notice keeps its staleness predicate — a stale
|
||||
notice re-queued by a failed wake must still be droppable at its next
|
||||
drain, not delivered against a gone shell."""
|
||||
from turnstone.core.nudge_queue import USER_DRAIN
|
||||
|
||||
alive = {"value": True}
|
||||
|
||||
def _fail(*a, **k):
|
||||
raise RuntimeError("storage down")
|
||||
|
||||
monkeypatch.setattr(session, "send", _fail)
|
||||
session._nudge_queue.enqueue(
|
||||
"background_shell_exit",
|
||||
"server died",
|
||||
"any",
|
||||
valid_until=lambda: alive["value"],
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
assert session._nudge_queue.pending(channel="quiet"), "notice must be re-queued"
|
||||
alive["value"] = False # the shell record is gone now
|
||||
drained = session._nudge_queue.drain(USER_DRAIN)
|
||||
assert drained == [], "stale re-queued notice must drop via its predicate"
|
||||
|
||||
|
||||
def test_mid_emit_failure_restashes_unemitted_tail(session, monkeypatch):
|
||||
"""A failure while emitting reminder k of n must leave k..n recoverable
|
||||
— the wake caller's finally re-enqueues them instead of losing the
|
||||
suffix."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def _append(source, text, **meta):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise RuntimeError("storage down")
|
||||
|
||||
monkeypatch.setattr(session, "_append_system_turn", _append)
|
||||
session._wake_drained_reminders = [
|
||||
{"type": "a", "text": "1"},
|
||||
{"type": "b", "text": "2"},
|
||||
{"type": "c", "text": "3"},
|
||||
]
|
||||
with pytest.raises(RuntimeError):
|
||||
session._emit_pending_user_nudges()
|
||||
assert session._wake_drained_reminders == [
|
||||
{"type": "b", "text": "2"},
|
||||
{"type": "c", "text": "3"},
|
||||
]
|
||||
|
||||
|
||||
def test_failed_wake_reenqueues_undelivered_as_quiet(session, monkeypatch):
|
||||
"""A wake send that dies before emitting its drained reminders must not
|
||||
eat them — a shell's exit notice fires exactly once."""
|
||||
|
||||
def _fail(*a, **k):
|
||||
raise RuntimeError("storage down")
|
||||
|
||||
monkeypatch.setattr(session, "send", _fail)
|
||||
session._nudge_queue.enqueue(
|
||||
"background_shell_exit", "server died", "any", metadata={"shell_id": "bash_1"}
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
pending = session._nudge_queue.pending_with_metadata(channel="quiet")
|
||||
assert [(t, x) for t, x, _m in pending] == [("background_shell_exit", "server died")]
|
||||
assert pending[0][2] == {"shell_id": "bash_1"}
|
||||
|
||||
|
||||
def test_failed_wake_preserves_chronology_and_stays_wake_quiescent(session, monkeypatch):
|
||||
"""Failed-wake recovery invariants: (a) the re-queued external notice
|
||||
keeps its seq, so the retry renders it BEFORE a newer event that
|
||||
arrived during the failure; (b) NOTHING wake-eligible remains after
|
||||
the failure — external notices demote to quiet and user-channel
|
||||
advisories are dropped outright, because a re-armed WAKE_PENDING gate
|
||||
plus the zero-backoff worker-exit retry would respawn wake workers in
|
||||
an unbounded hot loop against a persistent failure."""
|
||||
from turnstone.core.nudge_queue import WAKE_PENDING
|
||||
|
||||
calls = {"n": 0}
|
||||
seen = {}
|
||||
|
||||
def _send(*a, **k):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("transient storage failure")
|
||||
seen["reminders"] = list(session._wake_drained_reminders or [])
|
||||
session._wake_drained_reminders = None
|
||||
|
||||
monkeypatch.setattr(session, "send", _send)
|
||||
session._nudge_queue.enqueue("watch_triggered", "poll-4", "any")
|
||||
session._nudge_queue.enqueue("correction", "user advisory", "user")
|
||||
with pytest.raises(RuntimeError):
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
# (b) bounded: nothing left that could re-trigger the wake gate.
|
||||
assert not session._nudge_queue.has_pending(WAKE_PENDING), (
|
||||
"a failed wake must not leave wake-eligible entries (respawn hot loop)"
|
||||
)
|
||||
assert [t for t, _x in session._nudge_queue.pending(channel="quiet")] == ["watch_triggered"]
|
||||
# A NEWER event lands after the failure...
|
||||
session._nudge_queue.enqueue("watch_triggered", "poll-5", "any")
|
||||
session.deliver_wake_nudge_from_queue()
|
||||
texts = [e["text"] for e in seen["reminders"]]
|
||||
# (a) ...and the retry renders old-before-new despite the round trip.
|
||||
assert texts.index("poll-4") < texts.index("poll-5")
|
||||
|
||||
|
||||
def test_exit_notice_emits_end_to_end_as_system_turn(session):
|
||||
"""THE test whose absence hid an undeliverable notice for six review
|
||||
rounds: drive the notice through REAL emission (make_system_turn +
|
||||
_append_system_turn), not just queue assertions — an unregistered
|
||||
``_source`` raises ValueError only at this layer."""
|
||||
_start_background(session, "echo done")
|
||||
assert _wait_until(
|
||||
lambda: any(t == "background_shell_exit" for t, _ in session._nudge_queue.pending())
|
||||
)
|
||||
from turnstone.core.trajectory import Role
|
||||
|
||||
before = len(session.messages)
|
||||
session._emit_pending_user_nudges() # must not raise
|
||||
new_turns = session.messages[before:]
|
||||
assert any(
|
||||
turn.role is Role.SYSTEM and turn.source == "background_shell_exit" for turn in new_turns
|
||||
), f"exit notice must land as a first-class system turn, got {new_turns!r}"
|
||||
|
||||
|
||||
def test_cli_exit_closes_every_loaded_session():
|
||||
"""CLI exit must reap background shells in EVERY workstream, not just
|
||||
the active one — a server started before /new must not outlive /exit."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.cli import _close_all_sessions
|
||||
|
||||
ws_a, ws_b, ws_never_loaded = MagicMock(), MagicMock(), MagicMock()
|
||||
ws_never_loaded.session = None
|
||||
ws_a.session.close.side_effect = RuntimeError("bad teardown")
|
||||
manager = MagicMock()
|
||||
manager.list_all.return_value = [ws_a, ws_b, ws_never_loaded]
|
||||
_close_all_sessions(manager) # must not raise
|
||||
ws_a.session.close.assert_called_once()
|
||||
ws_b.session.close.assert_called_once(), "one bad teardown must not stop the rest"
|
||||
# Signal phase ran for every loaded session, before any close.
|
||||
ws_a.session._background_shells.signal_all.assert_called_once()
|
||||
ws_b.session._background_shells.signal_all.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_exit_ctrl_c_does_not_abort_the_reap():
|
||||
"""Ctrl-C during the close phase must not escape the helper: the kill
|
||||
signals already landed on every session in phase 1, and an escaping
|
||||
KeyboardInterrupt would also skip MCP/registry shutdown in main()."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.cli import _close_all_sessions
|
||||
|
||||
ws_a, ws_b = MagicMock(), MagicMock()
|
||||
ws_a.session.close.side_effect = KeyboardInterrupt
|
||||
manager = MagicMock()
|
||||
manager.list_all.return_value = [ws_a, ws_b]
|
||||
_close_all_sessions(manager) # must not raise
|
||||
ws_a.session._background_shells.signal_all.assert_called_once()
|
||||
(
|
||||
ws_b.session._background_shells.signal_all.assert_called_once(),
|
||||
("signals must land on every session before the interruptible close phase"),
|
||||
)
|
||||
|
||||
|
||||
def test_non_string_reminder_text_drops_silently(session):
|
||||
"""A dict reminder with non-str text must drop at the rail, not
|
||||
TypeError out of the dispatch closure (WatchRunner would re-fire the
|
||||
row every tick)."""
|
||||
runner = type(
|
||||
"R",
|
||||
(),
|
||||
{
|
||||
"set_dispatch_fn": lambda self, ws, fn: None,
|
||||
"remove_dispatch_fn": lambda self, ws, owner=None: None,
|
||||
},
|
||||
)()
|
||||
session.set_watch_runner(runner)
|
||||
session._watch_dispatch_fn({"text": 123, "watch_name": "w"}, "watch-1") # must not raise
|
||||
assert session._nudge_queue.pending() == []
|
||||
|
||||
|
||||
def test_string_typed_stop_on_error_is_honored(session):
|
||||
"""One coercion dialect for every bash boolean: a string-typed
|
||||
stop_on_error must add set -e in both branches, not silently drop it."""
|
||||
fg = session._prepare_bash("f1", {"command": "echo hi", "stop_on_error": "true"})
|
||||
assert fg["stop_on_error"] is True
|
||||
bg = session._prepare_bash(
|
||||
"b1", {"command": "echo hi", "run_in_background": True, "stop_on_error": "true"}
|
||||
)
|
||||
assert bg["stop_on_error"] is True
|
||||
|
||||
|
||||
def test_non_dict_watch_reminder_drops_silently(session):
|
||||
"""The rebuilt dispatch closure must drop a non-dict reminder like the
|
||||
old code did — a TypeError would make WatchRunner hold and re-fire the
|
||||
row every tick."""
|
||||
runner = type(
|
||||
"R",
|
||||
(),
|
||||
{
|
||||
"set_dispatch_fn": lambda self, ws, fn: None,
|
||||
"remove_dispatch_fn": lambda self, ws, owner=None: None,
|
||||
},
|
||||
)()
|
||||
session.set_watch_runner(runner)
|
||||
dispatch = session._watch_dispatch_fn
|
||||
dispatch("not a dict", "watch-1") # must not raise
|
||||
assert session._nudge_queue.pending() == []
|
||||
|
||||
|
||||
def test_truthy_flag_dialect_is_unified():
|
||||
"""One coercion dialect file-wide — 'on' and nonzero numbers count, so a
|
||||
provider quirk honored on coordinator tools is honored on bash too."""
|
||||
from turnstone.core.session import _is_truthy_flag
|
||||
|
||||
assert _is_truthy_flag(True)
|
||||
assert _is_truthy_flag("on")
|
||||
assert _is_truthy_flag(2)
|
||||
assert not _is_truthy_flag("off")
|
||||
assert not _is_truthy_flag(0)
|
||||
assert not _is_truthy_flag(None)
|
||||
assert not _is_truthy_flag(False)
|
||||
|
||||
|
||||
def test_bash_output_notes_clipped_lines_under_filter(session):
|
||||
_start_background(session, "printf 'x%.0s' $(seq 1 5000); echo tail")
|
||||
shell = _only_shell(session)
|
||||
assert _wait_until(lambda: shell.status == "completed")
|
||||
prepared = session._prepare_bash_output("r", {"id": shell.shell_id, "filter": "zzz"})
|
||||
_cid, output = prepared["execute"](prepared)
|
||||
assert "partially visible" in output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_agent scoping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_agent_shells_are_owner_scoped_and_reaped(session, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_run_agent(agent_turns, label="task", **kwargs):
|
||||
out = _start_background(session, "sleep 60", call_id="sub-bash")
|
||||
seen["start_output"] = out
|
||||
agent_shells = session._background_shells.shells(owner="task-1")
|
||||
seen["agent_shells"] = list(agent_shells)
|
||||
seen["pid"] = agent_shells[0].pid if agent_shells else None
|
||||
# The sub-agent's shell is invisible to the main scope.
|
||||
seen["visible_to_parent"] = [s.shell_id for s in session._background_shells.shells()]
|
||||
return "agent done"
|
||||
|
||||
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
|
||||
call_id, result = session._exec_task({"call_id": "task-1", "prompt": "start a server"})
|
||||
assert "agent done" in result
|
||||
assert seen["agent_shells"], "shell spawned inside the agent must carry its owner"
|
||||
# Scope honesty in the start message: the sub-agent must not promise its
|
||||
# caller a server that dies the moment it returns.
|
||||
assert "terminated when the agent finishes" in seen["start_output"]
|
||||
assert seen["visible_to_parent"] == []
|
||||
assert seen["pid"] is not None
|
||||
assert _wait_until(lambda: not _pid_alive(seen["pid"])), (
|
||||
"sub-agent shells must be reaped when the agent finishes"
|
||||
)
|
||||
|
||||
|
||||
def test_task_agent_cannot_touch_parent_shells(session, monkeypatch):
|
||||
_start_background(session, "sleep 60", call_id="parent-bash")
|
||||
parent_shell = _only_shell(session)
|
||||
seen = {}
|
||||
|
||||
def fake_run_agent(agent_turns, label="task", **kwargs):
|
||||
prepared = session._prepare_bash_output("r", {"id": parent_shell.shell_id})
|
||||
seen["read_output"] = prepared["execute"](prepared)[1]
|
||||
prepared_kill = session._prepare_kill_shell("k", {"id": parent_shell.shell_id})
|
||||
seen["kill_output"] = prepared_kill["execute"](prepared_kill)[1]
|
||||
return "done"
|
||||
|
||||
monkeypatch.setattr(session, "_run_agent", fake_run_agent)
|
||||
session._exec_task({"call_id": "task-1", "prompt": "snoop"})
|
||||
assert "no background shell" in seen["read_output"].lower()
|
||||
assert "no background shell" in seen["kill_output"].lower()
|
||||
assert _pid_alive(parent_shell.pid), "agent must not be able to kill a parent shell"
|
||||
|
||||
|
||||
def test_parent_scope_restored_after_task_agent(session, monkeypatch):
|
||||
monkeypatch.setattr(session, "_run_agent", lambda *a, **k: "done")
|
||||
session._exec_task({"call_id": "task-1", "prompt": "noop"})
|
||||
output = _start_background(session, "sleep 30", call_id="after-task")
|
||||
assert "bash_1" in output
|
||||
assert _only_shell(session).owner is None
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Regression tests for the bash tool hanging on a backgrounded child.
|
||||
|
||||
A bash command that backgrounds a long-lived process (``server &``,
|
||||
``python -m http.server &``, any daemon) used to wedge the whole workstream
|
||||
forever: the child inherits the tool's stdout/stderr pipe, so the foreground
|
||||
read never hit EOF, and the timeout watchdog bailed the moment the tracked
|
||||
``bash`` exited. ``_exec_bash`` now waits on the tracked process (not pipe
|
||||
EOF) bounded by ``tool_timeout`` and kills the whole session group on exit, so
|
||||
the call always returns and never leaks the background child.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from tests._proc_helpers import kill_pid as _kill_pid
|
||||
from tests._proc_helpers import pid_alive as _pid_alive
|
||||
from tests._session_helpers import NullUI, make_session
|
||||
from turnstone.core.trajectory import EffectStatus
|
||||
|
||||
|
||||
def _run_in_thread(fn, timeout):
|
||||
"""Run ``fn`` in a daemon thread; return ``(finished, result)``."""
|
||||
box = {}
|
||||
|
||||
def _target():
|
||||
box["result"] = fn()
|
||||
|
||||
t = threading.Thread(target=_target, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout)
|
||||
return (not t.is_alive()), box.get("result")
|
||||
|
||||
|
||||
def test_backgrounded_child_does_not_hang_and_is_reaped(tmp_path):
|
||||
"""Foreground exits immediately but leaves ``sleep 60 &`` holding the pipe.
|
||||
|
||||
Old behaviour: infinite hang (EOF never arrives, watchdog bails once the
|
||||
tracked bash exits). New behaviour: returns promptly and the background
|
||||
child is reaped by the session-group kill.
|
||||
"""
|
||||
pidfile = str(tmp_path / "bg.pid")
|
||||
# A generous tool_timeout proves the return comes from foreground-exit, not
|
||||
# from the deadline firing.
|
||||
session = make_session(tool_timeout=30)
|
||||
command = f"sleep 60 & echo $! > {pidfile}; echo done"
|
||||
bg_pid = None
|
||||
try:
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
||||
timeout=15,
|
||||
)
|
||||
assert finished, "_exec_bash hung on a backgrounded child"
|
||||
assert result is not None
|
||||
call_id, output = result
|
||||
assert call_id == "c1"
|
||||
assert "done" in output
|
||||
|
||||
# The backgrounded process must have been reaped by the group kill.
|
||||
with open(pidfile) as f:
|
||||
bg_pid = int(f.read().strip())
|
||||
deadline = time.monotonic() + 5
|
||||
while _pid_alive(bg_pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert not _pid_alive(bg_pid), f"backgrounded child {bg_pid} leaked"
|
||||
finally:
|
||||
if bg_pid is not None:
|
||||
_kill_pid(bg_pid)
|
||||
|
||||
|
||||
def test_timeout_still_fires_with_backgrounded_child():
|
||||
"""A silent foreground command plus a backgrounded child still hits the
|
||||
deadline: the watchdog kills the whole group and the result reads UNKNOWN
|
||||
(the ``unknown, never none`` timeout discipline)."""
|
||||
session = make_session(tool_timeout=1)
|
||||
command = "sleep 60 & sleep 60"
|
||||
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
||||
timeout=10,
|
||||
)
|
||||
assert finished, "_exec_bash did not return at its deadline"
|
||||
assert result is not None
|
||||
call_id, output = result
|
||||
assert call_id == "c1"
|
||||
assert "timed out" in output.lower()
|
||||
assert "UNKNOWN" in output
|
||||
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
|
||||
|
||||
|
||||
def test_undecodable_output_is_preserved_not_swallowed():
|
||||
"""Undecodable bytes on stdout must not silently vanish.
|
||||
|
||||
The drain's broad ``except (ValueError, OSError)`` would otherwise catch the
|
||||
``UnicodeDecodeError`` (a ``ValueError``) and kill the thread before any line
|
||||
was yielded — dropping ALL output and reporting a clean success. ``Popen``
|
||||
now decodes with ``errors="replace"`` so output always survives.
|
||||
"""
|
||||
session = make_session(tool_timeout=30)
|
||||
# Valid lines bracketing a raw invalid-UTF-8 byte sequence.
|
||||
command = r"printf 'before\n'; printf '\xff\xfe'; printf 'after\n'"
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
||||
timeout=15,
|
||||
)
|
||||
assert finished
|
||||
assert result is not None
|
||||
_call_id, output = result
|
||||
assert output != "(no output)"
|
||||
assert "before" in output
|
||||
assert "after" in output
|
||||
|
||||
|
||||
def test_stdout_streams_to_ui_from_drain_thread():
|
||||
"""stdout chunks are now emitted from the drain thread; they must still reach
|
||||
``on_tool_output_chunk``."""
|
||||
chunks: list[str] = []
|
||||
|
||||
class RecordingUI(NullUI):
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
chunks.append(chunk)
|
||||
|
||||
session = make_session(tool_timeout=30, ui=RecordingUI())
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": "echo streamed-line"}),
|
||||
timeout=15,
|
||||
)
|
||||
assert finished
|
||||
assert any("streamed-line" in c for c in chunks)
|
||||
|
||||
|
||||
def test_cancel_midbash_reports_unknown():
|
||||
"""An external ``cancel()`` during a running bash unblocks the process-bounded
|
||||
wait and reports UNKNOWN (unknown-never-none), not a clean result."""
|
||||
session = make_session(tool_timeout=30)
|
||||
|
||||
def _cancel_soon():
|
||||
time.sleep(0.5)
|
||||
session.cancel()
|
||||
|
||||
threading.Thread(target=_cancel_soon, daemon=True).start()
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": "sleep 30"}),
|
||||
timeout=15,
|
||||
)
|
||||
assert finished, "cancel did not unblock _exec_bash"
|
||||
assert result is not None
|
||||
_call_id, output = result
|
||||
assert "cancelled" in output.lower()
|
||||
assert session._tool_status.get("c1") is EffectStatus.UNKNOWN
|
||||
|
||||
|
||||
def test_popen_failure_reports_cleanly(monkeypatch):
|
||||
"""If ``Popen`` itself raises, the ``finally`` must not mask the real error
|
||||
with ``UnboundLocalError`` — ``proc`` is pre-bound to ``None``."""
|
||||
from turnstone.core import session as session_mod
|
||||
|
||||
session = make_session(tool_timeout=30)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("cannot fork")
|
||||
|
||||
monkeypatch.setattr(session_mod.subprocess, "Popen", _boom)
|
||||
call_id, output = session._exec_bash({"call_id": "c1", "command": "echo hi"})
|
||||
assert call_id == "c1"
|
||||
assert "cannot fork" in output
|
||||
@@ -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
|
||||
|
||||
|
||||
+67
-160
@@ -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
|
||||
|
||||
@@ -70,7 +71,7 @@ def _make_judge(
|
||||
session_provider=provider,
|
||||
session_client=client,
|
||||
session_model="test-model",
|
||||
session_capabilities=MagicMock(context_window=100_000),
|
||||
context_window=100_000,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -887,116 +892,15 @@ class TestModelAliasResolution:
|
||||
alias_provider: MagicMock,
|
||||
alias_client: MagicMock,
|
||||
underlying_model: str,
|
||||
*,
|
||||
capabilities: dict[str, Any] | None = None,
|
||||
) -> MagicMock:
|
||||
registry = MagicMock()
|
||||
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
|
||||
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
|
||||
the call site, or breaking the merge, must fail here."""
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
base = ModelCapabilities(supports_tools=True, effort_passthrough=False)
|
||||
alias_provider = _make_mock_provider(response_content=_good_verdict_json())
|
||||
alias_provider.get_capabilities = MagicMock(return_value=base)
|
||||
registry = self._make_alias_registry(
|
||||
"judge-mini",
|
||||
alias_provider,
|
||||
MagicMock(base_url="https://a/v1", api_key="k"),
|
||||
"local-9b",
|
||||
capabilities={"supports_tools": False, "effort_passthrough": True},
|
||||
)
|
||||
judge = 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,
|
||||
)
|
||||
# Merged at construction: overrides applied, untouched fields survive.
|
||||
assert judge._capabilities.supports_tools is False
|
||||
assert judge._capabilities.effort_passthrough is True
|
||||
assert judge._capabilities.context_window == base.context_window
|
||||
# ...and the SAME merged object reaches the wire.
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "x"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
passed = alias_provider.create_streaming.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``."""
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
sess_caps = ModelCapabilities(context_window=54_321, effort_passthrough=True)
|
||||
provider = _make_mock_provider(response_content=_good_verdict_json())
|
||||
judge = IntentJudge(
|
||||
config=JudgeConfig(enabled=True, model=""), # no alias → fallback
|
||||
session_provider=provider,
|
||||
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
|
||||
session_model="session-model",
|
||||
session_capabilities=sess_caps,
|
||||
)
|
||||
assert judge._capabilities is sess_caps
|
||||
assert judge._judge_context_window == 54_321
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "x"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert provider.create_streaming.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,
|
||||
and concrete model name all come from the alias."""
|
||||
@@ -1028,6 +932,7 @@ class TestModelAliasResolution:
|
||||
session_provider=session_provider,
|
||||
session_client=session_client,
|
||||
session_model="session-default-model",
|
||||
context_window=100_000,
|
||||
model_registry=registry,
|
||||
)
|
||||
|
||||
@@ -1054,6 +959,7 @@ class TestModelAliasResolution:
|
||||
session_provider=_make_mock_provider(),
|
||||
session_client=MagicMock(base_url="https://s/v1", api_key="s"),
|
||||
session_model="session-model",
|
||||
context_window=100_000,
|
||||
model_registry=registry,
|
||||
)
|
||||
assert judge._judge_context_window == 50_000
|
||||
@@ -1074,7 +980,7 @@ class TestModelAliasResolution:
|
||||
session_provider=_make_mock_provider(),
|
||||
session_client=MagicMock(base_url="http://s", api_key="s"),
|
||||
session_model="session-model",
|
||||
session_capabilities=MagicMock(context_window=100_000),
|
||||
context_window=100_000,
|
||||
model_registry=registry,
|
||||
)
|
||||
assert judge._judge_context_window == 100_000 # session window, not 0
|
||||
@@ -1102,7 +1008,7 @@ class TestModelAliasResolution:
|
||||
session_provider=session_provider,
|
||||
session_client=session_client,
|
||||
session_model="session-default-model",
|
||||
session_capabilities=MagicMock(context_window=100_000),
|
||||
context_window=100_000,
|
||||
model_registry=registry,
|
||||
)
|
||||
|
||||
@@ -1125,6 +1031,7 @@ class TestModelAliasResolution:
|
||||
session_provider=session_provider,
|
||||
session_client=session_client,
|
||||
session_model="session-default-model",
|
||||
context_window=100_000,
|
||||
)
|
||||
|
||||
assert judge._provider is session_provider
|
||||
|
||||
@@ -18,7 +18,6 @@ from turnstone.core.lowering import (
|
||||
CANCELLED_TOOL_RESULT,
|
||||
_find_orphaned_tool_calls,
|
||||
repair_wire_messages,
|
||||
restore_provider_tool_ids,
|
||||
sanitize_tool_call_arguments,
|
||||
tool_args_preview,
|
||||
wire_valid_arguments,
|
||||
@@ -341,90 +340,3 @@ def test_pipeline_every_emitted_arguments_is_a_json_object() -> None:
|
||||
for m in out:
|
||||
for tc in m.get("tool_calls", []):
|
||||
assert isinstance(json.loads(tc["function"]["arguments"]), dict)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# restore_provider_tool_ids — the agent-wire id map (minted → provider-original).
|
||||
#
|
||||
# Sub-agent tool ids are minted "{parent}::r{run}s{step}::{provider_id}" for
|
||||
# session-unique correlation (registry / DOM / recall). On the wire the pass
|
||||
# maps them BACK to the provider's own ids from the per-run mint map, so the
|
||||
# provider-native tool_use block (replayed verbatim, id never rewritten), the
|
||||
# top-level tool_calls mirror, and the tool_result all agree on every request.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_restore_ids_identity_on_empty_map() -> None:
|
||||
msgs = [_assistant_calls(_call("task-1::r1s1::call_0", "{}")), _tool("task-1::r1s1::call_0")]
|
||||
assert restore_provider_tool_ids(msgs, {}) is msgs
|
||||
|
||||
|
||||
def test_restore_ids_identity_when_nothing_matches() -> None:
|
||||
msgs = [_assistant_calls(_call("call_1", "{}")), _tool("call_1")]
|
||||
assert restore_provider_tool_ids(msgs, {"task-1::r1s1::call_0": "call_0"}) is msgs
|
||||
|
||||
|
||||
def test_restore_ids_maps_call_and_result_to_provider_original() -> None:
|
||||
minted = "task-1::r1s1::toolu_01AB"
|
||||
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
|
||||
out = restore_provider_tool_ids(msgs, {minted: "toolu_01AB"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "toolu_01AB"
|
||||
assert out[1]["tool_call_id"] == "toolu_01AB" # pairing restored on both sides
|
||||
# Copy-on-write: the input messages (the canonical-adjacent dicts) are unmutated.
|
||||
assert msgs[0]["tool_calls"][0]["id"] == minted
|
||||
assert msgs[1]["tool_call_id"] == minted
|
||||
|
||||
|
||||
def test_restore_ids_recovers_originals_containing_the_mint_delimiter() -> None:
|
||||
# Recovery is by MAP, not by string-splitting the mint suffix: a provider
|
||||
# id that itself contains "::" round-trips exactly.
|
||||
original = "srv::call::0"
|
||||
minted = f"task-1::r1s1::{original}"
|
||||
msgs = [_assistant_calls(_call(minted, "{}")), _tool(minted)]
|
||||
out = restore_provider_tool_ids(msgs, {minted: original})
|
||||
assert out[0]["tool_calls"][0]["id"] == original
|
||||
assert out[1]["tool_call_id"] == original
|
||||
|
||||
|
||||
def test_restore_ids_duplicate_originals_across_turns() -> None:
|
||||
# A local server reissuing "call_0" every turn: two distinct minted ids
|
||||
# both restore to "call_0" — the proven prior wire shape, each round
|
||||
# pairing with its adjacent result.
|
||||
m1, m2 = "task-1::r1s1::call_0", "task-1::r1s2::call_0"
|
||||
msgs = [
|
||||
_assistant_calls(_call(m1, "{}")),
|
||||
_tool(m1),
|
||||
_assistant_calls(_call(m2, "{}")),
|
||||
_tool(m2),
|
||||
]
|
||||
out = restore_provider_tool_ids(msgs, {m1: "call_0", m2: "call_0"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "call_0"
|
||||
assert out[1]["tool_call_id"] == "call_0"
|
||||
assert out[2]["tool_calls"][0]["id"] == "call_0"
|
||||
assert out[3]["tool_call_id"] == "call_0"
|
||||
|
||||
|
||||
def test_restore_ids_leaves_unmapped_siblings_untouched() -> None:
|
||||
minted = "task-1::r1s2::call_1"
|
||||
msgs = [
|
||||
_assistant_calls(_call("call_ok", "{}"), _call(minted, "{}")),
|
||||
_tool("call_ok"),
|
||||
_tool(minted),
|
||||
]
|
||||
out = restore_provider_tool_ids(msgs, {minted: "call_1"})
|
||||
assert out[0]["tool_calls"][0]["id"] == "call_ok"
|
||||
assert out[1]["tool_call_id"] == "call_ok"
|
||||
assert out[0]["tool_calls"][1]["id"] == "call_1"
|
||||
assert out[2]["tool_call_id"] == "call_1"
|
||||
|
||||
|
||||
def test_restore_ids_skips_empty_and_non_string() -> None:
|
||||
# Empty ids belong to repair_wire_messages' back-fill; non-strings are
|
||||
# someone else's malformation — neither is this pass's to invent.
|
||||
msgs = [
|
||||
_assistant_calls(
|
||||
{"id": "", "type": "function", "function": {"name": "b", "arguments": "{}"}}
|
||||
),
|
||||
{"role": "tool", "tool_call_id": None, "content": "x"},
|
||||
]
|
||||
out = restore_provider_tool_ids(msgs, {"task-1::r1s1::x": "x"})
|
||||
assert out[0]["tool_calls"][0]["id"] == ""
|
||||
assert out[1]["tool_call_id"] is None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user