mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,5 @@
|
||||
# Funding platforms for the GitHub "Sponsor" button.
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
github: [eous]
|
||||
custom: ["https://paypal.me/eousphoros"]
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- 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@01872ccc02bf66740207fb338a783ce028216758 # 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@01872ccc02bf66740207fb338a783ce028216758 # v1
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
+63
-4
@@ -6,13 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/) for
|
||||
version numbers (`X.Y.Z`, with `X.Y.ZaN` / `bN` / `rcN` for pre-releases).
|
||||
|
||||
Three release tracks are maintained — the current stable, one prior
|
||||
stable, and the experimental line:
|
||||
Two active release tracks are maintained — the current stable and the
|
||||
experimental line:
|
||||
|
||||
- **`stable/1.5`** — patch-only (`v1.5.x`)
|
||||
- **`stable/1.6`** — patch-only (`v1.6.x`)
|
||||
- **`stable/1.7`** — patch-only (`v1.7.x`)
|
||||
- **`main`** — experimental (next major)
|
||||
|
||||
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
## [1.7.1]
|
||||
|
||||
A maintenance and hardening patch for the 1.7 line. No schema migrations;
|
||||
the credential-redaction work below is additive and needs no configuration
|
||||
change. The one new operator-facing knob is the opt-in `[oidc]
|
||||
allow_private_network` flag (default off).
|
||||
|
||||
### Security
|
||||
|
||||
- **Credential redaction hardened across the tool-call surface** — the
|
||||
redactor that scrubs secrets from tool arguments and log previews was
|
||||
reworked on both the backend and the browser to close several leak paths
|
||||
and to fix false-positive and performance issues. Malformed tool-call
|
||||
arguments are now legalised before they reach the wire; the tool-args log
|
||||
preview scrubs credentials and control characters; and the coordinator's
|
||||
tool-call cards gain a matching client-side redaction pass so the JS and
|
||||
backend redactors stay at parity. Pattern coverage now includes
|
||||
`secret_access_key` / `aws_secret_access_key` multi-segment keys, bare
|
||||
`token=` / `key=` forms (guarded by a negative lookbehind to avoid
|
||||
false positives), and SQLAlchemy `+driver`-qualified connection-string
|
||||
schemes matched case-insensitively.
|
||||
- **OIDC SSRF guard: `[oidc] allow_private_network` opt-in** — self-hosted
|
||||
identity providers on private networks can now be reached by setting
|
||||
`allow_private_network = true` under `[oidc]` (default off; the MCP OAuth
|
||||
path stays strict). Rejections of discovered endpoints carry the opt-in
|
||||
hint so the misconfiguration is self-explanatory. See `docs/oidc.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Persona discoverability + forgiving name resolution** — personas are
|
||||
now discoverable by agents, and persona-name resolution tolerates
|
||||
case/whitespace variation; a not-found resolution reports the offending
|
||||
input verbatim instead of a bare error.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP transport lifecycles routed through per-entry owner tasks**
|
||||
(#787/#788) — static and pooled MCP transport lifecycles are now driven
|
||||
by per-server / per-entry owner tasks, with a hardened disarm-sweep loop
|
||||
guard and targeted exception handling in place of a broad `BaseException`
|
||||
arm, so a dying transport can no longer spin the CPU or strand delivery.
|
||||
- **Client-construction failures surface as misconfiguration, not raw
|
||||
500s** — a model whose client cannot be constructed now reports a factory
|
||||
misconfiguration, and the raw exception text is kept out of the resulting
|
||||
503 response.
|
||||
- **Postgres history search survives oversized rows** — a conversation row
|
||||
exceeding Postgres' full-text limits no longer aborts history search.
|
||||
- **Agent-tool render is idempotent** — tool rendering no longer deep-copies
|
||||
a tool definition until a description actually changes, so no-persona
|
||||
sessions share the tool constant (correctness plus a hot-path allocation
|
||||
win).
|
||||
- **Private-project workstream visibility scoped to members** — workstreams
|
||||
in a private project are visible to project members only, not to every
|
||||
admin; coordinator tenancy checks now use request-scoped storage.
|
||||
- **Pane hotkeys work off macOS and match across surfaces** — the pane
|
||||
keyboard shortcuts no longer collide with browser accelerators on
|
||||
non-macOS platforms and behave consistently across surfaces.
|
||||
|
||||
## [1.7.0]
|
||||
|
||||
The headline of the 1.7 line is **Personas** — operator-authored control
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /usr/local/bin/uv
|
||||
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
|
||||
+4
-2
@@ -137,9 +137,9 @@ With that caveat, **the interlingua and the certificate are one object seen twic
|
||||
|
||||
Borrowed theorems are real; the framings are not — keep them separate. Some framings are nonetheless *corroborated* — independently reached from another field — a third grade, weaker than proof and noted last.
|
||||
|
||||
**Proven (citable).** Foster–Lyapunov drift ⇒ positive recurrence + $\mathbb{E}[\tau]\le V(s_0)/\varepsilon$ (Foster 1953; Meyn & Tweedie, *Markov Chains and Stochastic Stability*, 1993) — positive recurrence needs the usual irreducibility/petite-set hypotheses, while the absorbing-halt case used here needs only the weaker supermartingale optional-stopping hitting-time bound. The minimal $V$ is the expected hitting time, by first-step analysis + optional stopping (Norris, *Markov Chains*, 1997). For an absorbing chain that expected hitting time is the row sum of the fundamental matrix $N=\sum_{n\ge0}Q_{\mathrm{tr}}^{\,n}$ (Kemeny & Snell, *Finite Markov Chains*, 1960), with the general-state analogue the potential (Green) operator (Revuz, *Markov Chains*, 1984). Koopman's linear-operator view of nonlinear dynamics is classical (Koopman 1931), and Lyapunov functions can be assembled from its eigenfunctions when the spectrum is suitable (Mauroy & Mezić, 2016). You certify a candidate $\hat V$ by a *proven* drift inequality rather than by deriving $V^\star$, and estimate it empirically only where a proof is out of reach — the empirical drift checks, it does not certify (neural-Lyapunov: Chang, Roohi & Gao, *Neural Lyapunov Control*, NeurIPS 2019, arXiv:2005.00611). A classical monotone data-flow analysis gets its $V$ for free because a finite-height lattice is a well-founded descent (Kildall, POPL 1973). The gate-a-plant architecture itself is classical: supervisory control theory synthesizes a deterministic supervisor that disables controllable events of a plant it does not author, with the supremal controllable sublanguage as the largest admissible behavior (Ramadge & Wonham, SIAM J. Control and Optimization, 1987) — $\gamma$ is that supervisor, with a learned stochastic plant on general state spaces. The successor representation is Dayan (*Improving Generalization for Temporal Difference Learning: The Successor Representation*, Neural Computation 1993). Dialect-stack architecture: MLIR (Lattner et al., CGO 2021, arXiv:2002.11054); learned pass-ordering: MLGO (Trofin et al., arXiv:2101.04808). Single-pass low-depth expressivity: log-precision transformers are simulable by constant-depth logspace-uniform threshold circuits ($\mathsf{TC}^0$) (Merrill & Sabharwal, *The Parallelism Tradeoff: Limitations of Log-Precision Transformers*, TACL 2023) — fixed/constant precision is a stronger restriction, added autoregressive steps escape it (Merrill & Sabharwal, *The Expressive Power of Transformers with Chain of Thought*, ICLR 2024), and growing precision changes the picture, so the bound is suggestive for deployed models, not literal.
|
||||
**Proven (citable).** Foster–Lyapunov drift ⇒ positive recurrence + $\mathbb{E}[\tau]\le V(s_0)/\varepsilon$ (Foster 1953; Meyn & Tweedie, *Markov Chains and Stochastic Stability*, 1993) — positive recurrence needs the usual irreducibility/petite-set hypotheses, while the absorbing-halt case used here needs only the weaker supermartingale optional-stopping hitting-time bound. The minimal $V$ is the expected hitting time, by first-step analysis + optional stopping (Norris, *Markov Chains*, 1997). For an absorbing chain that expected hitting time is the row sum of the fundamental matrix $N=\sum_{n\ge0}Q_{\mathrm{tr}}^{\,n}$ (Kemeny & Snell, *Finite Markov Chains*, 1960), with the general-state analogue the potential (Green) operator (Revuz, *Markov Chains*, 1984). Koopman's linear-operator view of nonlinear dynamics is classical (Koopman 1931), and Lyapunov functions can be assembled from its eigenfunctions when the spectrum is suitable (Mauroy & Mezić, 2016). You certify a candidate $\hat V$ by a *proven* drift inequality rather than by deriving $V^\star$, and estimate it empirically only where a proof is out of reach — the empirical drift checks, it does not certify (neural-Lyapunov: Chang, Roohi & Gao, *Neural Lyapunov Control*, NeurIPS 2019, arXiv:2005.00611). A classical monotone data-flow analysis gets its $V$ for free because a finite-height lattice is a well-founded descent (Kildall, POPL 1973). The gate-a-plant architecture itself is classical: supervisory control theory synthesizes a deterministic supervisor that disables controllable events of a plant it does not author, with the supremal controllable sublanguage as the largest admissible behavior (Ramadge & Wonham, SIAM J. Control and Optimization, 1987) — $\gamma$ is that supervisor, with a learned stochastic plant on general state spaces; the same theory's controllability condition (specifications must be closed under *uncontrollable* events) and its nonblocking requirement are the proven ancestors of gate-early-on-irreversibles and of the always-enabled escalation the appendix requires behind any learned veto. Covert-channel discipline — identify the channel, measure its bandwidth in bits, audit what cannot be closed — is the TCSEC lineage (*A Guide to Understanding Covert Channel Analysis of Trusted Systems*, NCSC-TG-030, 1993). The successor representation is Dayan (*Improving Generalization for Temporal Difference Learning: The Successor Representation*, Neural Computation 1993). Dialect-stack architecture: MLIR (Lattner et al., CGO 2021, arXiv:2002.11054); learned pass-ordering: MLGO (Trofin et al., arXiv:2101.04808). Single-pass low-depth expressivity: log-precision transformers are simulable by constant-depth logspace-uniform threshold circuits ($\mathsf{TC}^0$) (Merrill & Sabharwal, *The Parallelism Tradeoff: Limitations of Log-Precision Transformers*, TACL 2023) — fixed/constant precision is a stronger restriction, added autoregressive steps escape it (Merrill & Sabharwal, *The Expressive Power of Transformers with Chain of Thought*, ICLR 2024), and growing precision changes the picture, so the bound is suggestive for deployed models, not literal.
|
||||
|
||||
**Asserted (ours — not theorems).** That the harness is best modeled as nested stopped chains; that $V^\star$ is incompressible (no compression theorem); that "no lattice for $f(\cdot\,;W)$" means none is *known*, not that none exists; and everything under *Where this points* — including the Koopman/certificate co-determination, which is well-posed only under the spectral assumptions noted there, and the interlingua/certificate identification; and the design rules read off the objects rather than proven from them — the single-trusted-writer completion of the provenance partition, the narrow-only rule for learned checks, the composition law of the appendix. These organize the design; they are not results.
|
||||
**Asserted (ours — not theorems).** That the harness is best modeled as nested stopped chains; that $V^\star$ is incompressible (no compression theorem); that "no lattice for $f(\cdot\,;W)$" means none is *known*, not that none exists; and everything under *Where this points* — including the Koopman/certificate co-determination, which is well-posed only under the spectral assumptions noted there, and the interlingua/certificate identification; and the design rules read off the objects rather than proven from them — the single-trusted-writer completion of the provenance partition, the narrow-only rule for learned checks and its influence-side twin (verdict payloads to the plant selected, never generated), the composition law of the appendix. These organize the design; they are not results.
|
||||
|
||||
**Converged-upon (independently arrived at, from other framings).** The *Asserted* claims above are ours but not ours alone; several are reached independently, from starting points unconnected to this framing — which is the corroboration a definition earns: not a chorus of agreement (the systems below often disagree on method and goal), but that work approaching from capabilities, reinforcement learning, control theory, software architecture, and language-modeling theory each lands on a piece of the same object. That the **deterministic controller, not the model, carries the guarantee** is reached from four directions — capability and information-flow control (CaMeL: Debenedetti et al., *Defeating Prompt Injections by Design*, arXiv:2503.18813, securing the agent even when the underlying model is susceptible); reinforcement learning (shielding: Alshiekh et al., *Safe Reinforcement Learning via Shielding*, AAAI 2018, arXiv:1708.08611 — a deterministic reactive shield filtering a learned policy's actions against a temporal-logic specification); control theory (*Stable Agentic Control*, arXiv:2605.03034, enforcing finite action catalogs at the tool-output interface under a Lyapunov input-to-state-stability certificate against adversarial disturbance); and software architecture (the plan-then-execute / control-flow-integrity line, e.g. Beurer-Kellner et al., *Design Patterns for Securing LLM Agents against Prompt Injections*, arXiv:2506.08837). The **certified-vs-measured split** is reached from the construction side (CaMeL's provable security) and, independently, from the destruction side (guardrail-evasion results — *Bypassing Prompt Injection and Jailbreak Detection in LLM Guardrails*, arXiv:2504.11168, the v1 title — later versions retitle it; *No Free Lunch with Guardrails*, arXiv:2504.00441), with verification-oriented work stating it as the motivating gap (*Towards Verifiably Safe Tool Use for LLM Agents*, arXiv:2601.08012; VeriGuard, arXiv:2510.05156): a learned safeguard raises the odds of detection but cannot guarantee safety against a persistent attacker. The **inner readout as a composition of Markov kernels** is independently formalized in language-modeling theory — the autoregressive step as kernel composition in the category $\mathsf{Stoch}$ (*A Markov Categorical Framework for Language Modeling*, arXiv:2507.19247), and the broader "LLMs as Markov chains" line — though that work models the inner kernel alone and never closes it into an agentic loop, which is exactly the seam this definition adds. That **provenance shrinks the admissible adversary** is reached by datamarking / spotlighting (Hines et al., arXiv:2403.14720, 2024) and by CaMeL's data/control-flow separation; and a systematization of prompt injection against agentic coding assistants reaches the same verdict from the attack side — mitigation must be *architectural*, not model-level (*Prompt Injection Attacks on Agentic Coding Assistants*, arXiv:2601.17548); the sharper open problem this object is built to answer — formally specify the trust boundaries, then verify implementations respect them — is our phrasing of where that verdict points, not the paper's. Two convergences are weaker, and flagged. The **reach-avoid hitting-time certificate** is the independently developed reach-avoid supermartingale (RASM, arXiv:2210.05308, AAAI 2023) and stochastic Lyapunov–barrier apparatus, and its *hardness* is corroborated — expected-stopping-time problems for Markov chains are inter-reducible with the Positivity problem, a relative of the Skolem problem (Chatterjee & Doyen, *Stochastic Processes with Expected Stopping Time*, arXiv:2104.07278) — but this supports generic hardness only, not the specific incompressibility-at-$|W|$ conjecture, which remains ours and unproven. And **injection as an adversarial policy** is corroborated as a minimax game in the *detection* setting (DataSentinel: Liu et al., *A Game-Theoretic Detection of Prompt Injection Attacks*, arXiv:2504.11358) and as adversarial-disturbance robustness (*Stable Agentic Control*, above) — but no prior work assembles it as reach-avoid over the tool-output kernel with the gate as the irreversibility margin; here the relation is adjacency, not convergence.
|
||||
|
||||
@@ -175,6 +175,8 @@ The same pressure lands on tooling from a second direction. The $\mathsf{action\
|
||||
|
||||
That last kind marks the seam where the gate stops being able to stay pure, and it is the same seam the rest of this document is built around. The *structural* slice of intent — does the action cohere with the plan in $s$ — is a deterministic predicate over $s$ and $y$, effect-free, and belongs in $\gamma$ without reservation. But whether an action matches what the user *actually meant*, in the full semantic sense, is exactly the thing the definition says cannot be checked: natural language is all undefined behavior, with no source-language standard to validate against. So a semantic intent check is a *learned* check, and an LLM judging "is this what they wanted" is a **stochastic kernel** — putting it inside $\gamma$ breaks the property the gate exists to hold, by the same move flagged for the fold-back verifier: a learned judge is a kernel, and belongs in $M_W$, not in a deterministic map. Semantic intent therefore does not live *in* the gate; it is a plant call — a separate authorize-the-proposal pass through $M_W$ whose output $\gamma$ then deterministically gates — or it is drift you measure, never a guarantee you hold. That nested call is not a new kind of thing: it is a mini-harness inside the gate's decision — a judge $M_W$, its own syntactic readout, its own deterministic gate — so its failure case answers itself, the inner gate fail-closing on an unparseable or low-confidence judgment exactly as the outer one does, because it *is* one. The object is **closed under this construction**: semantic gating is added by recursion, not by a new primitive. One constraint on the recursion is load-bearing enough to be a rule, because it is where this entry meets the provenance partition of the body: the judge's verdict is derived, through a learned kernel, from the very content an adversary may have bent, so folding it into authorization is exactly the fold the partition forbids — *unless the verdict can only cost capability*. **A learned check may narrow the deterministic admissible set; it must never widen it.** Judge-as-veto is safe by construction: attacker influence over the judge can at worst manufacture a denial, a liveness cost the certificate already prices. Judge-as-approver — a verdict granting what the deterministic checks alone would refuse, or standing in for the trusted principal's confirmation — lowers the certified floor to those deterministic checks alone; if avoiding $B$ depended on the deny the judge now withholds on the adversary's behalf, the certificate is gone. Only the trusted principal widens authorization; learned kernels only narrow it. (The recursion already obeys this: the mini-harness's inner gate fail-closes to $\bot$ — a deny — which is why the construction was safe to add at all.) The cost is real and worth stating — a judge pass is another full model call, with its latency and tokens — so it is a decision about *which* actions warrant it, not a free wrapper for all of them. The gate widens to every deterministic predicate over $s$ and $y$; it does not widen to the one predicate the document says is not deterministically checkable.
|
||||
|
||||
One more caveat keeps the veto's pricing honest, because a denial is free only in the *authority* lattice. In the dynamics it is an input like any other — folded into $s$, lowered into the next context, conditioning the plant's next proposal — so adversarial influence over a judge is influence over the *trajectory*: a selection channel (deny all but the path toward $B$, and the admissible set the plant experiences is a maze the adversary curated), and a targeted-liveness channel against load-bearing actions — the unstated dual of judge-as-approver: if avoiding $B$ depends on the action the judge now denies on the adversary's behalf, fail-closed's safe landing is an obligation the design earns per-state, not an axiom it inherits. The supervisory ancestry supplies the discipline: a learned veto requires a **nonblocking escape it cannot disable** — an always-enabled route to the trusted principal behind a bounded retry budget — or manufactured denials strand the run, or steer it. And whatever a verdict carries *back to the plant* is a second channel, wearing the judge's authority framing. Free prose there is *generative* influence — injected context, priced by the minimax descent, never by the veto's zero-widening — so the narrow-only rule has an influence-side twin: **a learned verdict's payload to the plant is selected, never generated** — controller-authored symbols, typed citations validated like any effect record, template text with no interpolated model prose — its per-verdict capacity a designed constant rather than a measured hope, and the residual selection pattern audited as the covert channel it is. The alphabet's bound is not a count but two thresholds: symbols become tokens when their semantics stop being controller-authored — the registry the trusted writer can actually audit is the real constant, and borrowed alphabets with upstream owners (a linter's rule registry) spend that budget well — and tokens become language when composition turns productive, arrangement carrying meaning the controller never wrote. Below both thresholds the alphabet may be as large as the audit budget affords. The strongest form dissolves the learned verdict into *scheduling*: the learned component chooses which deterministic checks to run — pass-ordering over verification passes — and the only verdicts that flow anywhere are what the oracles actually said, leaving attention misallocation, a liveness cost, as the entire attack surface.
|
||||
|
||||
But "before any invocation" has to be read as *before any effect*, which is sharper than it sounds — and the reason is the irreversibility point above: you validate before execution because execution is what you cannot take back, so the real invariant is **no effect crosses $\gamma$ unvalidated**. That catches three cases the naive reading misses. *Reads are not free*: a read-only call is still an injection vector (it pulls attacker-controlled content into context) or an exfiltration vector (a request whose URL is the payload), so the gate authorizes the *call* regardless of whether it mutates. *Validation must not act*: a "validator" that resolves a call by hitting an API, expanding a template that fires a webhook, or evaluating an argument that runs code has collapsed validation into invocation, and the effect has already happened *inside* $\gamma$ — so $\gamma$ itself must be **effect-free**, pure and total over the proposal and the current $s$, with no network and no execution; if deciding validity *requires* a side effect, that side effect is itself an action and must go through the gate, recursively. *The output is an action too*: the user-visible response and any logging are effects — for model-authored text, emitted either as an authorized action through $\gamma$ or only after an accepted halt (shell-templated status on any halt is the controller speaking, not the model) — streaming raw tokens to a sink before $\gamma$ has cleared them is the same bug from the other end.
|
||||
|
||||
So the property, tightest: $\gamma$ is a **pure, effect-free authorization that every model-proposed action — tool call, read, write, or final output — must pass before any effect occurs**, with "before" enforced structurally by the gate being the only route from model text to $Q_E$. The two failure modes to design against are a path from model output to a sink that bypasses the gate, and a $\gamma$ that is not effect-free, so that "validating" a call already rang the bell. And the boundary, so the property does not overpromise: $\gamma$ guarantees *no unauthorized effect* — pure code ordering, fully in your control — but not that an *authorized* effect is safe or correct; that is the plant's problem, and the reason $\rho$ and the reach-avoid certificate exist. Fail-closed is the floor — nothing executes that did not pass the gate — not the ceiling.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/Nh3bWMacaq)
|
||||
[](https://github.com/sponsors/eous)
|
||||
|
||||
Self-hosted, local-first orchestration for tool-using AI agents. Give LLMs real tools — shell, files, search, web — and run them across your own cluster with direct HTTP routing and interactive interfaces. Your code, your models, your data stay on hardware you control: no telemetry, no phone-home.
|
||||
|
||||
@@ -171,6 +172,14 @@ UML diagrams in [`docs/diagrams/`](docs/diagrams/):
|
||||
- Optional: Discord / Slack channel integrations (`pip install turnstone[discord,slack]`)
|
||||
- [Git LFS](https://git-lfs.com/) for cloning (diagram PNGs)
|
||||
|
||||
## Support
|
||||
|
||||
Turnstone is free, Apache-2.0, and self-hosted — no paid tier, no telemetry, no upsell. If it saves you time or you'd like to help keep development moving, you can sponsor the project:
|
||||
|
||||
**[❤ Sponsor Turnstone →](https://github.com/sponsors/eous)** · one-off via **[PayPal](https://paypal.me/eousphoros)**
|
||||
|
||||
Sponsorship is entirely optional and funds maintenance, new features, and infrastructure. Prefer to contribute in other ways? Filing issues, improving docs, and [pull requests](CONTRIBUTING.md) help just as much.
|
||||
|
||||
## Community
|
||||
|
||||
Questions, ideas, or want to show what you're building? Join us on Discord:
|
||||
|
||||
@@ -41,6 +41,7 @@ are set.
|
||||
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
|
||||
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
|
||||
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
|
||||
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
|
||||
|
||||
All four required fields — issuer, client ID, client secret, and
|
||||
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
|
||||
@@ -99,6 +100,40 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
|
||||
this knob only relaxes the same-origin check, not the security gates.
|
||||
Each entry is a hostname (no scheme, no path).
|
||||
|
||||
### Self-hosted and internal IdPs
|
||||
|
||||
By default Turnstone refuses an issuer whose hostname resolves to a
|
||||
private or internal address:
|
||||
|
||||
```
|
||||
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
|
||||
```
|
||||
|
||||
This is SSRF hardening, not a licensing or product restriction: the OIDC
|
||||
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
|
||||
and refusing non-public destinations keeps a mistyped or maliciously
|
||||
steered issuer from aiming those fetches at internal services. For a
|
||||
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
|
||||
opt in explicitly in `config.toml`:
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
allow_private_network = true
|
||||
```
|
||||
|
||||
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
|
||||
when both are set).
|
||||
|
||||
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
|
||||
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
|
||||
and reserved ranges stay refused even with the opt-in — cloud metadata
|
||||
services (169.254.169.254) live there, and no legitimate IdP does. The
|
||||
HTTPS requirement and the same-origin endpoint checks are unaffected.
|
||||
|
||||
This knob only affects the login-flow IdP configured here. OAuth
|
||||
endpoints advertised by remote MCP servers are untrusted input and are
|
||||
always held to the strict public-address rule.
|
||||
|
||||
### config.toml alternative
|
||||
|
||||
```toml
|
||||
@@ -111,6 +146,8 @@ provider_name = "Google"
|
||||
role_claim = "groups"
|
||||
password_enabled = true
|
||||
redirect_base = "https://app.example.com"
|
||||
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
|
||||
allow_private_network = false
|
||||
|
||||
[oidc.role_map]
|
||||
admin = "builtin-admin"
|
||||
|
||||
+37
-4
@@ -118,9 +118,37 @@ seeded):
|
||||
`persona` argument, validated when the coordinator prepares the spawn
|
||||
and re-checked by the node that creates the child (children are always
|
||||
interactive-kind). Omitted means the interactive **default** — a child
|
||||
never inherits its parent coordinator's persona. Sub-agents spawned via
|
||||
`task_agent` have no persona parameter at all; they keep their own
|
||||
identity and envelope.
|
||||
never inherits its parent coordinator's persona.
|
||||
- **Sub-agents**: `task_agent` takes a `persona` argument setting the
|
||||
sub-agent's identity and capability envelope (resolved against
|
||||
interactive-kind personas, frozen into the task at prep). Omitted keeps
|
||||
the default autonomous task-agent identity — never the parent's persona.
|
||||
|
||||
## How agents discover personas
|
||||
|
||||
Agents are told, not expected to guess: the live persona list (enabled,
|
||||
interactive-kind — children and sub-agents are always interactive) is
|
||||
injected into the `persona` parameter description of `task_agent`,
|
||||
`spawn_workstream`, and `spawn_batch` whenever the session's tool surface
|
||||
is rendered — session start, MCP catalog change, model-registry reload.
|
||||
Each entry carries the name, the default marker, and the persona's
|
||||
one-line description so the model can pick by purpose (descriptions drop
|
||||
out past 25 personas; the name list always enumerates completely).
|
||||
|
||||
A persona created after that render is still reachable — pass its name.
|
||||
Every resolve failure enumerates the names currently valid for the kind,
|
||||
so a stale list (or a typo) self-corrects on the next attempt.
|
||||
|
||||
Resolution is forgiving on all surfaces (they share one rule):
|
||||
|
||||
- names match case-insensitively (`Writer` resolves `writer`);
|
||||
- an input that uniquely matches a persona's **display name**
|
||||
(case-insensitive, among the kind's enabled personas — display names are
|
||||
not unique, and a same-label persona of another kind neither blocks nor
|
||||
wins) resolves to that persona; an ambiguous match errors, listing the
|
||||
candidate slugs;
|
||||
- whatever variant matched, the stamped identity, approval chrome, and
|
||||
wire always carry the canonical `name` slug.
|
||||
|
||||
## Authoring (console)
|
||||
|
||||
@@ -128,7 +156,12 @@ Personas are managed in the console's **Manage → Governance → Personas**
|
||||
tab. The admin shelf exposes exactly the four levers plus the kind
|
||||
list, the default marker, and archive. Rules:
|
||||
|
||||
- `name` is an immutable lowercase slug; edit `display_name` instead.
|
||||
- `name` is an immutable lowercase slug — and the identifier agents and
|
||||
the CLI launch the persona by (`persona=` on the spawn tools,
|
||||
`--persona` on the CLI); the create shelf says so under **Name**.
|
||||
`display_name` is a list label, editable any time, and deliberately
|
||||
not an identifier (a unique display name happens to resolve, as a
|
||||
forgiveness fallback — don't design workflows around it).
|
||||
- Exactly one default per kind, storage-enforced: flipping the flag on a
|
||||
successor demotes the incumbent atomically, defaults are single-kind,
|
||||
and a default cannot be archived.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.7.0rc1"
|
||||
version = "1.7.1"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
Generated
+50
-50
@@ -409,16 +409,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -427,13 +427,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -454,9 +454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -467,13 +467,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -481,14 +481,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -497,9 +497,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -507,13 +507,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -949,9 +949,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1122,9 +1122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
|
||||
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1200,19 +1200,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1240,12 +1240,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
+196
-23
@@ -9,6 +9,8 @@ manual testing.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -17,6 +19,12 @@ import pytest
|
||||
|
||||
_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/ui/static/app.js"
|
||||
_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/interactive.js"
|
||||
_SHELL_JS = Path(__file__).resolve().parent.parent / "turnstone/shared_static/shell.js"
|
||||
_REDACT_CREDENTIALS_JS = (
|
||||
Path(__file__).resolve().parent.parent / "turnstone/shared_static/redact_credentials.js"
|
||||
)
|
||||
_CONSOLE_APP_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
|
||||
_CONSOLE_INDEX = Path(__file__).resolve().parent.parent / "turnstone/console/static/index.html"
|
||||
|
||||
|
||||
def _pane_method_offset(body: str, name: str) -> int:
|
||||
@@ -555,7 +563,6 @@ _CONSOLE_ADMIN_JS = Path(__file__).resolve().parent.parent / "turnstone/console/
|
||||
_CONSOLE_GOVERNANCE_JS = (
|
||||
Path(__file__).resolve().parent.parent / "turnstone/console/static/governance.js"
|
||||
)
|
||||
_CONSOLE_INTERACTIVE_JS = Path(__file__).resolve().parent.parent / "turnstone/console/static/app.js"
|
||||
|
||||
|
||||
_UNSAFE_CODE_SINK_LINT_TARGETS = [
|
||||
@@ -566,7 +573,7 @@ _UNSAFE_CODE_SINK_LINT_TARGETS = [
|
||||
("turnstone/console/static/coordinator/coordinator.js", _COORD_JS),
|
||||
("turnstone/console/static/admin.js", _CONSOLE_ADMIN_JS),
|
||||
("turnstone/console/static/governance.js", _CONSOLE_GOVERNANCE_JS),
|
||||
("turnstone/console/static/app.js", _CONSOLE_INTERACTIVE_JS),
|
||||
("turnstone/console/static/app.js", _CONSOLE_APP_JS),
|
||||
]
|
||||
|
||||
|
||||
@@ -955,6 +962,7 @@ _CONST_GUARD_BUNDLES = _SWEPT_BUNDLES + [
|
||||
_REPO_ROOT / "turnstone/shared_static/rail.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/interactive.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/conversation.js",
|
||||
_REPO_ROOT / "turnstone/shared_static/redact_credentials.js",
|
||||
]
|
||||
|
||||
|
||||
@@ -1267,41 +1275,102 @@ def test_swept_bundle_has_no_const_reassign(bundle: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_redact_api_keys_runtime_smoke() -> None:
|
||||
"""Runtime smoke for ``_redactApiKeys``. The function is pure — no
|
||||
DOM dependency — so it transplants cleanly into a standalone
|
||||
``node -e`` invocation. This is the bit that would have caught
|
||||
the original ``const redacted`` bug (which ``node --check`` and a
|
||||
pure-static keyword scan both miss; the ``TypeError`` only fires
|
||||
at call-time)."""
|
||||
body = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"function _redactApiKeys\(text\) \{.*?\n\}\n",
|
||||
body,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m is not None, "_redactApiKeys not found in app.js"
|
||||
fn = m.group(0)
|
||||
script = (
|
||||
fn
|
||||
+ "\nconst q = _redactApiKeys('https://x?api_key=abc&u=foo');\n"
|
||||
def test_redact_credentials_runtime_smoke() -> None:
|
||||
"""Runtime smoke for ``redactCredentials`` via a temp harness file.
|
||||
The function is pure (no DOM dependency). Tests the shared module
|
||||
directly via ESM import (replaces the legacy ``_redactApiKeys`` test
|
||||
which now delegates to this).
|
||||
|
||||
The tempfile is written with a ``.mjs`` extension so Node forces ESM
|
||||
parsing regardless of any ``package.json`` ``type`` field in parent
|
||||
directories. The ``redact_credentials.js`` source file is imported
|
||||
by absolute path so resolution is unambiguous.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
mod_path = _REDACT_CREDENTIALS_JS.resolve()
|
||||
harness = (
|
||||
"import { redactCredentials } from "
|
||||
+ json.dumps(str(mod_path))
|
||||
+ ";\n"
|
||||
+ "const q = redactCredentials('https://x?api_key=abc&u=foo');\n"
|
||||
+ 'if (q !== "https://x?api_key=***&u=foo") '
|
||||
+ "throw new Error('query-string redact failed: ' + q);\n"
|
||||
+ 'const j = _redactApiKeys(\'{"api_key":"abc"}\');\n'
|
||||
+ 'const j = redactCredentials(\'{"api_key":"abc"}\');\n'
|
||||
+ 'if (j !== \'{"api_key":"***"}\') '
|
||||
+ "throw new Error('json-style redact failed: ' + j);\n"
|
||||
+ "// Bearer token redaction (raw input)\n"
|
||||
+ "const b = redactCredentials('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.test-token_here');\n"
|
||||
+ "if (!b.includes('[REDACTED:api_key]')) "
|
||||
+ "throw new Error('bearer redact failed: ' + b);\n"
|
||||
+ "// Connection string redaction (raw input)\n"
|
||||
+ "const c = redactCredentials('postgresql://user:supersecret@localhost/db');\n"
|
||||
+ "if (!c.includes('[REDACTED:password]')) "
|
||||
+ "throw new Error('conn-string redact failed: ' + c);\n"
|
||||
+ "// Authorization JSON key redaction (step 6 comprehensive)\n"
|
||||
+ 'const a = redactCredentials(\'{"Authorization": "Bearer canstillseethis"}\');\n'
|
||||
+ "if (!a.includes('[REDACTED:secret]')) "
|
||||
+ "throw new Error('authorization JSON redact failed: ' + a);\n"
|
||||
+ "// Single-quote JSON (Python dict repr / JS object literal)\n"
|
||||
+ "const sq = redactCredentials(\"{'Authorization': 'Bearer canstillseethis'}\");\n"
|
||||
+ "if (!sq.includes('[REDACTED:secret]')) "
|
||||
+ "throw new Error('single-quote authorization redact failed: ' + sq);\n"
|
||||
+ "// mongodb+srv connection string (Atlas SRV)\n"
|
||||
+ "const ms = redactCredentials('mongodb+srv://u:s3cretpw@cluster.mongodb.net/db');\n"
|
||||
+ "if (!ms.includes('[REDACTED:password]')) "
|
||||
+ "throw new Error('mongodb+srv redact failed: ' + ms);\n"
|
||||
+ "// lowercase bearer scheme (RFC 7235 case-insensitive)\n"
|
||||
+ "const lb = redactCredentials('authorization: bearer "
|
||||
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig12345');\n"
|
||||
+ "if (!lb.includes('[REDACTED:api_key]')) "
|
||||
+ "throw new Error('lowercase bearer redact failed: ' + lb);\n"
|
||||
+ "// api_key= assignment redacts the whole token, not a garbled api_[REDACTED\n"
|
||||
+ "const ak = redactCredentials('api_key=abcdefghijklmnopqrstuvwxyz');\n"
|
||||
+ "if (ak !== '[REDACTED:api_key]') "
|
||||
+ "throw new Error('api_key= clean redact failed: ' + ak);\n"
|
||||
+ "// Prefilter fast path: plain text with no anchor substring is unchanged\n"
|
||||
+ "const fp = redactCredentials('build ok in 42s - 3 tests passed');\n"
|
||||
+ "if (fp !== 'build ok in 42s - 3 tests passed') "
|
||||
+ "throw new Error('prefilter fast-path no-op failed: ' + fp);\n"
|
||||
+ "// Bare credentials with no =, quote or @ anywhere must still redact\n"
|
||||
+ "// (these pin the prefilter as a superset of the pattern set)\n"
|
||||
+ "const bk = redactCredentials('loaded sk-abcdefghijklmnopqrstuvwx');\n"
|
||||
+ "if (bk !== 'loaded [REDACTED:api_key]') "
|
||||
+ "throw new Error('bare sk- redact failed: ' + bk);\n"
|
||||
+ "const aw = redactCredentials('using AKIAABCDEFGHIJKLMNOP now');\n"
|
||||
+ "if (aw !== 'using [REDACTED:api_key] now') "
|
||||
+ "throw new Error('bare AKIA redact failed: ' + aw);\n"
|
||||
+ "const bt = redactCredentials('Bearer abcdefghijklmnopqrstuvwxyz');\n"
|
||||
+ "if (bt !== '[REDACTED:api_key]') "
|
||||
+ "throw new Error('bare bearer redact failed: ' + bt);\n"
|
||||
+ "// SQLAlchemy dialect+driver connection URLs (psycopg2/asyncpg)\n"
|
||||
+ "const pg2 = redactCredentials('postgresql+psycopg2://user:s3cret@db:5432/app');\n"
|
||||
+ "if (pg2 !== 'postgresql+psycopg2://user:[REDACTED:password]@db:5432/app') "
|
||||
+ "throw new Error('psycopg2 conn redact failed: ' + pg2);\n"
|
||||
+ "const apg = redactCredentials('postgresql+asyncpg://user:s3cret@db/app');\n"
|
||||
+ "if (apg !== 'postgresql+asyncpg://user:[REDACTED:password]@db/app') "
|
||||
+ "throw new Error('asyncpg conn redact failed: ' + apg);\n"
|
||||
+ "// RFC 3986 schemes are case-insensitive - uppercase must not bypass\n"
|
||||
+ "const up = redactCredentials('POSTGRESQL+PSYCOPG2://user:s3cret@db/app');\n"
|
||||
+ "if (up !== 'POSTGRESQL+PSYCOPG2://user:[REDACTED:password]@db/app') "
|
||||
+ "throw new Error('uppercase scheme conn redact failed: ' + up);\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".mjs", delete=False) as f:
|
||||
f.write(harness)
|
||||
tmp = f.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["node", "-e", script],
|
||||
["node", tmp],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
assert proc.returncode == 0, (
|
||||
f"_redactApiKeys runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
|
||||
f"redactCredentials runtime smoke failed. stdout={proc.stdout!r} stderr={proc.stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
@@ -1757,3 +1826,107 @@ def test_global_stream_recovery_floor_and_render_coalescing() -> None:
|
||||
assert "requestAnimationFrame(" in body[fire : fire + 700], (
|
||||
"fireRender must coalesce subscriber repaints to one per frame"
|
||||
)
|
||||
|
||||
|
||||
def test_server_global_accels_are_platform_aware_and_scoped() -> None:
|
||||
"""The standalone's keydown handler owns only the GLOBAL accels — new
|
||||
workstream, switch, dashboard. They pick the modifier per platform (Ctrl on
|
||||
macOS where the browser owns Cmd, Alt elsewhere) so Ctrl+T/1-9 aren't eaten
|
||||
by the browser off macOS. The per-pane verbs (edit/refresh/fork/delete/
|
||||
close) moved to shell.js, so the handler must not invoke them itself."""
|
||||
body = _APP_JS.read_text(encoding="utf-8")
|
||||
assert "const IS_MAC" in body and 'navigator.platform.indexOf("Mac")' in body, (
|
||||
"the accelerators need a platform check to choose Ctrl vs Alt"
|
||||
)
|
||||
handler = body[body.index('document.addEventListener("keydown"') :]
|
||||
assert "const paneMod" in handler, (
|
||||
"global accels must gate on the platform-aware paneMod, not raw ctrlKey"
|
||||
)
|
||||
assert 'e.ctrlKey && e.key === "t"' not in handler, (
|
||||
"Ctrl+T is browser-reserved off macOS — new workstream must bind via paneMod"
|
||||
)
|
||||
assert "newWorkstream()" in handler and "switchTab(" in handler, (
|
||||
"the standalone handler still owns new + switch"
|
||||
)
|
||||
# macOS Ctrl+T / Ctrl+D are the Cocoa transpose / delete-forward text
|
||||
# bindings; the creation/dashboard chords must yield while typing, through
|
||||
# the shared TS_SHELL.inEditable guard (not a per-file copy).
|
||||
assert "TS_SHELL.inEditable(" in handler, (
|
||||
"new + dashboard must yield to text editing (macOS Ctrl+T / Ctrl+D)"
|
||||
)
|
||||
# The per-pane verbs are shell.js's job now — the standalone handler must not
|
||||
# double-bind them (shell.js drives them off the active pane's menu).
|
||||
for verb in ("editWorkstreamTitle()", "forkWorkstream()", "confirmDeleteWorkstream()"):
|
||||
assert verb not in handler, (
|
||||
f"{verb} moved to shell.js — the app.js handler must not also bind it"
|
||||
)
|
||||
|
||||
|
||||
def test_shortcut_overlay_labels_match_the_platform_modifier() -> None:
|
||||
"""The '?' help overlay must advertise the same modifier the handler
|
||||
listens for — Ctrl on macOS, Alt on Windows/Linux — instead of a hardcoded
|
||||
Ctrl that is wrong (and non-functional) off macOS."""
|
||||
index = _INDEX_HTML.read_text(encoding="utf-8")
|
||||
assert "const PANE_MOD" in index and 'navigator.platform.indexOf("Mac")' in index, (
|
||||
"the overlay must compute its modifier label per platform"
|
||||
)
|
||||
assert "${PANE_MOD}+T" in index, "the New-workstream badge must render through PANE_MOD"
|
||||
assert '<span class="kb-key">Ctrl+T</span>' not in index, (
|
||||
"the New-workstream badge must not hardcode Ctrl (wrong off macOS)"
|
||||
)
|
||||
|
||||
|
||||
def test_pane_menu_accels_are_shared_and_platform_aware() -> None:
|
||||
"""shell.js is the single source of truth for the per-pane tab-menu
|
||||
shortcuts: the badge string and the keydown handler come from ONE registry,
|
||||
so a badge can't advertise a chord the handler ignores. Badges must be
|
||||
platform-aware (no hardcoded Ctrl), and the shared handler must drive the
|
||||
ACTIVE pane's own menu so each surface contributes only what it supports."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "PANE_MENU_ACCELS" in shell and "function paneAccelBadge" in shell, (
|
||||
"shell.js must own the accel registry + badge builder"
|
||||
)
|
||||
assert "const PANE_MOD_LABEL" in shell and 'navigator.platform.indexOf("Mac")' in shell, (
|
||||
"the shared badge must be platform-aware (Ctrl on macOS, Alt elsewhere)"
|
||||
)
|
||||
# The tab-menu items carry a stable accel + a computed badge, NOT a hardcoded
|
||||
# Ctrl string that would lie on Windows/Linux.
|
||||
for accel in ("close-pane", "edit-title", "refresh-title", "delete"):
|
||||
assert f'accel: "{accel}"' in shell, f"tab menu must tag the {accel} item"
|
||||
assert 'key: "Ctrl+Shift+E"' not in shell and 'key: "Ctrl+W"' not in shell, (
|
||||
"tab-menu badges must go through paneAccelBadge, not hardcoded Ctrl"
|
||||
)
|
||||
# The shared handler resolves the active pane and runs its menu item by accel.
|
||||
assert "paneAccelFor(e)" in shell and "pane.tabMenu()" in shell, (
|
||||
"the shared keydown handler must drive the active pane's menu by accel"
|
||||
)
|
||||
# The typing guard is shared (TS_SHELL.inEditable), not copied per surface.
|
||||
assert "function inEditable(" in shell and "inEditable," in shell, (
|
||||
"shell.js must define + expose the shared inEditable guard on TS_SHELL"
|
||||
)
|
||||
ui = _APP_JS.read_text(encoding="utf-8")
|
||||
console = _CONSOLE_APP_JS.read_text(encoding="utf-8")
|
||||
assert "_inEditable" not in ui and "_consoleInEditable" not in console, (
|
||||
"surfaces must use TS_SHELL.inEditable, not a per-file copy of the guard"
|
||||
)
|
||||
|
||||
|
||||
def test_console_has_matching_pane_hotkeys() -> None:
|
||||
"""The console regained pane hotkeys to match the standalone: a keydown
|
||||
handler for switch (Mod+1-9) + dashboard (Ctrl+D), and a '?' overlay that
|
||||
advertises them platform-aware. New workstream and Fork are intentionally
|
||||
omitted (no console fork / blank-new surface)."""
|
||||
app = _CONSOLE_APP_JS.read_text(encoding="utf-8")
|
||||
assert (
|
||||
"_CONSOLE_IS_MAC" in app and "statefulTabs()" in app and 'openPane("dashboard")' in app
|
||||
), "the console must wire switch (statefulTabs) + dashboard hotkeys"
|
||||
index = _CONSOLE_INDEX.read_text(encoding="utf-8")
|
||||
assert "const PANE_MOD" in index and '"Panes"' in index, (
|
||||
"the console '?' overlay needs a platform-aware Panes section"
|
||||
)
|
||||
assert "${PANE_MOD}+W" in index and "${PANE_MOD}+Shift+E" in index, (
|
||||
"console badges must render through PANE_MOD"
|
||||
)
|
||||
assert '"Fork"' not in index and "New workstream" not in index, (
|
||||
"Fork + New are intentionally omitted on the console"
|
||||
)
|
||||
|
||||
@@ -1600,6 +1600,82 @@ class TestConsoleProxy:
|
||||
# browser's interactive UI 403-loops on every retry.
|
||||
assert sse_mock.await_args.kwargs.get("use_service_auth") is True
|
||||
|
||||
def test_proxy_events_global_403_without_cluster_inspect(self, mock_collector):
|
||||
"""A plain authenticated user (no service scope, no
|
||||
admin.cluster.inspect) cannot reach the node's cross-tenant
|
||||
firehose through the proxy: elevating to the console's service
|
||||
identity would bypass per-user filtering, so the path is
|
||||
operator-gated. _proxy_sse must NOT be reached."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from starlette.responses import Response
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
_load_static()
|
||||
app = create_app(collector=mock_collector, jwt_secret=_TEST_JWT_SECRET)
|
||||
user_jwt = create_jwt(
|
||||
user_id="plain-user",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
permissions=frozenset(),
|
||||
)
|
||||
user_client = TestClient(
|
||||
app,
|
||||
raise_server_exceptions=False,
|
||||
headers={"Authorization": f"Bearer {user_jwt}"},
|
||||
)
|
||||
with patch(
|
||||
"turnstone.console.server._proxy_sse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=Response("ok", status_code=200),
|
||||
) as sse_mock:
|
||||
resp = user_client.get("/node/node-a/v1/api/events/global")
|
||||
assert resp.status_code == 403
|
||||
assert sse_mock.await_count == 0
|
||||
user_client.close()
|
||||
|
||||
def test_proxy_events_global_allows_cluster_inspect(self, mock_collector):
|
||||
"""An operator holding admin.cluster.inspect passes the gate and
|
||||
reaches the SSE proxy with the service token."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from starlette.responses import Response
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
|
||||
|
||||
_load_static()
|
||||
app = create_app(collector=mock_collector, jwt_secret=_TEST_JWT_SECRET)
|
||||
op_jwt = create_jwt(
|
||||
user_id="operator",
|
||||
scopes=frozenset({"read"}),
|
||||
source="test",
|
||||
secret=_TEST_JWT_SECRET,
|
||||
audience=JWT_AUD_CONSOLE,
|
||||
permissions=frozenset({"admin.cluster.inspect"}),
|
||||
)
|
||||
op_client = TestClient(
|
||||
app,
|
||||
raise_server_exceptions=False,
|
||||
headers={"Authorization": f"Bearer {op_jwt}"},
|
||||
)
|
||||
with patch(
|
||||
"turnstone.console.server._proxy_sse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=Response("ok", status_code=200),
|
||||
) as sse_mock:
|
||||
resp = op_client.get("/node/node-a/v1/api/events/global")
|
||||
assert resp.status_code == 200
|
||||
assert sse_mock.await_count == 1
|
||||
assert sse_mock.await_args.kwargs.get("use_service_auth") is True
|
||||
op_client.close()
|
||||
|
||||
def test_proxy_api_per_ws_events_uses_user_auth_not_service(self, client, mock_collector):
|
||||
"""Per-ws events route uses the user's re-minted JWT, not the
|
||||
service token — the upstream per-ws SSE handler scopes by
|
||||
|
||||
@@ -42,6 +42,7 @@ from turnstone.console.server import (
|
||||
_coord_create_post_install,
|
||||
_coord_create_validate_request,
|
||||
_coord_saved_loaded_lookup,
|
||||
_coordinator_tenant_check,
|
||||
_require_admin_coordinator,
|
||||
_require_coord_mgr,
|
||||
cluster_ws_detail,
|
||||
@@ -83,15 +84,24 @@ def _coord_attach_owner(request, ws_id, mgr):
|
||||
|
||||
Kind-strict — coord attachments can only be accessed for
|
||||
workstreams currently held by ``coord_mgr``; no storage fallback
|
||||
so cross-kind ws_ids 404 instead of leaking through storage.
|
||||
so cross-kind ws_ids 404 instead of leaking through storage. Also
|
||||
project-tenancy-strict: mirrors ``_coord_attachment_owner`` so a
|
||||
private-project coordinator's attachments 404-mask non-members.
|
||||
"""
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
from turnstone.core.web_helpers import auth_user_id
|
||||
|
||||
ws = mgr.get(ws_id)
|
||||
if ws is None:
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage is None:
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
if not visibility.ws_visible(getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or ""):
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
return ws.user_id or auth_user_id(request), None
|
||||
|
||||
|
||||
@@ -101,7 +111,7 @@ def _coord_attach_owner(request, ws_id, mgr):
|
||||
_coord_endpoint_config = SessionEndpointConfig(
|
||||
permission_gate=_require_admin_coordinator,
|
||||
manager_lookup=_require_coord_mgr,
|
||||
tenant_check=None,
|
||||
tenant_check=_coordinator_tenant_check,
|
||||
not_found_label="coordinator not found",
|
||||
audit_action_prefix="coordinator",
|
||||
supports_attachments=True,
|
||||
@@ -1408,6 +1418,110 @@ def test_history_any_admin_coordinator_caller_can_read(storage):
|
||||
assert resp.json()["ws_id"] == ws.id
|
||||
|
||||
|
||||
def test_history_private_project_hidden_from_non_member(storage):
|
||||
# admin.coordinator gates the surface, but a coordinator in a private
|
||||
# project the caller isn't a member of is 404-masked — the conversation
|
||||
# does not leak to a non-member operator.
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.register_workstream(
|
||||
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
|
||||
)
|
||||
storage.save_message("c" * 32, "user", "secret plan")
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{'c' * 32}/history",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_history_private_project_visible_to_member(storage):
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.add_project_member("proj-secret", "member-bob")
|
||||
storage.register_workstream(
|
||||
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
|
||||
)
|
||||
storage.save_message("c" * 32, "user", "secret plan")
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{'c' * 32}/history",
|
||||
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert any(m.get("content") == "secret plan" for m in resp.json()["messages"])
|
||||
|
||||
|
||||
def test_export_private_project_hidden_from_non_member(storage):
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.register_workstream(
|
||||
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
|
||||
)
|
||||
storage.save_message("c" * 32, "user", "secret plan")
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{'c' * 32}/export",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_children_private_project_hidden_from_non_member(storage):
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.register_workstream(
|
||||
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
|
||||
)
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{'c' * 32}/children",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_open_private_project_hidden_from_non_member(storage):
|
||||
# `open` rehydrates + returns the auto-titled name, so an ungated open is a
|
||||
# private-project existence/metadata oracle AND an unauthorized resurrection.
|
||||
# The tenant_check must fire before the already-loaded shortcut and mgr.open.
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.register_workstream(
|
||||
"c" * 32, kind="coordinator", user_id="alice", project_id="proj-secret"
|
||||
)
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.post(
|
||||
f"/v1/api/workstreams/{'c' * 32}/open",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_coord_attachments_private_project_hidden_from_non_member(storage):
|
||||
# Attachment list/serve resolves the owner as the coord owner and only
|
||||
# enforced cross-kind before — a non-member operator could enumerate and
|
||||
# download the owner's staged blobs. Now 404-masked by project tenancy.
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
mgr = _build_mgr(storage)
|
||||
ws = mgr.create(user_id="alice", project_id="proj-secret")
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{ws.id}/attachments",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_coord_attachments_private_project_visible_to_member(storage):
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.add_project_member("proj-secret", "member-bob")
|
||||
mgr = _build_mgr(storage)
|
||||
ws = mgr.create(user_id="alice", project_id="proj-secret")
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/workstreams/{ws.id}/attachments",
|
||||
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.coordinator"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_history_serves_storage_only_workstream(storage):
|
||||
"""Persisted-but-not-loaded coordinators (closed / evicted) are still
|
||||
readable via /history without rehydrating. Mirrors the pre-lift
|
||||
@@ -2108,6 +2222,10 @@ def test_open_any_admin_coordinator_caller_succeeds_in_memory(storage):
|
||||
|
||||
def test_open_rehydrates_when_not_in_memory(storage, monkeypatch):
|
||||
mgr = _build_mgr(storage)
|
||||
# The tenancy gate resolves the row from storage before rehydrating, so a
|
||||
# legitimately-openable coordinator must exist there (it always does in
|
||||
# production — open rehydrates a persisted row).
|
||||
storage.register_workstream("coord-rehy", kind="coordinator", user_id="user-1")
|
||||
rehydrated = MagicMock()
|
||||
rehydrated.id = "coord-rehy"
|
||||
rehydrated.name = "rehydrated"
|
||||
@@ -2141,6 +2259,7 @@ def test_open_503_on_coord_mgr_unavailable(storage):
|
||||
|
||||
def test_open_correlation_id_on_factory_failure(storage, monkeypatch):
|
||||
mgr = _build_mgr(storage)
|
||||
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
|
||||
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=RuntimeError("boom")))
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
|
||||
@@ -2151,6 +2270,7 @@ def test_open_correlation_id_on_factory_failure(storage, monkeypatch):
|
||||
def test_open_503_when_open_raises_value_error(storage, monkeypatch):
|
||||
"""ValueError from the factory surfaces as 503 with the remediation text."""
|
||||
mgr = _build_mgr(storage)
|
||||
storage.register_workstream("bad-ws", kind="coordinator", user_id="user-1")
|
||||
monkeypatch.setattr(mgr, "open", MagicMock(side_effect=ValueError("coord registry missing")))
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
resp = client.post("/v1/api/workstreams/bad-ws/open", headers=_COORD_HEADERS)
|
||||
@@ -2316,7 +2436,8 @@ def test_cluster_inspect_invalid_ws_id_400(storage):
|
||||
|
||||
|
||||
def test_cluster_inspect_any_inspect_caller_sees_detail(storage):
|
||||
# Trusted-team visibility: admin.cluster.inspect sees every row.
|
||||
# A project-less workstream has no tenancy to enforce, so any
|
||||
# admin.cluster.inspect caller sees it (trusted-team default).
|
||||
mgr = _build_mgr(storage)
|
||||
ws = mgr.create(user_id="owner")
|
||||
client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
|
||||
@@ -2328,6 +2449,46 @@ def test_cluster_inspect_any_inspect_caller_sees_detail(storage):
|
||||
assert resp.json()["persisted"]["ws_id"] == ws.id
|
||||
|
||||
|
||||
def test_cluster_inspect_private_project_hidden_from_non_member(storage):
|
||||
# admin.cluster.inspect gates the surface, but a workstream in a
|
||||
# private project the caller isn't a member of is masked as 404 —
|
||||
# no private-project oracle even for a cluster admin.
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.register_workstream(
|
||||
"c" * 32,
|
||||
node_id="console",
|
||||
user_id="alice",
|
||||
kind="coordinator",
|
||||
project_id="proj-secret",
|
||||
)
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/cluster/ws/{'c' * 32}/detail",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.cluster.inspect"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_cluster_inspect_private_project_visible_to_member(storage):
|
||||
# A project member (even a non-owner) still sees the persisted row.
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.add_project_member("proj-secret", "member-bob")
|
||||
storage.register_workstream(
|
||||
"c" * 32,
|
||||
node_id="console",
|
||||
user_id="alice",
|
||||
kind="coordinator",
|
||||
project_id="proj-secret",
|
||||
)
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage), registry=_fake_registry())
|
||||
resp = client.get(
|
||||
f"/v1/api/cluster/ws/{'c' * 32}/detail",
|
||||
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.cluster.inspect"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["persisted"]["ws_id"] == "c" * 32
|
||||
|
||||
|
||||
def test_cluster_inspect_coordinator_self_path(storage):
|
||||
"""A coordinator row returns live from the in-process manager."""
|
||||
mgr = _build_mgr(storage)
|
||||
|
||||
@@ -11,12 +11,16 @@ this pins the behaviour the old ``_anthropic`` ``pc_tool_ids`` /
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.lowering import (
|
||||
CANCELLED_TOOL_RESULT,
|
||||
_find_orphaned_tool_calls,
|
||||
repair_wire_messages,
|
||||
sanitize_tool_call_arguments,
|
||||
tool_args_preview,
|
||||
wire_valid_arguments,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,3 +184,159 @@ def test_repair_does_not_mutate_input() -> None:
|
||||
repair_wire_messages(msgs)
|
||||
assert len(msgs) == original_len # caller's list untouched
|
||||
assert "tool_calls" in msgs[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# wire_valid_arguments — the shared "is this renderable" predicate
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_wire_valid_arguments_accepts_json_objects() -> None:
|
||||
assert wire_valid_arguments("{}") is True
|
||||
assert wire_valid_arguments('{"command": "ls -la"}') is True
|
||||
assert wire_valid_arguments(' { "a": 1 }\n') is True # surrounding whitespace ok
|
||||
|
||||
|
||||
def test_wire_valid_arguments_rejects_unrenderable() -> None:
|
||||
assert wire_valid_arguments('{"command": "cat /va') is False # unterminated (the incident)
|
||||
assert wire_valid_arguments("") is False # empty (no-arg call) — json.loads raises
|
||||
assert wire_valid_arguments("[]") is False # array, not object
|
||||
assert wire_valid_arguments("5") is False # bare scalar
|
||||
assert wire_valid_arguments('"hi"') is False # bare string
|
||||
assert wire_valid_arguments(None) is False # missing
|
||||
assert wire_valid_arguments({"a": 1}) is False # raw dict — not a string on the wire
|
||||
|
||||
|
||||
def test_wire_valid_arguments_totals_on_deeply_nested_json() -> None:
|
||||
# Deeply-nested JSON makes json.loads raise RecursionError (not a ValueError);
|
||||
# the predicate must return False, not propagate and crash the send.
|
||||
deep = "[" * 5000 + "]" * 5000
|
||||
assert wire_valid_arguments(deep) is False
|
||||
|
||||
|
||||
def test_tool_args_preview_stringifies_and_caps() -> None:
|
||||
assert tool_args_preview("x" * 500) == "x" * 120
|
||||
assert tool_args_preview(None) == "None"
|
||||
assert tool_args_preview({"a": 1}) == "{'a': 1}"
|
||||
|
||||
|
||||
def test_tool_args_preview_redacts_credentials() -> None:
|
||||
# Secrets in tool args (bash commands, tokens) must not reach logs — the preview
|
||||
# runs output_guard.redact_credentials over the full value first (PR #778 review).
|
||||
out = tool_args_preview('{"command": "aws configure set key AKIAIOSFODNN7EXAMPLE"}')
|
||||
assert "AKIAIOSFODNN7EXAMPLE" not in out
|
||||
assert "[REDACTED:api_key]" in out
|
||||
|
||||
|
||||
def test_tool_args_preview_is_single_line() -> None:
|
||||
# Control chars (LF/CR/TAB) collapse to spaces so the preview stays one log line.
|
||||
raw = "line1" + chr(10) + "line2" + chr(13) + "end" + chr(9) + "z"
|
||||
out = tool_args_preview(raw)
|
||||
assert chr(10) not in out and chr(13) not in out and chr(9) not in out
|
||||
assert "line1" in out and "end" in out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sanitize_tool_call_arguments — the legalize pass
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _call(call_id: str, arguments: Any, name: str = "bash") -> dict[str, Any]:
|
||||
return {"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments}}
|
||||
|
||||
|
||||
def _assistant_calls(*calls: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"role": "assistant", "content": "", "tool_calls": list(calls)}
|
||||
|
||||
|
||||
def test_sanitize_identity_when_all_valid() -> None:
|
||||
msgs = [_assistant_calls(_call("c1", "{}"), _call("c2", '{"a": 1}')), _tool("c1"), _tool("c2")]
|
||||
# Every arguments already a JSON object → same object returned (allocation-free).
|
||||
assert sanitize_tool_call_arguments(msgs) is msgs
|
||||
|
||||
|
||||
def test_sanitize_identity_when_no_tool_calls() -> None:
|
||||
msgs = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]
|
||||
assert sanitize_tool_call_arguments(msgs) is msgs
|
||||
|
||||
|
||||
def test_sanitize_legalizes_unterminated_arguments() -> None:
|
||||
# The production incident: deepseek-v4-flash emitted an unterminated args string
|
||||
# with a non-``length`` finish reason, so it was committed and replayed verbatim.
|
||||
msgs = [_assistant_calls(_call("c1", '{"command": "cat /va')), _tool("c1", "retry")]
|
||||
out = sanitize_tool_call_arguments(msgs)
|
||||
assert out is not msgs # copied on repair
|
||||
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
|
||||
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
|
||||
|
||||
|
||||
def test_sanitize_legalizes_empty_arguments() -> None:
|
||||
# A no-arg tool call sends ``""``; json.loads("") raises, so deepseek_v4 would 400.
|
||||
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", ""))])
|
||||
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
|
||||
|
||||
|
||||
def test_sanitize_legalizes_non_object_json() -> None:
|
||||
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", "[]"), _call("c2", "5"))])
|
||||
assert [tc["function"]["arguments"] for tc in out[0]["tool_calls"]] == ["{}", "{}"]
|
||||
|
||||
|
||||
def test_sanitize_serializes_raw_dict_arguments() -> None:
|
||||
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", {"command": "ls"}))])
|
||||
got = out[0]["tool_calls"][0]["function"]["arguments"]
|
||||
assert isinstance(got, str) and json.loads(got) == {"command": "ls"}
|
||||
|
||||
|
||||
def test_sanitize_falls_back_when_dict_not_serializable() -> None:
|
||||
# Defensive branch: a dict arguments carrying a non-JSON-encodable value
|
||||
# (a set) makes json.dumps raise TypeError — it collapses to "{}", not a crash.
|
||||
out = sanitize_tool_call_arguments([_assistant_calls(_call("c1", {"x": {1, 2, 3}}))])
|
||||
assert out[0]["tool_calls"][0]["function"]["arguments"] == "{}"
|
||||
|
||||
|
||||
def test_sanitize_touches_only_the_offending_call() -> None:
|
||||
good = _call("c1", '{"a": 1}')
|
||||
bad = _call("c2", "{oops")
|
||||
out = sanitize_tool_call_arguments([_assistant_calls(good, bad)])
|
||||
# Valid sibling preserved by identity; only the bad call is rebuilt.
|
||||
assert out[0]["tool_calls"][0] is good
|
||||
assert out[0]["tool_calls"][1]["function"]["arguments"] == "{}"
|
||||
|
||||
|
||||
def test_sanitize_does_not_mutate_input() -> None:
|
||||
raw = '{"command": "cat /va'
|
||||
bad = _call("c1", raw)
|
||||
msgs = [_assistant_calls(bad)]
|
||||
sanitize_tool_call_arguments(msgs)
|
||||
assert bad["function"]["arguments"] == raw # caller's dict untouched
|
||||
assert msgs[0]["tool_calls"][0] is bad
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# legalize ∘ repair — the two send-time validity passes compose
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_legalize_then_repair_answered_call() -> None:
|
||||
# Malformed-but-answered (the poison-pill shape): args legalized, no orphan added.
|
||||
msgs = [_assistant_calls(_call("c1", "{bad")), _tool("c1", "retry with valid JSON")]
|
||||
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
|
||||
assert [m["role"] for m in out] == ["assistant", "tool"]
|
||||
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
|
||||
|
||||
|
||||
def test_legalize_then_repair_orphaned_call() -> None:
|
||||
# Malformed AND unanswered: legalized args + a synthesized cancellation result.
|
||||
msgs = [_assistant_calls(_call("c1", "{bad"))]
|
||||
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
|
||||
assert [m["role"] for m in out] == ["assistant", "tool"]
|
||||
assert json.loads(out[0]["tool_calls"][0]["function"]["arguments"]) == {}
|
||||
assert out[1]["content"] == CANCELLED_TOOL_RESULT
|
||||
|
||||
|
||||
def test_pipeline_every_emitted_arguments_is_a_json_object() -> None:
|
||||
# The end-state invariant a strict renderer relies on.
|
||||
msgs = [
|
||||
_assistant_calls(_call("c1", ""), _call("c2", "{oops"), _call("c3", '{"ok": true}')),
|
||||
_tool("c1"),
|
||||
_tool("c2"),
|
||||
_tool("c3"),
|
||||
]
|
||||
out = repair_wire_messages(sanitize_tool_call_arguments(msgs))
|
||||
for m in out:
|
||||
for tc in m.get("tool_calls", []):
|
||||
assert isinstance(json.loads(tc["function"]["arguments"]), dict)
|
||||
|
||||
+87
-71
@@ -2204,42 +2204,6 @@ class TestConnectOneUnreachable:
|
||||
assert "bad" in mgr._last_error
|
||||
|
||||
|
||||
class TestSafeCloseStack:
|
||||
"""_safe_close_stack should suppress errors from broken anyio scopes."""
|
||||
|
||||
def test_suppresses_runtime_error(self):
|
||||
"""RuntimeError from broken cancel scope is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
# Simulate a broken close that raises RuntimeError
|
||||
async def _broken_close():
|
||||
raise RuntimeError("Attempted to exit cancel scope in a different task")
|
||||
|
||||
stack.aclose = _broken_close
|
||||
# Should not raise
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_suppresses_cancelled_error(self):
|
||||
"""CancelledError during close is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
async def _cancel_close():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
stack.aclose = _cancel_close
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 1: Cancel orphaned futures on timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2455,10 +2419,12 @@ class TestCircuitBreaker:
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
mock_session.call_tool = MagicMock(return_value="sentinel")
|
||||
# Seed both session and stack so the test can verify stack survives.
|
||||
old_stack = MagicMock()
|
||||
# Seed session + owner so the test can verify the owner survives.
|
||||
old_owner = MagicMock()
|
||||
old_streams = (MagicMock(), MagicMock())
|
||||
_seed_static_state(mgr, "test", session=mock_session, stack=old_stack, streams=old_streams)
|
||||
_seed_static_state(
|
||||
mgr, "test", session=mock_session, owner_task=old_owner, streams=old_streams
|
||||
)
|
||||
mgr._loop = MagicMock()
|
||||
mgr._tool_map["mcp__test__ping"] = ("test", "ping")
|
||||
mock_future = MagicMock()
|
||||
@@ -2468,11 +2434,11 @@ class TestCircuitBreaker:
|
||||
pytest.raises(BrokenPipeError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
# Session evicted, but stack/streams remain for the stale-and-stack
|
||||
# guard in _connect_one to clean up on next reconnect attempt.
|
||||
# Session evicted, but the owner/streams remain for the stale guard in
|
||||
# _connect_one_locked to close on the next reconnect attempt.
|
||||
state = mgr._static_servers["test"]
|
||||
assert state.session is None
|
||||
assert state.stack is old_stack
|
||||
assert state.owner_task is old_owner
|
||||
assert state.streams is old_streams
|
||||
|
||||
def test_independent_circuits_per_server(self):
|
||||
@@ -2958,42 +2924,47 @@ class TestReconnectSync:
|
||||
``reconnect_sync`` no longer carries its own copy. Drive the REAL locked
|
||||
body via a no-command stdio cfg: the stale-guard runs, then the connect
|
||||
early-returns, so the ordering is observable without a live server."""
|
||||
mgr, _loop, _thread = running_loop_mgr
|
||||
mgr, loop, _thread = running_loop_mgr
|
||||
mgr._server_configs["srv"] = {"type": "stdio"} # no command → early return
|
||||
|
||||
order: list[str] = []
|
||||
old_stack = MagicMock(spec=AsyncExitStack)
|
||||
|
||||
async def _make_owner() -> tuple[asyncio.Event, asyncio.Task[None]]:
|
||||
ev = asyncio.Event()
|
||||
|
||||
async def _parked_owner() -> None:
|
||||
await ev.wait()
|
||||
order.append("owner_exit")
|
||||
|
||||
task = asyncio.create_task(_parked_owner())
|
||||
await asyncio.sleep(0)
|
||||
return ev, task
|
||||
|
||||
ev, old_owner = _run_hl(loop, _make_owner())
|
||||
|
||||
async def _pre_close(name: str) -> None:
|
||||
order.append("pre_close")
|
||||
# Session must already be nulled when streams close (canonical order).
|
||||
assert mgr._static_servers["srv"].session is None
|
||||
|
||||
async def _safe_close(stack: Any) -> None:
|
||||
# Only the OLD stack is closed on this path (the fresh connect
|
||||
# stack is aclose()d directly by the no-command early return).
|
||||
assert stack is old_stack
|
||||
order.append("safe_close")
|
||||
|
||||
# Seed the old session/stack/streams that the stale-guard should clear.
|
||||
# Seed the old session/owner/streams that the stale-guard should close.
|
||||
_seed_static_state(
|
||||
mgr,
|
||||
"srv",
|
||||
session=MagicMock(),
|
||||
stack=old_stack,
|
||||
owner_task=old_owner,
|
||||
close_requested=ev,
|
||||
streams=(MagicMock(), MagicMock()),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(mgr, "_pre_close_streams", side_effect=_pre_close),
|
||||
patch.object(mgr, "_safe_close_stack", side_effect=_safe_close),
|
||||
):
|
||||
with patch.object(mgr, "_pre_close_streams", side_effect=_pre_close):
|
||||
result = mgr.reconnect_sync("srv")
|
||||
assert order == ["pre_close", "safe_close"] # teardown ran, in order
|
||||
assert order == ["pre_close", "owner_exit"] # teardown ran, in order
|
||||
assert result["connected"] is False # no command — nothing to rebuild
|
||||
state = mgr._static_servers["srv"]
|
||||
assert state.session is None
|
||||
assert state.stack is not old_stack # old stack cleared from state
|
||||
assert state.owner_task is None # old owner cleared from state
|
||||
assert old_owner.done() and not old_owner.cancelled()
|
||||
|
||||
def test_reconnect_failure_returns_error_dict(self, running_loop_mgr):
|
||||
mgr, _loop, _thread = running_loop_mgr
|
||||
@@ -4013,7 +3984,7 @@ class TestEnsureStaticConnected:
|
||||
"""session None + in_flight > 0 → defer (None) without teardown; once
|
||||
the sibling call drains, the next call reconnects."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
state = _seed_static_state(mgr, "srv", session=None, stack=MagicMock(spec=AsyncExitStack))
|
||||
state = _seed_static_state(mgr, "srv", session=None)
|
||||
state.in_flight = 1
|
||||
sess = MagicMock()
|
||||
|
||||
@@ -4179,30 +4150,75 @@ class TestTeardownStaticSession:
|
||||
stale-guard and remove_server_sync)."""
|
||||
|
||||
def test_teardown_order_and_state_cleared(self, running_loop_mgr) -> None:
|
||||
"""Close protocol: session nulled, close event set BEFORE the first
|
||||
await (a teardown cancelled mid-flight must still have delivered the
|
||||
owner's marching orders), streams pre-closed, then the parked owner
|
||||
exits GRACEFULLY — no cancel."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
order: list[str] = []
|
||||
old_stack = MagicMock(spec=AsyncExitStack)
|
||||
|
||||
async def _make_owner() -> tuple[asyncio.Event, asyncio.Task[None]]:
|
||||
ev = asyncio.Event()
|
||||
|
||||
async def _parked_owner() -> None:
|
||||
await ev.wait()
|
||||
order.append("owner_exit")
|
||||
|
||||
task = asyncio.create_task(_parked_owner())
|
||||
await asyncio.sleep(0) # let the owner park
|
||||
return ev, task
|
||||
|
||||
ev, owner = _run_hl(loop, _make_owner())
|
||||
|
||||
async def _pre_close(name: str) -> None:
|
||||
order.append("pre_close")
|
||||
# Session nulled FIRST so concurrent dispatch reads see
|
||||
# "disconnected", not a corpse.
|
||||
assert mgr._static_servers["srv"].session is None
|
||||
# The close signal precedes the first await of the teardown.
|
||||
assert ev.is_set()
|
||||
|
||||
async def _safe_close(stack: Any) -> None:
|
||||
order.append("safe_close")
|
||||
assert stack is old_stack
|
||||
|
||||
_seed_static_state(mgr, "srv", session=MagicMock(), stack=old_stack)
|
||||
with (
|
||||
patch.object(mgr, "_pre_close_streams", side_effect=_pre_close),
|
||||
patch.object(mgr, "_safe_close_stack", side_effect=_safe_close),
|
||||
):
|
||||
_seed_static_state(mgr, "srv", session=MagicMock(), owner_task=owner, close_requested=ev)
|
||||
with patch.object(mgr, "_pre_close_streams", side_effect=_pre_close):
|
||||
_run_hl(loop, mgr._teardown_static_session("srv"))
|
||||
assert order == ["pre_close", "safe_close"]
|
||||
assert order == ["pre_close", "owner_exit"]
|
||||
state = mgr._static_servers["srv"]
|
||||
assert state.session is None
|
||||
assert state.stack is None
|
||||
assert state.owner_task is None
|
||||
assert state.close_requested is None
|
||||
assert owner.done() and not owner.cancelled() # graceful, no escalation
|
||||
|
||||
def test_teardown_escalates_to_single_cancel(self, running_loop_mgr) -> None:
|
||||
"""An owner that ignores the close event gets EXACTLY one cancel — a
|
||||
second cancel is the zombie-minting mistake the protocol forbids, so
|
||||
the count is pinned, not just the final cancelled state."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
mgr._OWNER_CLOSE_GRACE_S = 0.05 # keep the graceful window short
|
||||
cancel_calls: list[Any] = []
|
||||
|
||||
async def _make_owner() -> asyncio.Task[None]:
|
||||
async def _stubborn_owner() -> None:
|
||||
await asyncio.sleep(3600) # never watches the event
|
||||
|
||||
task = asyncio.create_task(_stubborn_owner())
|
||||
await asyncio.sleep(0)
|
||||
real_cancel = task.cancel
|
||||
|
||||
def _counting_cancel(*args: Any, **kwargs: Any) -> bool:
|
||||
cancel_calls.append(args)
|
||||
return real_cancel(*args, **kwargs)
|
||||
|
||||
task.cancel = _counting_cancel # type: ignore[method-assign]
|
||||
return task
|
||||
|
||||
owner = _run_hl(loop, _make_owner())
|
||||
_seed_static_state(
|
||||
mgr, "srv", session=MagicMock(), owner_task=owner, close_requested=asyncio.Event()
|
||||
)
|
||||
_run_hl(loop, mgr._teardown_static_session("srv"))
|
||||
assert owner.cancelled()
|
||||
assert len(cancel_calls) == 1 # one cancel, never a second
|
||||
assert mgr._static_servers["srv"].owner_task is None
|
||||
|
||||
def test_teardown_missing_server_is_noop(self, running_loop_mgr) -> None:
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Live flaky-server smoke test: SIGKILL-flap a real MCP server, no CPU spin.
|
||||
|
||||
End-to-end regression for the flaky-server 100%-CPU incident: a real
|
||||
streamable-http MCP server (FastMCP, subprocess) is SIGKILLed and restarted
|
||||
several times underneath a real ``MCPClientManager`` with the health loop
|
||||
running on compressed timings. The production failure signature was armed
|
||||
anyio ``CancelScope``s — each one re-delivers cancellation via ``call_soon``
|
||||
every event-loop iteration, forever (~10^5+ callbacks/s), one more per flap
|
||||
cycle — so the pass criterion is structural: after the flaps settle, ZERO
|
||||
armed scopes exist on the mcp-loop, exactly one transport owner is alive, the
|
||||
health loop still runs, and a real tool call round-trips.
|
||||
|
||||
Self-contained (spawns its own server; no LLM backend, no network beyond
|
||||
127.0.0.1) — deliberately NOT marked ``live``. Wall clock ~10-15s.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_SRC = textwrap.dedent(
|
||||
'''
|
||||
"""Healthy streamable-http MCP server; the test SIGKILLs it to flap."""
|
||||
import sys
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
port = int(sys.argv[1])
|
||||
mcp = FastMCP("flaky-victim", host="127.0.0.1", port=port)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ping_me(x: int) -> int:
|
||||
"""Return x + 1."""
|
||||
return x + 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
'''
|
||||
).lstrip()
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_tcp_ready(port: int, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
state = mgr._static_servers.get(name)
|
||||
if state is not None and state.session is not None:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
async def _armed_scope_count() -> int:
|
||||
"""Armed scopes hosted on THIS (the mcp) loop — mirrors the production
|
||||
disarm sweep's scoping, and keeps an unrelated scope on another loop that
|
||||
is momentarily mid-cancellation from flaking the assertion."""
|
||||
import asyncio as _asyncio
|
||||
|
||||
from anyio._backends._asyncio import CancelScope
|
||||
|
||||
this_loop = _asyncio.get_running_loop()
|
||||
armed = 0
|
||||
for obj in gc.get_objects():
|
||||
if not isinstance(obj, CancelScope):
|
||||
continue
|
||||
if getattr(obj, "_cancel_handle", None) is None:
|
||||
continue
|
||||
host = getattr(obj, "_host_task", None)
|
||||
if host is not None and host.get_loop() is not this_loop:
|
||||
continue
|
||||
armed += 1
|
||||
return armed
|
||||
|
||||
|
||||
async def _live_owner_count() -> int:
|
||||
return sum(
|
||||
1
|
||||
for t in asyncio.all_tasks()
|
||||
if t.get_name().startswith("mcp-transport-owner:") and not t.done()
|
||||
)
|
||||
|
||||
|
||||
class TestFlakyServerNoSpin:
|
||||
def test_sigkill_flap_cycle_no_armed_scopes_and_recovers(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The subprocess runs sys.executable, so importability HERE is a
|
||||
# faithful proxy for the server side. Environment gaps skip, not fail.
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
script = tmp_path / "flaky_srv.py"
|
||||
script.write_text(SERVER_SRC)
|
||||
port = _free_port()
|
||||
|
||||
# Compress recovery timings so 3 flap cycles fit a unit-test budget.
|
||||
monkeypatch.setattr(MCPClientManager, "_CONNECT_TIMEOUT", 3)
|
||||
monkeypatch.setattr(MCPClientManager, "_TCP_PROBE_TIMEOUT", 1)
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_RECONNECT_ATTEMPT_TIMEOUT_S", 5.0)
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_RECONNECT_CALLER_TIMEOUT_S", 6.0)
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_RECONNECT_BASE_S", 0.2)
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_RECONNECT_MAX_S", 0.8)
|
||||
monkeypatch.setattr(MCPClientManager, "_STATIC_HEALTH_PING_TIMEOUT_S", 1.5)
|
||||
|
||||
def _spawn_server(*, initial: bool = False) -> subprocess.Popen[bytes]:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(script), str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if not _wait_tcp_ready(port, 10.0):
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
if initial:
|
||||
# Environment gap (loaded CI runner, sandboxed sockets) —
|
||||
# not a regression signal. Mid-test respawns DO fail: the
|
||||
# server already bound once, so a vanishing rebind is real.
|
||||
pytest.skip("flaky-server subprocess did not come up")
|
||||
raise AssertionError("flaky server did not come back up mid-test")
|
||||
return proc
|
||||
|
||||
proc: subprocess.Popen[bytes] | None = None
|
||||
mgr: MCPClientManager | None = None
|
||||
try:
|
||||
proc = _spawn_server(initial=True)
|
||||
with patch(
|
||||
"turnstone.core.mcp_client.load_config",
|
||||
return_value={"static_health_check_seconds": 0.4},
|
||||
):
|
||||
mgr = MCPClientManager(
|
||||
{"flaky": {"type": "http", "url": f"http://127.0.0.1:{port}/mcp"}}
|
||||
)
|
||||
mgr.start()
|
||||
assert _wait_session_live(mgr, "flaky", 8.0), "initial connect failed"
|
||||
|
||||
for _cycle in range(3):
|
||||
proc.send_signal(signal.SIGKILL)
|
||||
proc.wait()
|
||||
time.sleep(0.6) # dead window: health loop sees the corpse
|
||||
proc = _spawn_server()
|
||||
assert _wait_session_live(mgr, "flaky", 10.0), (
|
||||
f"no reconnect after flap cycle {_cycle}"
|
||||
)
|
||||
|
||||
# Let in-flight teardown/backoff machinery fully settle.
|
||||
time.sleep(1.5)
|
||||
|
||||
assert mgr._loop is not None
|
||||
armed = asyncio.run_coroutine_threadsafe(_armed_scope_count(), mgr._loop).result(
|
||||
timeout=10
|
||||
)
|
||||
owners = asyncio.run_coroutine_threadsafe(_live_owner_count(), mgr._loop).result(
|
||||
timeout=10
|
||||
)
|
||||
health = mgr._static_health_task
|
||||
|
||||
# The production failure signature: one armed scope per flap cycle.
|
||||
assert armed == 0, f"{armed} armed cancel scope(s) — the CPU-spin signature"
|
||||
# Exactly the current session's owner is alive; the flapped ones
|
||||
# all unwound instead of leaking.
|
||||
assert owners == 1
|
||||
# The recovery machinery itself survived every flap.
|
||||
assert health is not None and not health.done()
|
||||
# The structural fix did the work — the disarm backstop never ran.
|
||||
assert mgr._last_scope_disarm == 0.0
|
||||
|
||||
# And the recovered session actually dispatches.
|
||||
out = mgr.call_tool_sync("mcp__flaky__ping_me", {"x": 41}, timeout=10)
|
||||
assert "42" in out
|
||||
finally:
|
||||
if mgr is not None:
|
||||
mgr.shutdown()
|
||||
if proc is not None:
|
||||
proc.send_signal(signal.SIGKILL)
|
||||
proc.wait(timeout=5)
|
||||
@@ -1033,22 +1033,22 @@ class TestStaticPathUnchanged:
|
||||
|
||||
from turnstone.core import mcp_client
|
||||
|
||||
# The connect body (incl. the streamablehttp_client call site) lives in
|
||||
# ``_connect_one_locked``; ``_connect_one`` is now a per-name-lock wrapper.
|
||||
source = inspect.getsource(mcp_client.MCPClientManager._connect_one_locked)
|
||||
# The static path's streamablehttp_client call site lives in the
|
||||
# transport owner task (``_static_transport_owner``); ``_connect_one``
|
||||
# is a per-name-lock wrapper and ``_connect_one_locked`` only waits on
|
||||
# the owner's readiness.
|
||||
source = inspect.getsource(mcp_client.MCPClientManager._static_transport_owner)
|
||||
|
||||
# The static path's streamablehttp_client invocation should NOT
|
||||
# mention ``httpx_client_factory``. Pool path keeps it.
|
||||
# Find the streamablehttp_client(...) call inside _connect_one.
|
||||
assert "streamablehttp_client" in source
|
||||
# The call site in _connect_one is bare — no factory keyword.
|
||||
# We grep by line: the factory keyword must not appear in the
|
||||
# static-path source.
|
||||
# The call site in the owner is bare — no factory keyword. We grep by
|
||||
# line: the factory keyword must not appear in the static-path source.
|
||||
for line in source.splitlines():
|
||||
if "httpx_client_factory" in line:
|
||||
pytest.fail(
|
||||
"_connect_one (static path) passes httpx_client_factory to "
|
||||
"streamablehttp_client; hard invariant 1 violated."
|
||||
"_static_transport_owner (static path) passes httpx_client_factory "
|
||||
"to streamablehttp_client; hard invariant 1 violated."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Pool transport owner-task lifecycle + anyio cancel-scope regressions.
|
||||
|
||||
The pool (auth_type=oauth_user) sibling of ``test_mcp_transport_owner.py``.
|
||||
Each ``(user, server)`` pool entry's transport + ``ClientSession`` cms are now
|
||||
entered, parked, and exited by ONE long-lived owner task
|
||||
(``_pool_transport_owner``) with a one-cancel close protocol, so a cancel scope
|
||||
whose host task has finished can never be left re-delivering cancellation in a
|
||||
``call_soon`` loop (the SDK #2147 100%-CPU spin). These fast mock-transport
|
||||
tests pin that protocol for the pool path; the real-server integration coverage
|
||||
lives in ``test_mcp_pool_auth_integration.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager, PoolEntryState, _AuthCapture
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def running_loop_mgr():
|
||||
"""Background-loop fixture matching the pool-path test convention.
|
||||
|
||||
Teardown drains the eviction / sweep / health tasks AND any parked pool
|
||||
transport owner a successful connect left installed — the conftest fails
|
||||
leaked threads and an undrained owner is destroyed pending at GC.
|
||||
"""
|
||||
cfg: dict[str, Any] = {}
|
||||
mgr = MCPClientManager(cfg)
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True, name="mcp-pool-owner-test-loop")
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
try:
|
||||
yield mgr, loop, thread
|
||||
finally:
|
||||
|
||||
async def _drain(m: MCPClientManager) -> None:
|
||||
for attr in (
|
||||
"_user_pool_eviction_task",
|
||||
"_user_token_sweep_task",
|
||||
"_static_health_task",
|
||||
):
|
||||
task = getattr(m, attr)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
setattr(m, attr, None)
|
||||
for entry in list(m._user_pool_entries.values()):
|
||||
owner = entry.owner_task
|
||||
if owner is not None and not owner.done():
|
||||
if entry.close_requested is not None:
|
||||
entry.close_requested.set()
|
||||
owner.cancel()
|
||||
await asyncio.gather(owner, return_exceptions=True)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=5)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
if not thread.is_alive():
|
||||
loop.close()
|
||||
|
||||
|
||||
def _run(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 5.0) -> Any:
|
||||
return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=timeout)
|
||||
|
||||
|
||||
def _http_cfg() -> dict[str, Any]:
|
||||
return {"type": "streamable-http", "url": "https://mcp.example.com/mcp", "headers": {}}
|
||||
|
||||
|
||||
def _make_pool_session_mock() -> AsyncMock:
|
||||
"""A ClientSession-shaped mock good enough for pool connect + discovery."""
|
||||
session = AsyncMock()
|
||||
session.initialize = AsyncMock()
|
||||
# None caps → resources/prompts discovery is skipped; only list_tools runs.
|
||||
session.get_server_capabilities = MagicMock(return_value=None)
|
||||
session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||
return session
|
||||
|
||||
|
||||
def _fake_transport_and_session(patches: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build fake streamable-http transport + ClientSession cms.
|
||||
|
||||
Records enter/exit events and captures the kwargs that reach
|
||||
``streamablehttp_client`` (so the bearer-header / factory contract is
|
||||
observable).
|
||||
"""
|
||||
events: list[str] = []
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
session = _make_pool_session_mock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_streamablehttp_client(**kwargs: Any):
|
||||
captured_kwargs.clear()
|
||||
captured_kwargs.update(kwargs)
|
||||
events.append("transport_enter")
|
||||
try:
|
||||
yield (AsyncMock(), AsyncMock(), lambda: None)
|
||||
finally:
|
||||
events.append("transport_exit")
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client_session_cm():
|
||||
events.append("session_enter")
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
events.append("session_exit")
|
||||
|
||||
def fake_client_session(_read: Any, _write: Any, message_handler: Any = None):
|
||||
return fake_client_session_cm()
|
||||
|
||||
patches["streamablehttp_client"] = fake_streamablehttp_client
|
||||
patches["ClientSession"] = fake_client_session
|
||||
return {"events": events, "session": session, "kwargs": captured_kwargs}
|
||||
|
||||
|
||||
async def _connect_under_lock(
|
||||
mgr: MCPClientManager, key: tuple[str, str], cfg: dict[str, Any], **kw: Any
|
||||
) -> PoolEntryState:
|
||||
"""Drive ``_connect_one_pool`` the way production does — under open_lock."""
|
||||
entry = await mgr._ensure_pool_entry(key)
|
||||
async with entry.open_lock:
|
||||
return await mgr._connect_one_pool(key, cfg, "tok-aaa", **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPoolTransportOwnerLifecycle:
|
||||
def test_connect_installs_owner_and_teardown_closes_gracefully(self, running_loop_mgr) -> None:
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
key = ("user-1", "pool-srv")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.mcp_client.streamablehttp_client", patches["streamablehttp_client"]
|
||||
),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
entry = _run(loop, _connect_under_lock(mgr, key, _http_cfg()))
|
||||
assert entry.session is fake["session"]
|
||||
owner = entry.owner_task
|
||||
assert owner is not None and not owner.done()
|
||||
assert entry.close_requested is not None
|
||||
assert fake["events"] == ["transport_enter", "session_enter"]
|
||||
|
||||
_run(loop, mgr._teardown_pool_entry(key))
|
||||
|
||||
# Graceful close: the parked owner exits via the event — no cancel —
|
||||
# and unwinds BOTH cms in-task, inner-out (session before transport).
|
||||
assert owner.done() and not owner.cancelled()
|
||||
assert fake["events"] == [
|
||||
"transport_enter",
|
||||
"session_enter",
|
||||
"session_exit",
|
||||
"transport_exit",
|
||||
]
|
||||
assert entry.session is None
|
||||
assert entry.owner_task is None
|
||||
assert entry.close_requested is None
|
||||
# The entry itself is NOT popped — teardown leaves map/catalog cleanup
|
||||
# to callers.
|
||||
assert key in mgr._user_pool_entries
|
||||
|
||||
def test_owner_death_during_discovery_fails_fast(self, running_loop_mgr) -> None:
|
||||
"""The owner-died branch of ``_await_owner_discovery`` — the reason the
|
||||
helper exists: discovery runs in the caller while the transport is
|
||||
hosted by the owner, so a transport collapse mid-discovery cancels the
|
||||
OWNER and a bare await on the response stream would hang until the 30s
|
||||
phase timeout. The race must convert that into a PROMPT
|
||||
``ConnectionError``, reap the parked discovery future, and leave the
|
||||
entry torn down."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
key = ("user-1", "pool-srv")
|
||||
|
||||
discovery_parked = asyncio.Event()
|
||||
|
||||
async def _parked_list_tools() -> Any:
|
||||
discovery_parked.set()
|
||||
await asyncio.sleep(3600) # the transport never answers
|
||||
|
||||
fake["session"].list_tools = AsyncMock(side_effect=_parked_list_tools)
|
||||
|
||||
async def _drive() -> tuple[float, BaseException | None]:
|
||||
entry = await mgr._ensure_pool_entry(key)
|
||||
|
||||
async def _collapse_owner_when_parked() -> None:
|
||||
await discovery_parked.wait()
|
||||
owner = entry.owner_task # installed before discovery begins
|
||||
assert owner is not None
|
||||
# The transport task group collapsing under live discovery
|
||||
# (e.g. an upstream 401) surfaces as the owner being cancelled.
|
||||
owner.cancel()
|
||||
|
||||
collapser = asyncio.create_task(_collapse_owner_when_parked())
|
||||
t0 = asyncio.get_running_loop().time()
|
||||
exc: BaseException | None = None
|
||||
try:
|
||||
async with entry.open_lock:
|
||||
await mgr._connect_one_pool(key, _http_cfg(), "tok-aaa")
|
||||
except Exception as e:
|
||||
# The expected ConnectionError; anything else (a cancel leak,
|
||||
# an interpreter exit) propagates and fails the test loudly.
|
||||
exc = e
|
||||
_ = await collapser # synchronization point; failures propagate
|
||||
return asyncio.get_running_loop().time() - t0, exc
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.mcp_client.streamablehttp_client", patches["streamablehttp_client"]
|
||||
),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
elapsed, exc = _run(loop, _drive(), timeout=15)
|
||||
|
||||
assert isinstance(exc, ConnectionError)
|
||||
assert "died during discovery" in str(exc)
|
||||
assert elapsed < 5.0 # prompt fail — not the 30s phase timeout
|
||||
entry = mgr._user_pool_entries[key]
|
||||
assert entry.session is None # discovery-failure teardown ran
|
||||
assert entry.owner_task is None
|
||||
|
||||
def test_cancelled_discovery_future_converts_to_connection_error(
|
||||
self, running_loop_mgr
|
||||
) -> None:
|
||||
"""A discovery future that completes CANCELLED without this race's own
|
||||
reap (an SDK-internal cancellation shape) is the transport-failure
|
||||
class, not the caller's cancellation — ``_await_owner_discovery`` must
|
||||
surface it as ``ConnectionError``, never a bare ``CancelledError`` the
|
||||
caller would misread as its own cancel."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
|
||||
async def _drive() -> BaseException | None:
|
||||
parked = asyncio.Event()
|
||||
|
||||
async def _parked_owner() -> None:
|
||||
await parked.wait()
|
||||
|
||||
owner = asyncio.create_task(_parked_owner())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def _self_cancelling_discovery() -> Any:
|
||||
# A coroutine raising CancelledError makes its wrapping task
|
||||
# complete CANCELLED — the shape of an SDK-internal cancel.
|
||||
raise asyncio.CancelledError
|
||||
|
||||
exc: BaseException | None = None
|
||||
try:
|
||||
await mgr._await_owner_discovery(owner, _self_cancelling_discovery())
|
||||
except (Exception, asyncio.CancelledError) as e:
|
||||
# Exception covers the expected ConnectionError; CancelledError
|
||||
# covers the exact regression this test guards (the bare cancel
|
||||
# leaking through instead of being converted).
|
||||
exc = e
|
||||
parked.set()
|
||||
_ = await owner # synchronization point; failures propagate
|
||||
return exc
|
||||
|
||||
exc = _run(loop, _drive())
|
||||
assert isinstance(exc, ConnectionError)
|
||||
assert "cancelled by transport failure" in str(exc)
|
||||
|
||||
def test_teardown_single_cancel_escalation(self, running_loop_mgr) -> None:
|
||||
"""A parked owner whose in-task unwind stalls past the graceful window
|
||||
gets EXACTLY ONE cancel — never a second (a second abandons an anyio
|
||||
scope exit mid-flight and mints the zombie the protocol prevents)."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
mgr._OWNER_CLOSE_GRACE_S = 0.1
|
||||
mgr._OWNER_CANCEL_GRACE_S = 1.0
|
||||
|
||||
events: list[str] = []
|
||||
cancels = {"n": 0}
|
||||
session = _make_pool_session_mock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_streamablehttp_client(**_kwargs: Any):
|
||||
events.append("transport_enter")
|
||||
try:
|
||||
yield (AsyncMock(), AsyncMock(), lambda: None)
|
||||
finally:
|
||||
events.append("transport_exit")
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_session_cm():
|
||||
events.append("session_enter")
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
# Stall the graceful unwind so teardown must escalate; count
|
||||
# each cancellation that reaches this in-task exit.
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
cancels["n"] += 1
|
||||
raise
|
||||
finally:
|
||||
events.append("session_exit")
|
||||
|
||||
def fake_session(_read: Any, _write: Any, message_handler: Any = None):
|
||||
return fake_session_cm()
|
||||
|
||||
key = ("user-1", "pool-srv")
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.streamablehttp_client", fake_streamablehttp_client),
|
||||
patch("turnstone.core.mcp_client.ClientSession", fake_session),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
entry = _run(loop, _connect_under_lock(mgr, key, _http_cfg()))
|
||||
owner = entry.owner_task
|
||||
assert owner is not None
|
||||
_run(loop, mgr._teardown_pool_entry(key), timeout=10)
|
||||
|
||||
assert owner.done() and owner.cancelled()
|
||||
assert cancels["n"] == 1
|
||||
assert events[-1] == "transport_exit"
|
||||
assert entry.session is None and entry.owner_task is None
|
||||
|
||||
def test_owner_death_evicts_session_keeps_entry_and_catalog(self, running_loop_mgr) -> None:
|
||||
"""The transport collapsing under a live session (owner dies with no
|
||||
requested close) evicts the session via the done-callback but leaves the
|
||||
entry AND its discovered catalog in place for the next dispatch."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
key = ("user-1", "pool-srv")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.mcp_client.streamablehttp_client", patches["streamablehttp_client"]
|
||||
),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
entry = _run(loop, _connect_under_lock(mgr, key, _http_cfg()))
|
||||
owner = entry.owner_task
|
||||
assert owner is not None and entry.session is fake["session"]
|
||||
# Seed a catalog so we can prove the death-callback leaves it alone.
|
||||
entry.tools = [{"name": "mcp__pool-srv__ping", "server": "pool-srv"}]
|
||||
|
||||
# Simulate the transport task group collapsing: the owner gets a
|
||||
# stray cancellation (exactly what anyio's scope delivery does).
|
||||
loop.call_soon_threadsafe(owner.cancel)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and entry.owner_task is not None:
|
||||
time.sleep(0.02)
|
||||
|
||||
assert owner.done()
|
||||
assert entry.session is None # evicted by the done-callback
|
||||
assert entry.owner_task is None
|
||||
assert key in mgr._user_pool_entries # entry kept
|
||||
assert entry.tools == [
|
||||
{"name": "mcp__pool-srv__ping", "server": "pool-srv"}
|
||||
] # catalog kept
|
||||
# The cms were still unwound in-task despite the stray cancel.
|
||||
assert fake["events"][-2:] == ["session_exit", "transport_exit"]
|
||||
|
||||
def test_caller_cancel_mid_connect_does_not_abandon_cms(self, running_loop_mgr) -> None:
|
||||
"""Cancelling the CONNECTING caller (an eviction giving up, shutdown, a
|
||||
sync boundary timing out) must close the owner via the one-cancel
|
||||
protocol — the transport cm still exits, in-task."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
events: list[str] = []
|
||||
entered = asyncio.Event()
|
||||
key = ("user-1", "pool-srv")
|
||||
|
||||
@asynccontextmanager
|
||||
async def hanging_streamablehttp_client(**_kwargs: Any):
|
||||
events.append("transport_enter")
|
||||
try:
|
||||
entered.set()
|
||||
await asyncio.sleep(3600) # server accepted, then stalled
|
||||
yield (AsyncMock(), AsyncMock(), lambda: None)
|
||||
finally:
|
||||
events.append("transport_exit")
|
||||
|
||||
async def _drive() -> None:
|
||||
entry = await mgr._ensure_pool_entry(key)
|
||||
|
||||
async def _connect() -> None:
|
||||
async with entry.open_lock:
|
||||
await mgr._connect_one_pool(key, _http_cfg(), "tok-aaa")
|
||||
|
||||
connect = asyncio.create_task(_connect())
|
||||
await asyncio.wait_for(entered.wait(), timeout=5)
|
||||
connect.cancel() # the attempt-timeout / shutdown shape
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_ = await connect # only the expected cancel is absorbed
|
||||
# The owner must be closed (one cancel) and fully unwound.
|
||||
deadline = asyncio.get_running_loop().time() + 5
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
owners = [
|
||||
t
|
||||
for t in asyncio.all_tasks()
|
||||
if t.get_name().startswith("mcp-pool-owner:") and not t.done()
|
||||
]
|
||||
if not owners:
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise AssertionError("owner task still alive after caller cancel")
|
||||
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.streamablehttp_client", hanging_streamablehttp_client),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
_run(loop, _drive(), timeout=15)
|
||||
|
||||
assert events == ["transport_enter", "transport_exit"]
|
||||
assert mgr._user_pool_entries[key].session is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client-kwargs contract (bearer header + auth-capture factory)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPoolOwnerClientKwargs:
|
||||
def test_client_factory_present_iff_auth_capture(self, running_loop_mgr) -> None:
|
||||
"""The caller builds ``client_kwargs`` and the owner passes them to
|
||||
``streamablehttp_client`` verbatim: the auth-capture
|
||||
``httpx_client_factory`` is present exactly when a carrier is supplied,
|
||||
and the per-user bearer always reaches the wire."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
key = ("user-1", "pool-srv")
|
||||
|
||||
# With auth_capture → factory present.
|
||||
patches_a: dict[str, Any] = {}
|
||||
fake_a = _fake_transport_and_session(patches_a)
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.mcp_client.streamablehttp_client",
|
||||
patches_a["streamablehttp_client"],
|
||||
),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches_a["ClientSession"]),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
_run(loop, _connect_under_lock(mgr, key, _http_cfg(), auth_capture=_AuthCapture()))
|
||||
assert "httpx_client_factory" in fake_a["kwargs"]
|
||||
assert fake_a["kwargs"]["headers"]["Authorization"] == "Bearer tok-aaa"
|
||||
_run(loop, mgr._teardown_pool_entry(key))
|
||||
|
||||
# Without auth_capture → factory absent (but bearer still present).
|
||||
patches_b: dict[str, Any] = {}
|
||||
fake_b = _fake_transport_and_session(patches_b)
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.mcp_client.streamablehttp_client",
|
||||
patches_b["streamablehttp_client"],
|
||||
),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches_b["ClientSession"]),
|
||||
patch.object(mgr, "_tcp_probe", new=AsyncMock()),
|
||||
):
|
||||
_run(loop, _connect_under_lock(mgr, key, _http_cfg()))
|
||||
assert "httpx_client_factory" not in fake_b["kwargs"]
|
||||
assert fake_b["kwargs"]["headers"]["Authorization"] == "Bearer tok-aaa"
|
||||
_run(loop, mgr._teardown_pool_entry(key))
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Transport owner-task lifecycle + anyio cancel-scope zombie regressions.
|
||||
|
||||
Covers the two bugs behind the flaky-MCP-server 100%-CPU incident:
|
||||
|
||||
* Bug 1 — an anyio cancel scope whose host task has finished can never be
|
||||
exited; once cancelled (SDK task-group child death, or a teardown racing a
|
||||
connect) anyio re-delivers cancellation to it via ``call_soon`` every loop
|
||||
iteration, forever. The fix routes every transport cm through a long-lived
|
||||
per-server OWNER task (enter, park, exit — all in one task) with a
|
||||
one-cancel close protocol; these tests pin the protocol's behavior.
|
||||
* Bug 2 — ``BaseExceptionGroup`` (BaseException-derived) escaping
|
||||
``except Exception`` killed ``_connect_all`` before the health/sweep loops
|
||||
were created, silently disabling all autonomous recovery.
|
||||
|
||||
The live end-to-end flap test (real server, SIGKILL cycle) lives in
|
||||
``test_mcp_live_flaky_server.py``; these are fast mock-transport unit tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.mcp_client import MCPClientManager
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def running_loop_mgr():
|
||||
"""Background-loop fixture matching the static-path test convention."""
|
||||
cfg: dict[str, Any] = {"srv": {"type": "stdio", "command": "fake-cmd"}}
|
||||
mgr = MCPClientManager(cfg)
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True, name="mcp-owner-test-loop")
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
try:
|
||||
yield mgr, loop, thread
|
||||
finally:
|
||||
|
||||
async def _drain(m: MCPClientManager) -> None:
|
||||
for attr in (
|
||||
"_user_pool_eviction_task",
|
||||
"_user_token_sweep_task",
|
||||
"_static_health_task",
|
||||
):
|
||||
task = getattr(m, attr)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
setattr(m, attr, None)
|
||||
for state in m._static_servers.values():
|
||||
owner = state.owner_task
|
||||
if owner is not None and not owner.done():
|
||||
if state.close_requested is not None:
|
||||
state.close_requested.set()
|
||||
owner.cancel()
|
||||
await asyncio.gather(owner, return_exceptions=True)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=5)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
if not thread.is_alive():
|
||||
loop.close()
|
||||
|
||||
|
||||
def _run(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 5.0) -> Any:
|
||||
return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=timeout)
|
||||
|
||||
|
||||
def _make_session_mock() -> AsyncMock:
|
||||
"""A ClientSession-shaped mock good enough for connect + discovery."""
|
||||
session = AsyncMock()
|
||||
session.initialize = AsyncMock()
|
||||
session.get_server_capabilities = MagicMock(return_value=None)
|
||||
session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))
|
||||
return session
|
||||
|
||||
|
||||
def _fake_transport_and_session(mgr_module_patches: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build fake stdio transport + ClientSession cms, recording enter/exit."""
|
||||
events: list[str] = []
|
||||
session = _make_session_mock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_stdio_client(_params: Any):
|
||||
events.append("transport_enter")
|
||||
try:
|
||||
yield (AsyncMock(), AsyncMock())
|
||||
finally:
|
||||
events.append("transport_exit")
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client_session_cm():
|
||||
events.append("session_enter")
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
events.append("session_exit")
|
||||
|
||||
def fake_client_session(_read: Any, _write: Any, message_handler: Any = None):
|
||||
return fake_client_session_cm()
|
||||
|
||||
mgr_module_patches["stdio_client"] = fake_stdio_client
|
||||
mgr_module_patches["ClientSession"] = fake_client_session
|
||||
return {"events": events, "session": session}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransportOwnerLifecycle:
|
||||
def test_connect_installs_owner_and_teardown_closes_gracefully(self, running_loop_mgr) -> None:
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.stdio_client", patches["stdio_client"]),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
):
|
||||
_run(loop, mgr._connect_one_locked("srv", mgr._server_configs["srv"]))
|
||||
state = mgr._static_servers["srv"]
|
||||
assert state.session is fake["session"]
|
||||
owner = state.owner_task
|
||||
assert owner is not None and not owner.done()
|
||||
assert state.close_requested is not None
|
||||
assert fake["events"] == ["transport_enter", "session_enter"]
|
||||
|
||||
_run(loop, mgr._teardown_static_session("srv"))
|
||||
|
||||
# Graceful close: the parked owner exits via the event — no cancel —
|
||||
# and unwinds BOTH cms in-task, inner-out.
|
||||
assert owner.done() and not owner.cancelled()
|
||||
assert fake["events"] == [
|
||||
"transport_enter",
|
||||
"session_enter",
|
||||
"session_exit",
|
||||
"transport_exit",
|
||||
]
|
||||
assert state.session is None
|
||||
assert state.owner_task is None
|
||||
assert state.close_requested is None
|
||||
|
||||
def test_owner_death_evicts_session(self, running_loop_mgr) -> None:
|
||||
"""Trigger-A observer: the transport collapsing under a live session
|
||||
(owner task dies without a requested close) evicts the session so the
|
||||
health loop / next dispatch reconnects instead of probing a corpse."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.stdio_client", patches["stdio_client"]),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
):
|
||||
_run(loop, mgr._connect_one_locked("srv", mgr._server_configs["srv"]))
|
||||
state = mgr._static_servers["srv"]
|
||||
owner = state.owner_task
|
||||
assert owner is not None and state.session is fake["session"]
|
||||
|
||||
# Simulate the transport task group collapsing: the owner gets a
|
||||
# stray cancellation (exactly what anyio's scope delivery does).
|
||||
loop.call_soon_threadsafe(owner.cancel)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and state.owner_task is not None:
|
||||
time.sleep(0.02)
|
||||
|
||||
assert owner.done()
|
||||
assert state.session is None # evicted by the done-callback
|
||||
assert state.owner_task is None
|
||||
# The cms were still unwound in-task despite the stray cancel.
|
||||
assert fake["events"][-2:] == ["session_exit", "transport_exit"]
|
||||
|
||||
def test_owner_death_during_discovery_fails_fast(self, running_loop_mgr) -> None:
|
||||
"""The static sibling of the pool's owner-death discovery race:
|
||||
discovery runs in the connecting caller while the transport is hosted
|
||||
by the owner, so a transport collapse mid-discovery cancels the OWNER
|
||||
and a bare await on the response stream would hang to the caller-side
|
||||
attempt timeout (~45s). ``_await_owner_discovery`` must convert it
|
||||
into a PROMPT ``ConnectionError`` and leave the state torn down."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
patches: dict[str, Any] = {}
|
||||
fake = _fake_transport_and_session(patches)
|
||||
|
||||
discovery_parked = asyncio.Event()
|
||||
|
||||
async def _parked_list_tools() -> Any:
|
||||
discovery_parked.set()
|
||||
await asyncio.sleep(3600) # the transport never answers
|
||||
|
||||
fake["session"].list_tools = AsyncMock(side_effect=_parked_list_tools)
|
||||
|
||||
async def _drive() -> tuple[float, BaseException | None]:
|
||||
async def _collapse_owner_when_parked() -> None:
|
||||
await discovery_parked.wait()
|
||||
owner = mgr._static_servers["srv"].owner_task
|
||||
assert owner is not None
|
||||
owner.cancel() # the transport task group collapsing
|
||||
|
||||
collapser = asyncio.create_task(_collapse_owner_when_parked())
|
||||
t0 = asyncio.get_running_loop().time()
|
||||
exc: BaseException | None = None
|
||||
try:
|
||||
await mgr._connect_one_locked("srv", mgr._server_configs["srv"])
|
||||
except Exception as e:
|
||||
# The expected ConnectionError; anything else (a cancel leak,
|
||||
# an interpreter exit) propagates and fails the test loudly.
|
||||
exc = e
|
||||
_ = await collapser # synchronization point; failures propagate
|
||||
return asyncio.get_running_loop().time() - t0, exc
|
||||
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.stdio_client", patches["stdio_client"]),
|
||||
patch("turnstone.core.mcp_client.ClientSession", patches["ClientSession"]),
|
||||
):
|
||||
elapsed, exc = _run(loop, _drive(), timeout=15)
|
||||
|
||||
assert isinstance(exc, ConnectionError)
|
||||
assert "died during discovery" in str(exc)
|
||||
assert elapsed < 5.0 # prompt fail — not the attempt-timeout hang
|
||||
assert mgr._static_servers["srv"].session is None
|
||||
# The owner unwound its cms despite dying mid-discovery.
|
||||
assert fake["events"][-2:] == ["session_exit", "transport_exit"]
|
||||
|
||||
def test_base_exception_escape_resolves_waiter_and_propagates(self, running_loop_mgr) -> None:
|
||||
"""A BaseException-derived escape that is neither CancelledError nor
|
||||
Exception/group (a library control-flow escape; SystemExit and
|
||||
KeyboardInterrupt take the same path but additionally stop the loop —
|
||||
asyncio semantics, unobservable in-process) is NOT swallowed — it
|
||||
propagates from the owner task — but the waiter must still be resolved
|
||||
with a transport-failure error, or the connecting caller would block
|
||||
until its outer bound (and ``_connect_all``'s initial connect has
|
||||
none)."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
|
||||
class _TransportLibraryEscape(BaseException):
|
||||
pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def escaping_stdio_client(_params: Any):
|
||||
raise _TransportLibraryEscape("control-flow escape")
|
||||
yield # pragma: no cover
|
||||
|
||||
async def _drive() -> tuple[BaseException | None, BaseException | None]:
|
||||
ready: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
close_requested = asyncio.Event()
|
||||
owner = asyncio.create_task(
|
||||
mgr._static_transport_owner(
|
||||
"srv", mgr._server_configs["srv"], ready, close_requested
|
||||
)
|
||||
)
|
||||
waiter_exc: BaseException | None = None
|
||||
try:
|
||||
await ready
|
||||
except (Exception, _TransportLibraryEscape) as e:
|
||||
# Exception covers the expected ConnectionError; the escape
|
||||
# type covers the exact regression this test guards (the raw
|
||||
# escape leaking to the waiter instead of being converted).
|
||||
waiter_exc = e
|
||||
await asyncio.wait({owner}, timeout=5)
|
||||
owner_exc = owner.exception() if owner.done() and not owner.cancelled() else None
|
||||
return waiter_exc, owner_exc
|
||||
|
||||
with patch("turnstone.core.mcp_client.stdio_client", escaping_stdio_client):
|
||||
waiter_exc, owner_exc = _run(loop, _drive(), timeout=10)
|
||||
|
||||
assert isinstance(waiter_exc, ConnectionError) # waiter resolved, never hung
|
||||
assert isinstance(owner_exc, _TransportLibraryEscape) # propagated, unswallowed
|
||||
|
||||
def test_connect_failure_unwinds_owner_and_raises(self, running_loop_mgr) -> None:
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
|
||||
@asynccontextmanager
|
||||
async def failing_stdio_client(_params: Any):
|
||||
raise ConnectionError("refused")
|
||||
yield # pragma: no cover
|
||||
|
||||
with (
|
||||
patch("turnstone.core.mcp_client.stdio_client", failing_stdio_client),
|
||||
pytest.raises(ConnectionError, match="refused"),
|
||||
):
|
||||
_run(loop, mgr._connect_one_locked("srv", mgr._server_configs["srv"]))
|
||||
|
||||
state = mgr._static_servers["srv"]
|
||||
assert state.session is None
|
||||
assert state.owner_task is None
|
||||
|
||||
async def _no_owner_tasks() -> int:
|
||||
return sum(
|
||||
1
|
||||
for t in asyncio.all_tasks()
|
||||
if t.get_name().startswith("mcp-transport-owner:") and not t.done()
|
||||
)
|
||||
|
||||
assert _run(loop, _no_owner_tasks()) == 0
|
||||
|
||||
def test_caller_cancel_mid_connect_does_not_abandon_cms(self, running_loop_mgr) -> None:
|
||||
"""Bug-1 core regression: cancelling the CONNECTING caller (attempt
|
||||
timeout, shutdown, sync boundary giving up) must close the owner via
|
||||
the one-cancel protocol — the transport cm still exits, in-task."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
events: list[str] = []
|
||||
entered = asyncio.Event()
|
||||
|
||||
@asynccontextmanager
|
||||
async def hanging_stdio_client(_params: Any):
|
||||
events.append("transport_enter")
|
||||
try:
|
||||
entered.set()
|
||||
await asyncio.sleep(3600) # server accepted, then stalled
|
||||
yield (AsyncMock(), AsyncMock())
|
||||
finally:
|
||||
events.append("transport_exit")
|
||||
|
||||
async def _drive() -> None:
|
||||
connect = asyncio.create_task(
|
||||
mgr._connect_one_locked("srv", mgr._server_configs["srv"])
|
||||
)
|
||||
await asyncio.wait_for(entered.wait(), timeout=5)
|
||||
connect.cancel() # the attempt-timeout / shutdown shape
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_ = await connect # only the expected cancel is absorbed
|
||||
# The owner must be closed (one cancel) and fully unwound.
|
||||
deadline = asyncio.get_running_loop().time() + 5
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
owners = [
|
||||
t
|
||||
for t in asyncio.all_tasks()
|
||||
if t.get_name().startswith("mcp-transport-owner:") and not t.done()
|
||||
]
|
||||
if not owners:
|
||||
return
|
||||
await asyncio.sleep(0.02)
|
||||
raise AssertionError("owner task still alive after caller cancel")
|
||||
|
||||
with patch("turnstone.core.mcp_client.stdio_client", hanging_stdio_client):
|
||||
_run(loop, _drive(), timeout=15)
|
||||
|
||||
assert events == ["transport_enter", "transport_exit"]
|
||||
assert mgr._static_servers["srv"].session is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2: BaseExceptionGroup vs except Exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBaseExceptionGroupHardening:
|
||||
def test_connect_all_survives_group_and_starts_loops(self, running_loop_mgr) -> None:
|
||||
"""A transport failure wrapped in BaseExceptionGroup (e.g. an
|
||||
accept-then-RST server collapsing the SDK task group with a stray
|
||||
CancelledError inside) must not kill ``_connect_all`` before the
|
||||
health/sweep loops are started — that silently disabled ALL
|
||||
autonomous recovery."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
# Pin the loop cadences: the assertions below require both loops to be
|
||||
# ENABLED, independent of whatever mcp config the environment carries.
|
||||
mgr._user_token_sweep_s = 240.0
|
||||
mgr._static_health_check_s = 30.0
|
||||
|
||||
async def _exploding_connect(name: str, _cfg: dict[str, Any]) -> None:
|
||||
raise BaseExceptionGroup("transport collapsed", [asyncio.CancelledError()])
|
||||
|
||||
with patch.object(mgr, "_connect_one", side_effect=_exploding_connect):
|
||||
_run(loop, mgr._connect_all())
|
||||
|
||||
assert mgr._connected.is_set()
|
||||
assert "srv" in mgr._last_error
|
||||
health = mgr._static_health_task
|
||||
sweep = mgr._user_token_sweep_task
|
||||
assert health is not None and not health.done()
|
||||
assert sweep is not None and not sweep.done()
|
||||
|
||||
def test_health_loop_survives_group(self, running_loop_mgr) -> None:
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
ticks: list[int] = []
|
||||
|
||||
async def _tick_then_group() -> float:
|
||||
ticks.append(1)
|
||||
if len(ticks) == 1:
|
||||
raise BaseExceptionGroup("boom", [asyncio.CancelledError()])
|
||||
return 3600.0
|
||||
|
||||
mgr._static_health_check_s = 0.05 # quick recovery sleep after the group
|
||||
with patch.object(mgr, "_static_health_tick", side_effect=_tick_then_group):
|
||||
|
||||
async def _drive() -> asyncio.Task[None]:
|
||||
task = asyncio.create_task(mgr._static_health_loop())
|
||||
deadline = asyncio.get_running_loop().time() + 5
|
||||
while asyncio.get_running_loop().time() < deadline and len(ticks) < 2:
|
||||
await asyncio.sleep(0.02)
|
||||
assert len(ticks) >= 2, "loop died on BaseExceptionGroup"
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
_ = await task # only the expected cancel is absorbed
|
||||
return task
|
||||
|
||||
_run(loop, _drive(), timeout=10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orphaned-scope disarm backstop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScopeDisarmBackstop:
|
||||
def test_disarms_exactly_the_all_done_scope_on_this_loop(self, running_loop_mgr) -> None:
|
||||
"""One sweep over three armed scopes must touch EXACTLY the true
|
||||
orphan: the all-done-tasks scope hosted on the mcp-loop. The
|
||||
live-task scope (its task may still drain the scope) and the
|
||||
hostless scope (loop unknown — not ours to reach into) stay armed.
|
||||
Asserting ``disarmed == 1`` discriminates both failure directions:
|
||||
a no-op sweep and an over-eager one."""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
|
||||
async def _arm_and_sweep() -> dict[str, Any]:
|
||||
from anyio._backends._asyncio import CancelScope
|
||||
|
||||
this_loop = asyncio.get_running_loop()
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
|
||||
blocker = asyncio.Event()
|
||||
|
||||
async def _parked() -> None:
|
||||
await blocker.wait()
|
||||
|
||||
done_task = asyncio.create_task(_noop())
|
||||
_ = await done_task # synchronization point; failures propagate
|
||||
live_task = asyncio.create_task(_parked())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
orphan = CancelScope()
|
||||
orphan._host_task = done_task
|
||||
orphan._tasks.add(done_task)
|
||||
orphan._cancel_handle = this_loop.call_soon(lambda: None)
|
||||
|
||||
live_scope = CancelScope()
|
||||
live_scope._host_task = live_task
|
||||
live_scope._tasks.add(live_task)
|
||||
live_scope._cancel_handle = this_loop.call_soon(lambda: None)
|
||||
|
||||
hostless = CancelScope()
|
||||
hostless._tasks.add(done_task)
|
||||
hostless._cancel_handle = this_loop.call_soon(lambda: None)
|
||||
|
||||
mgr._last_scope_disarm = 0.0
|
||||
disarmed = mgr._maybe_disarm_orphaned_scopes("unit test")
|
||||
results = {
|
||||
"disarmed": disarmed,
|
||||
"orphan_handle_cleared": orphan._cancel_handle is None,
|
||||
"orphan_tasks_cleared": len(orphan._tasks) == 0,
|
||||
"live_still_armed": live_scope._cancel_handle is not None,
|
||||
"live_task_kept": live_task in live_scope._tasks,
|
||||
"hostless_still_armed": hostless._cancel_handle is not None,
|
||||
"rate_limited_second": mgr._maybe_disarm_orphaned_scopes("again"),
|
||||
}
|
||||
for scope in (live_scope, hostless):
|
||||
if scope._cancel_handle is not None:
|
||||
scope._cancel_handle.cancel()
|
||||
scope._cancel_handle = None
|
||||
scope._tasks.clear()
|
||||
blocker.set()
|
||||
_ = await live_task # synchronization point; failures propagate
|
||||
return results
|
||||
|
||||
r = _run(loop, _arm_and_sweep())
|
||||
assert r["disarmed"] == 1
|
||||
assert r["orphan_handle_cleared"] and r["orphan_tasks_cleared"]
|
||||
assert r["live_still_armed"] and r["live_task_kept"]
|
||||
assert r["hostless_still_armed"]
|
||||
assert r["rate_limited_second"] == 0
|
||||
+45
-11
@@ -18,7 +18,6 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -115,13 +114,31 @@ def running_loop_mgr():
|
||||
# handlers don't fire after pytest has torn its handlers down. Mirrors
|
||||
# the production ``shutdown()`` shape.
|
||||
async def _drain(m: MCPClientManager) -> None:
|
||||
for attr in ("_user_pool_eviction_task", "_user_token_sweep_task"):
|
||||
# ``_static_health_task`` included: since the BaseExceptionGroup
|
||||
# hardening, ``_connect_all`` reliably starts (and keeps alive) the
|
||||
# health loop even when every configured connect fails — a test
|
||||
# that drives ``_connect_all`` must drain it like production
|
||||
# ``shutdown()`` does, or the task is destroyed pending at GC.
|
||||
for attr in (
|
||||
"_user_pool_eviction_task",
|
||||
"_user_token_sweep_task",
|
||||
"_static_health_task",
|
||||
):
|
||||
task = getattr(m, attr)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
await task
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
setattr(m, attr, None)
|
||||
# Close any parked pool transport owners a successful
|
||||
# ``_connect_one_pool`` left installed, mirroring production
|
||||
# ``shutdown()`` — an undrained owner is destroyed pending at GC.
|
||||
for entry in list(m._user_pool_entries.values()):
|
||||
owner = entry.owner_task
|
||||
if owner is not None and not owner.done():
|
||||
if entry.close_requested is not None:
|
||||
entry.close_requested.set()
|
||||
owner.cancel()
|
||||
await asyncio.gather(owner, return_exceptions=True)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_drain(mgr), loop).result(timeout=2)
|
||||
@@ -343,25 +360,42 @@ class TestEviction:
|
||||
assert ("u4", "pool-srv") in mgr._user_pool_entries
|
||||
assert ("u3", "pool-srv") in mgr._user_pool_entries
|
||||
|
||||
def test_eviction_resilient_to_close_errors(self, running_loop_mgr) -> None:
|
||||
def test_eviction_resilient_to_owner_unwind_errors(self, running_loop_mgr) -> None:
|
||||
"""Owner-model successor to the old ``resilient_to_close_errors`` test.
|
||||
|
||||
Teardown reaps the entry's owner through a bounded ``asyncio.wait`` that
|
||||
never re-raises, so even an owner whose in-task unwind raises cannot
|
||||
break eviction. The old failure mode this guarded — a cross-task
|
||||
``stack.aclose()`` raising ``RuntimeError('...different task...')`` — is
|
||||
structurally impossible now: the transport cms live in, and unwind in,
|
||||
the owner task, never the evictor.
|
||||
"""
|
||||
mgr, loop, _ = running_loop_mgr
|
||||
mgr._user_pool_idle_ttl_s = 0.0
|
||||
|
||||
broken_stack = MagicMock(spec=AsyncExitStack)
|
||||
broken_stack.aclose = AsyncMock(side_effect=RuntimeError("close failed"))
|
||||
|
||||
async def _seed() -> None:
|
||||
for i in range(2):
|
||||
entry = await mgr._ensure_pool_entry((f"u{i}", "pool-srv"))
|
||||
key = (f"u{i}", "pool-srv")
|
||||
entry = await mgr._ensure_pool_entry(key)
|
||||
event = asyncio.Event()
|
||||
|
||||
async def _owner(ev: asyncio.Event = event) -> None:
|
||||
await ev.wait()
|
||||
raise RuntimeError("unwind failed")
|
||||
|
||||
owner = asyncio.create_task(_owner(), name=f"mcp-pool-owner-test:{i}")
|
||||
# Retrieve the exception so the raising owner doesn't warn at GC.
|
||||
owner.add_done_callback(lambda t: None if t.cancelled() else t.exception())
|
||||
entry.session = MagicMock()
|
||||
entry.stack = broken_stack
|
||||
entry.owner_task = owner
|
||||
entry.close_requested = event
|
||||
|
||||
_run_on_loop(loop, _seed())
|
||||
|
||||
async def _evict() -> None:
|
||||
await mgr._evict_idle_pool_entries()
|
||||
|
||||
# Eviction must not raise even if close fails.
|
||||
# Eviction must not raise even if the owner's unwind raises.
|
||||
_run_on_loop(loop, _evict())
|
||||
# All entries removed from the dict regardless.
|
||||
assert mgr._user_pool_entries == {}
|
||||
|
||||
@@ -167,6 +167,43 @@ class TestModelRegistry:
|
||||
with pytest.raises(ValueError, match="Unknown model alias"):
|
||||
reg.get_client("nonexistent")
|
||||
|
||||
def test_client_construction_failure_is_value_error(self) -> None:
|
||||
# Environment failures inside SDK construction (e.g. httpx raising
|
||||
# FileNotFoundError for a CA bundle deleted by a venv rebuild) must
|
||||
# surface as ValueError so routes answer 503-with-message instead
|
||||
# of an opaque 500.
|
||||
reg = self._make_registry()
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.model_registry.create_client",
|
||||
side_effect=FileNotFoundError(2, "No such file", "/gone/cacert.pem"),
|
||||
),
|
||||
pytest.raises(ValueError, match="'default'.*FileNotFoundError") as excinfo,
|
||||
):
|
||||
reg.get_client("default")
|
||||
assert isinstance(excinfo.value.__cause__, FileNotFoundError)
|
||||
# The message is echoed in 503 bodies: exception TYPE only — the
|
||||
# raw exception text can embed filesystem paths and must stay in
|
||||
# the server log.
|
||||
assert "/gone/cacert.pem" not in str(excinfo.value)
|
||||
assert "No such file" not in str(excinfo.value)
|
||||
# Nothing half-constructed may be cached — a later call with a
|
||||
# repaired environment must construct for real.
|
||||
assert "default" not in reg._clients
|
||||
|
||||
def test_client_construction_value_error_passes_through(self) -> None:
|
||||
# create_client's own misconfig ValueErrors already carry
|
||||
# remediation text and must not be double-wrapped.
|
||||
reg = self._make_registry()
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.model_registry.create_client",
|
||||
side_effect=ValueError("anthropic-compatible requires base_url"),
|
||||
),
|
||||
pytest.raises(ValueError, match="^anthropic-compatible requires base_url$"),
|
||||
):
|
||||
reg.get_client("default")
|
||||
|
||||
def test_shutdown(self) -> None:
|
||||
reg = self._make_registry()
|
||||
reg.get_client("default")
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
OAuthSSRFError,
|
||||
OAuthSSRFPrivateAddressError,
|
||||
effective_port,
|
||||
is_localhost,
|
||||
validate_discovered_endpoint,
|
||||
@@ -86,6 +87,55 @@ class TestValidateUrlNoSSRF:
|
||||
):
|
||||
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
|
||||
|
||||
def test_private_address_raises_distinct_subclass(self) -> None:
|
||||
# Callers with an operator opt-in (OIDC) catch the subclass to
|
||||
# append the remediation hint; plain OAuthSSRFError catches still work.
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR),
|
||||
pytest.raises(OAuthSSRFPrivateAddressError),
|
||||
):
|
||||
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
|
||||
|
||||
def test_allow_private_accepts_rfc1918(self) -> None:
|
||||
with patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR):
|
||||
parsed = validate_url_no_ssrf(
|
||||
"https://auth.corp.example.com", allow_http=False, allow_private=True
|
||||
)
|
||||
assert parsed.hostname == "auth.corp.example.com"
|
||||
|
||||
def test_allow_private_accepts_cgnat(self) -> None:
|
||||
# 100.64/10 (RFC 6598, shared address space) — e.g. a tailnet-hosted IdP.
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("100.64.0.7", 0))]):
|
||||
validate_url_no_ssrf("https://idp.tail.example", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_accepts_loopback_hostname(self) -> None:
|
||||
# A non-localhost hostname resolving to loopback (IdP behind a
|
||||
# local reverse proxy) is operator-trusted under the opt-in.
|
||||
with patch("socket.getaddrinfo", return_value=self._LOOPBACK_ADDR):
|
||||
validate_url_no_ssrf("https://auth.internal", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_link_local(self) -> None:
|
||||
# Cloud metadata services live on link-local; no legitimate IdP does.
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
|
||||
pytest.raises(OAuthSSRFError, match="refused even with private"),
|
||||
):
|
||||
validate_url_no_ssrf("https://md.example.com", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_unspecified(self) -> None:
|
||||
# The message names the class so 0.0.0.0/:: rejections are unambiguous.
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("0.0.0.0", 0))]),
|
||||
pytest.raises(OAuthSSRFError, match="unspecified"),
|
||||
):
|
||||
validate_url_no_ssrf("https://zero.example.com", allow_http=False, allow_private=True)
|
||||
|
||||
def test_allow_private_does_not_relax_https(self) -> None:
|
||||
with pytest.raises(OAuthSSRFError, match="must use HTTPS"):
|
||||
validate_url_no_ssrf(
|
||||
"http://auth.corp.example.com", allow_http=False, allow_private=True
|
||||
)
|
||||
|
||||
def test_rejects_unresolvable(self) -> None:
|
||||
import socket
|
||||
|
||||
@@ -122,6 +172,19 @@ class TestValidateDiscoveredEndpoint:
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
)
|
||||
|
||||
def test_allow_private_passes_through(self) -> None:
|
||||
# Same-origin endpoint on a private-resolving issuer host is accepted
|
||||
# when the operator opted in.
|
||||
issuer = urllib.parse.urlparse("https://auth.corp.example.com")
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
validate_discovered_endpoint(
|
||||
"https://auth.corp.example.com/token",
|
||||
issuer,
|
||||
allow_http=False,
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
allow_private=True,
|
||||
)
|
||||
|
||||
def test_trusted_endpoint_host_passes(self) -> None:
|
||||
issuer = urllib.parse.urlparse("https://idp.example.com")
|
||||
with patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR):
|
||||
|
||||
@@ -90,6 +90,42 @@ class TestLoadOIDCConfig:
|
||||
assert cfg.scopes == "openid"
|
||||
assert cfg.provider_name == "Okta"
|
||||
|
||||
def test_load_oidc_config_allow_private_network_env(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", "true")
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is True
|
||||
|
||||
def test_load_oidc_config_allow_private_network_toml(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"allow_private_network": True},
|
||||
):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is True
|
||||
|
||||
def test_load_oidc_config_allow_private_network_default_off(self, monkeypatch):
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
cfg = load_oidc_config()
|
||||
|
||||
assert cfg.allow_private_network is False
|
||||
|
||||
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
|
||||
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
|
||||
@@ -345,6 +381,27 @@ class TestValidateIssuerURL:
|
||||
):
|
||||
validate_issuer_url("https://idp.example.com")
|
||||
|
||||
def test_private_address_hint_mentions_opt_in(self):
|
||||
"""The rejection message points the operator at allow_private_network."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
|
||||
pytest.raises(OIDCError, match="allow_private_network"),
|
||||
):
|
||||
validate_issuer_url("https://auth.internal.example")
|
||||
|
||||
def test_allow_private_accepts_private_issuer(self):
|
||||
"""The opt-in accepts an issuer resolving to RFC 1918 space."""
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
validate_issuer_url("https://auth.internal.example", allow_private=True)
|
||||
|
||||
def test_allow_private_still_rejects_link_local(self):
|
||||
"""Link-local (cloud metadata) is refused even with the opt-in."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
|
||||
pytest.raises(OIDCError, match="refused even with private"),
|
||||
):
|
||||
validate_issuer_url("https://md.internal.example", allow_private=True)
|
||||
|
||||
def test_rejects_http_non_localhost(self):
|
||||
"""HTTP is rejected for non-localhost hosts."""
|
||||
with pytest.raises(OIDCError, match="must use HTTPS"):
|
||||
@@ -508,6 +565,20 @@ class TestValidateDiscoveredEndpoint:
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
)
|
||||
|
||||
def test_private_endpoint_hint_mentions_opt_in(self):
|
||||
"""A discovered endpoint resolving private carries the opt-in hint
|
||||
just like the issuer does — the remediation is the same knob."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
|
||||
pytest.raises(OIDCError, match="allow_private_network"),
|
||||
):
|
||||
validate_discovered_endpoint(
|
||||
"https://idp.example.com/token",
|
||||
self._issuer(),
|
||||
allow_http=False,
|
||||
trusted_endpoint_hosts=frozenset(),
|
||||
)
|
||||
|
||||
def test_rejects_http_when_issuer_is_https(self):
|
||||
"""http:// discovered endpoint rejected when issuer is https://."""
|
||||
with (
|
||||
@@ -2166,6 +2237,58 @@ class TestDiscoverOIDC:
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_private_issuer_rejected_by_default(self):
|
||||
"""Without the opt-in, a private-resolving issuer disables OIDC."""
|
||||
config = _make_config(
|
||||
issuer="https://auth.internal.example",
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
|
||||
result = await discover_oidc(config)
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_private_issuer_with_opt_in(self):
|
||||
"""allow_private_network=True lets a private-resolving IdP discover."""
|
||||
config = _make_config(
|
||||
issuer="https://auth.internal.example",
|
||||
allow_private_network=True,
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
discovery_doc = {
|
||||
"authorization_endpoint": "https://auth.internal.example/authorize",
|
||||
"token_endpoint": "https://auth.internal.example/token",
|
||||
"userinfo_endpoint": "https://auth.internal.example/userinfo",
|
||||
"jwks_uri": "https://auth.internal.example/jwks",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = discovery_doc
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is True
|
||||
assert result.token_endpoint == "https://auth.internal.example/token"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_discover_oidc_failure(self):
|
||||
"""Mock httpx error -> enabled=False returned."""
|
||||
config = _make_config(
|
||||
|
||||
@@ -8,6 +8,7 @@ capability-gated emission in ``ChatSession._init_system_messages``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -330,3 +331,38 @@ class TestEmptyUserTurnDrop:
|
||||
assert len(user_turns) == 1
|
||||
assert f"[start system-reminder_{nonce}]" in user_turns[0]["content"]
|
||||
assert "child done" in user_turns[0]["content"]
|
||||
|
||||
|
||||
class TestToolArgumentLegalization:
|
||||
"""``_prepare_wire_messages`` legalizes malformed tool-call ``arguments`` so a
|
||||
strict renderer (vLLM ``deepseek_v4``) can ``json.loads`` every arguments string
|
||||
— the sibling send-time validity pass to orphan repair."""
|
||||
|
||||
def test_unterminated_arguments_legalized_on_the_wire(self) -> None:
|
||||
s = make_session()
|
||||
msgs = [
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "bash", "arguments": '{"command": "cat /va'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "retry with valid JSON"},
|
||||
]
|
||||
out = s._prepare_wire_messages(msgs)
|
||||
emitted = [
|
||||
tc["function"]["arguments"]
|
||||
for m in out
|
||||
if m.get("role") == "assistant"
|
||||
for tc in m.get("tool_calls", [])
|
||||
]
|
||||
assert emitted == ["{}"]
|
||||
assert json.loads(emitted[0]) == {}
|
||||
# Canonical input is untouched — legalization is wire-copy only.
|
||||
assert msgs[1]["tool_calls"][0]["function"]["arguments"] == '{"command": "cat /va'
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.core.output_guard import evaluate_output, merge_guard_display_payload
|
||||
from turnstone.core.output_guard import (
|
||||
evaluate_output,
|
||||
merge_guard_display_payload,
|
||||
redact_credentials,
|
||||
)
|
||||
|
||||
|
||||
class TestBenignOutput:
|
||||
@@ -205,6 +209,73 @@ class TestCredentialLeakage:
|
||||
)
|
||||
assert "credential_leak" not in r.flags
|
||||
|
||||
def test_single_quote_json_secret(self) -> None:
|
||||
# Python dict reprs / JS object literals emit single quotes; these must
|
||||
# be detected and redacted just like the double-quoted JSON form.
|
||||
r = evaluate_output("headers = {'Authorization': 'Bearer canstillseethis'}")
|
||||
assert "credential_leak" in r.flags
|
||||
assert "json_secret_leak" in r.flags
|
||||
assert r.sanitized is not None
|
||||
assert "canstillseethis" not in r.sanitized
|
||||
|
||||
def test_single_quote_password(self) -> None:
|
||||
r = evaluate_output("{'password': 'hunter2hunter2'}")
|
||||
assert "json_secret_leak" in r.flags
|
||||
assert r.sanitized is not None
|
||||
assert "hunter2hunter2" not in r.sanitized
|
||||
|
||||
def test_mongodb_srv_connection_string(self) -> None:
|
||||
r = evaluate_output("uri: mongodb+srv://admin:s3cretpw@cluster.mongodb.net/db")
|
||||
assert "connection_string_leak" in r.flags
|
||||
assert r.sanitized is not None
|
||||
assert "s3cretpw" not in r.sanitized
|
||||
|
||||
def test_rediss_connection_string(self) -> None:
|
||||
r = evaluate_output("rediss://user:s3cretpw@redis.host:6380/0")
|
||||
assert "connection_string_leak" in r.flags
|
||||
assert r.sanitized is not None
|
||||
assert "s3cretpw" not in r.sanitized
|
||||
|
||||
def test_sqlalchemy_driver_connection_string(self) -> None:
|
||||
# SQLAlchemy dialect+driver URLs must match — the bare-dialect
|
||||
# list alone leaked these (only +psycopg was enumerated).
|
||||
for url in (
|
||||
"postgresql+psycopg2://admin:s3cret_pass@db.internal:5432/prod",
|
||||
"postgresql+asyncpg://admin:s3cret_pass@db.internal/prod",
|
||||
"mysql+pymysql://admin:s3cret_pass@db.internal/prod",
|
||||
):
|
||||
r = evaluate_output(url)
|
||||
assert "connection_string_leak" in r.flags, url
|
||||
assert r.sanitized is not None, url
|
||||
assert "s3cret_pass" not in r.sanitized, url
|
||||
assert ":[REDACTED:password]@" in r.sanitized, url
|
||||
|
||||
def test_uppercase_scheme_connection_string(self) -> None:
|
||||
# RFC 3986 schemes are case-insensitive; an uppercase scheme must
|
||||
# not bypass redaction.
|
||||
for url in (
|
||||
"POSTGRESQL+PSYCOPG2://admin:s3cret_pass@db.internal/prod",
|
||||
"HTTPS://admin:s3cret_pass@api.internal/x",
|
||||
):
|
||||
r = evaluate_output(url)
|
||||
assert "connection_string_leak" in r.flags, url
|
||||
assert r.sanitized is not None, url
|
||||
assert "s3cret_pass" not in r.sanitized, url
|
||||
|
||||
def test_bearer_scheme_case_insensitive(self) -> None:
|
||||
# RFC 7235 scheme name is case-insensitive.
|
||||
r = evaluate_output("authorization: bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig12345")
|
||||
assert "credential_leak" in r.flags
|
||||
|
||||
def test_prefixed_key_assignment_redacts_whole_token(self) -> None:
|
||||
# api_key=/secret_key=/access_token= must redact the entire assignment,
|
||||
# not chew only the tail into a garbled "api_[REDACTED:api_key]".
|
||||
secret = "abcdefghijklmnopqrstuvwxyz"
|
||||
for prefix in ("api_key", "secret_key", "session_key", "access_token", "key", "token"):
|
||||
out = redact_credentials(f"{prefix}={secret}")
|
||||
assert secret not in out, (prefix, out)
|
||||
assert out == "[REDACTED:api_key]", (prefix, out)
|
||||
|
||||
|
||||
class TestEncodedPayloads:
|
||||
"""Detect encoded/obfuscated payloads."""
|
||||
|
||||
@@ -1329,3 +1329,180 @@ class TestCreateStampsPersona:
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session._persona_name == ""
|
||||
assert not ws.persona
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guard 10 — discovery: the calling LLM is TOLD which personas exist. The
|
||||
# live enabled interactive-kind list rides the `persona` parameter
|
||||
# description of task_agent / spawn_workstream / spawn_batch, rebuilt from
|
||||
# the pristine TOOLS base on every render; storage-less sessions keep the
|
||||
# base text untouched. Resolution is forgiving (case, unique display name)
|
||||
# but everything downstream carries the canonical slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _persona_desc(session: ChatSession, tool_name: str) -> str:
|
||||
tool = next(t for t in session._tools if t.get("function", {}).get("name") == tool_name)
|
||||
prop = ChatSession._persona_property(tool["function"]["parameters"]["properties"])
|
||||
assert prop is not None, f"{tool_name} has no persona parameter"
|
||||
return prop["description"]
|
||||
|
||||
|
||||
def _pristine_persona_desc(tool_name: str) -> str:
|
||||
from turnstone.core.tools import TOOLS
|
||||
|
||||
tool = next(t for t in TOOLS if t["function"]["name"] == tool_name)
|
||||
prop = ChatSession._persona_property(tool["function"]["parameters"]["properties"])
|
||||
assert prop is not None, f"{tool_name} has no persona parameter"
|
||||
return prop["description"]
|
||||
|
||||
|
||||
class TestPersonaDiscovery:
|
||||
def _seed(self) -> None:
|
||||
get_storage().create_persona(
|
||||
{
|
||||
"persona_id": "p-eng",
|
||||
"name": "engineer",
|
||||
"display_name": "Engineer",
|
||||
"description": "Default engineering identity",
|
||||
"base_prompt": "E",
|
||||
"applies_to_kinds": ["interactive"],
|
||||
"is_default": True,
|
||||
}
|
||||
)
|
||||
get_storage().create_persona(
|
||||
{
|
||||
"persona_id": "p-wri",
|
||||
"name": "writer",
|
||||
"display_name": "Creative Writer",
|
||||
"description": "Prose-first writing partner",
|
||||
"base_prompt": "W",
|
||||
"applies_to_kinds": ["interactive"],
|
||||
}
|
||||
)
|
||||
|
||||
def _coord_session(self, mock_openai_client: Any) -> ChatSession:
|
||||
return _session(
|
||||
mock_openai_client,
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
user_id="u1",
|
||||
coord_client=MagicMock(),
|
||||
)
|
||||
|
||||
def test_task_agent_description_lists_personas(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
session = _session(mock_openai_client)
|
||||
desc = _persona_desc(session, "task_agent")
|
||||
assert desc.startswith(_pristine_persona_desc("task_agent"))
|
||||
assert "Available personas:" in desc
|
||||
# Default first, then A→Z, each with its one-line description.
|
||||
assert desc.index("`engineer` (default)") < desc.index("`writer`")
|
||||
assert "Prose-first writing partner" in desc
|
||||
|
||||
def test_spawn_tools_list_personas_for_coordinators(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
session = self._coord_session(mock_openai_client)
|
||||
for tool_name in ("spawn_workstream", "spawn_batch"):
|
||||
desc = _persona_desc(session, tool_name)
|
||||
assert desc.startswith(_pristine_persona_desc(tool_name))
|
||||
assert "Available personas:" in desc
|
||||
assert "`engineer` (default)" in desc
|
||||
|
||||
def test_coordinator_kind_personas_are_not_offered(self, tmp_db, mock_openai_client) -> None:
|
||||
# Children and sub-agents are always interactive-kind; a
|
||||
# coordinator-only persona in the list would be a guaranteed error.
|
||||
self._seed()
|
||||
get_storage().create_persona(
|
||||
{
|
||||
"persona_id": "p-exe",
|
||||
"name": "executive",
|
||||
"base_prompt": "X",
|
||||
"applies_to_kinds": ["coordinator"],
|
||||
}
|
||||
)
|
||||
session = self._coord_session(mock_openai_client)
|
||||
assert "`executive`" not in _persona_desc(session, "spawn_workstream")
|
||||
|
||||
def test_storage_down_keeps_pristine_base(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
with patch("turnstone.core.storage.is_storage_initialized", return_value=False):
|
||||
session = _session(mock_openai_client)
|
||||
assert _persona_desc(session, "task_agent") == _pristine_persona_desc("task_agent")
|
||||
|
||||
def test_rerender_is_idempotent_and_tracks_archive(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
session = _session(mock_openai_client)
|
||||
session._render_agent_tool_descriptions()
|
||||
session._render_agent_tool_descriptions()
|
||||
desc = _persona_desc(session, "task_agent")
|
||||
assert desc.count("Available personas:") == 1
|
||||
# Archive one persona; the next render must drop it, not append.
|
||||
storage = get_storage()
|
||||
writer = storage.get_persona_by_name("writer")
|
||||
assert writer is not None
|
||||
storage.update_persona(writer["persona_id"], enabled=False)
|
||||
session._render_agent_tool_descriptions()
|
||||
desc = _persona_desc(session, "task_agent")
|
||||
assert "`writer`" not in desc
|
||||
assert desc.count("Available personas:") == 1
|
||||
|
||||
def test_large_shelf_drops_prose_keeps_every_name(self, tmp_db, mock_openai_client) -> None:
|
||||
storage = get_storage()
|
||||
for i in range(26):
|
||||
storage.create_persona(
|
||||
{
|
||||
"persona_id": f"p-{i:02d}",
|
||||
"name": f"persona-{i:02d}",
|
||||
"description": "UNIQUE-PROSE-MARKER",
|
||||
"base_prompt": "x",
|
||||
"applies_to_kinds": ["interactive"],
|
||||
}
|
||||
)
|
||||
session = _session(mock_openai_client)
|
||||
desc = _persona_desc(session, "task_agent")
|
||||
for i in range(26):
|
||||
assert f"`persona-{i:02d}`" in desc
|
||||
assert "UNIQUE-PROSE-MARKER" not in desc
|
||||
|
||||
def test_spawn_forgives_case_and_display_name_but_stamps_slug(
|
||||
self, tmp_db, mock_openai_client
|
||||
) -> None:
|
||||
self._seed()
|
||||
session = self._coord_session(mock_openai_client)
|
||||
for variant in ("WRITER", "Writer", "Creative Writer"):
|
||||
item = session._prepare_spawn_workstream("c1", {"persona": variant})
|
||||
assert not item.get("error"), item.get("error")
|
||||
assert item["persona"] == "writer"
|
||||
|
||||
def test_spawn_batch_rows_land_on_canonical_slug(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
session = self._coord_session(mock_openai_client)
|
||||
item = session._prepare_spawn_batch(
|
||||
"c1",
|
||||
{
|
||||
"children": [
|
||||
{"initial_message": "a", "persona": "WRITER"},
|
||||
{"initial_message": "b", "persona": "Creative Writer"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert not item.get("error"), item.get("error")
|
||||
personas = [c["persona"] for c in item["children"] if "_error" not in c]
|
||||
assert personas == ["writer", "writer"]
|
||||
|
||||
def test_task_agent_prep_canonicalizes_header_and_stamp(
|
||||
self, tmp_db, mock_openai_client
|
||||
) -> None:
|
||||
self._seed()
|
||||
session = _session(mock_openai_client)
|
||||
item = session._prepare_task("t1", {"prompt": "go", "persona": "Writer"})
|
||||
assert not item.get("error"), item.get("error")
|
||||
assert item["persona"] == "writer"
|
||||
assert "persona: writer" in item["header"]
|
||||
|
||||
def test_unknown_persona_error_enumerates_live_names(self, tmp_db, mock_openai_client) -> None:
|
||||
self._seed()
|
||||
session = self._coord_session(mock_openai_client)
|
||||
item = session._prepare_spawn_workstream("c1", {"persona": "nope"})
|
||||
assert item.get("error")
|
||||
assert "Available for interactive: engineer (default), writer" in item["error"]
|
||||
|
||||
@@ -125,3 +125,191 @@ class TestConfigParsing:
|
||||
cfg["persona_memory"] = "True"
|
||||
with pytest.raises(ValueError, match="persona_memory"):
|
||||
snapshot_from_config(cfg)
|
||||
|
||||
|
||||
class _FakeStorage:
|
||||
"""Minimal storage double for resolve tests — exact-name index + list."""
|
||||
|
||||
def __init__(self, rows: list[dict]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def get_persona_by_name(self, name: str) -> dict | None:
|
||||
return next((dict(r) for r in self._rows if r["name"] == name), None)
|
||||
|
||||
def list_personas(self, include_disabled: bool = False) -> list[dict]:
|
||||
return [dict(r) for r in self._rows if include_disabled or r.get("enabled")]
|
||||
|
||||
|
||||
def _rows() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": "engineer",
|
||||
"display_name": "Engineer",
|
||||
"enabled": True,
|
||||
"is_default": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
{
|
||||
"name": "writer",
|
||||
"display_name": "Creative Writer",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
{
|
||||
"name": "executive",
|
||||
"display_name": "Executive",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["coordinator"],
|
||||
},
|
||||
{
|
||||
"name": "retired",
|
||||
"display_name": "Retired Persona",
|
||||
"enabled": False,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestForgivingResolution:
|
||||
"""resolve_persona_for_kind — one shared rule, forgiving on all surfaces.
|
||||
|
||||
Exact slug first, then the lowercased input, then a UNIQUE
|
||||
case-insensitive display-name match; every failure enumerates the
|
||||
kind's live names (the self-correction path for stale tool
|
||||
descriptions), and callers stamp the returned row's canonical slug.
|
||||
"""
|
||||
|
||||
def _resolve(self, name: str, kind: str = "interactive", rows: list[dict] | None = None):
|
||||
from turnstone.core.personas import resolve_persona_for_kind
|
||||
|
||||
return resolve_persona_for_kind(_FakeStorage(rows or _rows()), name, kind)
|
||||
|
||||
def test_exact_slug_resolves(self) -> None:
|
||||
row, err = self._resolve("writer")
|
||||
assert err == "" and row is not None and row["name"] == "writer"
|
||||
|
||||
def test_case_variants_resolve_to_canonical_row(self) -> None:
|
||||
for variant in ("Writer", "WRITER", " writer "):
|
||||
row, err = self._resolve(variant)
|
||||
assert err == "" and row is not None and row["name"] == "writer"
|
||||
|
||||
def test_unique_display_name_resolves_to_slug(self) -> None:
|
||||
for variant in ("Creative Writer", "creative writer"):
|
||||
row, err = self._resolve(variant)
|
||||
assert err == "" and row is not None and row["name"] == "writer"
|
||||
|
||||
def test_ambiguous_display_name_names_the_candidates(self) -> None:
|
||||
rows = _rows() + [
|
||||
{
|
||||
"name": "novelist",
|
||||
"display_name": "creative writer",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
}
|
||||
]
|
||||
row, err = self._resolve("Creative Writer", rows=rows)
|
||||
assert row is None
|
||||
assert "more than one display name" in err
|
||||
assert "novelist" in err and "writer" in err
|
||||
assert "use the exact name" in err
|
||||
|
||||
def test_same_display_name_across_kinds_resolves_per_kind(self) -> None:
|
||||
# The label the caller saw came from a kind-filtered surface, so a
|
||||
# same-label persona of the OTHER kind must neither block (spurious
|
||||
# ambiguity) nor win (cross-kind resolution).
|
||||
rows = _rows() + [
|
||||
{
|
||||
"name": "helper-coord",
|
||||
"display_name": "Helper",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["coordinator"],
|
||||
},
|
||||
{
|
||||
"name": "helper-int",
|
||||
"display_name": "Helper",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
]
|
||||
row, err = self._resolve("Helper", rows=rows)
|
||||
assert err == "" and row is not None and row["name"] == "helper-int"
|
||||
row, err = self._resolve("Helper", kind="coordinator", rows=rows)
|
||||
assert err == "" and row is not None and row["name"] == "helper-coord"
|
||||
|
||||
def test_wrong_kind_display_match_is_not_found_with_choices(self) -> None:
|
||||
# Display names are labels, not identifiers: a label that only exists
|
||||
# on another kind's persona reads as unknown for THIS kind (with the
|
||||
# kind's live choices attached) — never as a cross-kind resolution.
|
||||
rows = _rows() + [
|
||||
{
|
||||
"name": "chief",
|
||||
"display_name": "The Chief",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["coordinator"],
|
||||
}
|
||||
]
|
||||
row, err = self._resolve("The Chief", rows=rows)
|
||||
assert row is None
|
||||
assert "not found or disabled" in err
|
||||
assert "Available for interactive: engineer (default), writer" in err
|
||||
|
||||
def test_whitespace_input_never_matches_blank_display_names(self) -> None:
|
||||
# display_name defaults to "" — a whitespace-only input (reachable via
|
||||
# CLI `--persona " "`) must read as unknown, never resolve to a
|
||||
# blank-labelled persona or report a bogus ambiguity.
|
||||
rows = _rows() + [
|
||||
{
|
||||
"name": "unlabelled",
|
||||
"display_name": "",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
{
|
||||
"name": "unlabelled-too",
|
||||
"display_name": " ",
|
||||
"enabled": True,
|
||||
"applies_to_kinds": ["interactive"],
|
||||
},
|
||||
]
|
||||
for raw in ("", " ", " "):
|
||||
row, err = self._resolve(raw, rows=rows)
|
||||
assert row is None
|
||||
assert "not found or disabled" in err
|
||||
assert "more than one display name" not in err
|
||||
|
||||
def test_unknown_error_lists_kind_names_default_first(self) -> None:
|
||||
row, err = self._resolve("nope")
|
||||
assert row is None
|
||||
assert "Persona not found or disabled: 'nope'" in err
|
||||
assert "Available for interactive: engineer (default), writer" in err
|
||||
assert "executive" not in err # wrong kind
|
||||
assert "retired" not in err # disabled
|
||||
|
||||
def test_kind_mismatch_reports_canonical_slug_and_choices(self) -> None:
|
||||
row, err = self._resolve("Executive") # case-forgiven, then kind-refused
|
||||
assert row is None
|
||||
assert "'executive' does not apply to kind 'interactive'" in err
|
||||
assert "Available for interactive: engineer (default), writer" in err
|
||||
|
||||
def test_disabled_persona_is_not_resolvable_by_any_route(self) -> None:
|
||||
for variant in ("retired", "RETIRED", "Retired Persona"):
|
||||
row, err = self._resolve(variant)
|
||||
assert row is None
|
||||
assert "not found or disabled" in err
|
||||
|
||||
def test_storage_none_is_a_distinct_error(self) -> None:
|
||||
from turnstone.core.personas import resolve_persona_for_kind
|
||||
|
||||
row, err = resolve_persona_for_kind(None, "writer", "interactive")
|
||||
assert row is None and err == "persona storage unavailable"
|
||||
|
||||
def test_listing_failure_degrades_to_plain_error(self) -> None:
|
||||
class _Broken(_FakeStorage):
|
||||
def list_personas(self, include_disabled: bool = False) -> list[dict]:
|
||||
raise RuntimeError("db gone")
|
||||
|
||||
from turnstone.core.personas import resolve_persona_for_kind
|
||||
|
||||
row, err = resolve_persona_for_kind(_Broken(_rows()), "nope", "interactive")
|
||||
assert row is None
|
||||
assert "Persona not found or disabled: 'nope'" in err
|
||||
|
||||
@@ -179,10 +179,10 @@ def test_bulk_live_admin_bypass_returns_live(storage):
|
||||
|
||||
|
||||
def test_bulk_live_cluster_wide_visibility(storage):
|
||||
"""Trusted-team visibility: any ``admin.cluster.inspect`` caller
|
||||
sees every row in ``results``. ``denied`` is reserved for ids
|
||||
that don't correspond to a persisted workstream (no existence
|
||||
oracle for unknown ids)."""
|
||||
"""A project-less workstream has no tenancy to enforce, so any
|
||||
``admin.cluster.inspect`` caller sees it in ``results``. ``denied``
|
||||
is reserved for ids that don't correspond to a persisted workstream
|
||||
(no existence oracle for unknown ids)."""
|
||||
ws_id = "b" * 32
|
||||
_seed_workstream(storage, ws_id=ws_id, node_id="node-a", user_id="stranger")
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage))
|
||||
@@ -196,6 +196,48 @@ def test_bulk_live_cluster_wide_visibility(storage):
|
||||
assert body["denied"] == []
|
||||
|
||||
|
||||
def test_bulk_live_private_project_row_routes_to_denied(storage):
|
||||
"""A workstream in a private project the caller isn't a member of
|
||||
routes to ``denied``, not ``results`` — a cluster admin gets no
|
||||
private-project oracle from the bulk surface either."""
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
ws_id = "c" * 32
|
||||
storage.register_workstream(ws_id, node_id="node-a", user_id="alice", project_id="proj-secret")
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage))
|
||||
resp = client.get(
|
||||
f"/v1/api/cluster/ws/live?ids={ws_id}",
|
||||
headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.cluster.inspect"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["results"] == {}
|
||||
assert body["denied"] == [ws_id]
|
||||
|
||||
|
||||
def test_bulk_live_private_project_row_visible_to_member(storage):
|
||||
"""A project member sees the row (routes to ``results``); the live
|
||||
block is null only because the coordinator row isn't loaded."""
|
||||
storage.create_project("proj-secret", "Secret", "alice")
|
||||
storage.add_project_member("proj-secret", "member-bob")
|
||||
ws_id = "c" * 32
|
||||
storage.register_workstream(
|
||||
ws_id,
|
||||
node_id="console",
|
||||
user_id="alice",
|
||||
kind="coordinator",
|
||||
project_id="proj-secret",
|
||||
)
|
||||
client = _make_client(storage, coord_mgr=_build_mgr(storage))
|
||||
resp = client.get(
|
||||
f"/v1/api/cluster/ws/live?ids={ws_id}",
|
||||
headers={"X-Test-User": "member-bob", "X-Test-Perms": "admin.cluster.inspect"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert ws_id in body["results"]
|
||||
assert body["denied"] == []
|
||||
|
||||
|
||||
def test_bulk_live_unknown_ids_route_to_denied(storage):
|
||||
"""Unknown ids (not in storage) land in ``denied`` so the endpoint
|
||||
can't be used as an existence oracle."""
|
||||
|
||||
@@ -127,10 +127,14 @@ class TestWsVisiblePredicate:
|
||||
assert storage.get_project.call_count == 1
|
||||
|
||||
def test_for_request_bypass_rules(self) -> None:
|
||||
# Only service scope bypasses (node→console machine plumbing,
|
||||
# re-filtered per-user at the console edge).
|
||||
assert WorkstreamProjectVisibility.for_request(
|
||||
_request_for("bob", scopes=("service",))
|
||||
)._bypass
|
||||
assert WorkstreamProjectVisibility.for_request(
|
||||
# admin.cluster.inspect gates the inspect *surfaces* but does NOT
|
||||
# bypass private-project tenancy — the admin filters as themselves.
|
||||
assert not WorkstreamProjectVisibility.for_request(
|
||||
_request_for("bob", permissions=("admin.cluster.inspect",))
|
||||
)._bypass
|
||||
assert not WorkstreamProjectVisibility.for_request(_request_for("bob"))._bypass
|
||||
@@ -214,15 +218,17 @@ class TestResolveWorkstreamOwnerProjectGate:
|
||||
assert err is None
|
||||
assert owner == "bob"
|
||||
|
||||
def test_admin_inspect_bypasses(self, tmp_db: str) -> None:
|
||||
def test_admin_inspect_does_not_bypass(self, tmp_db: str) -> None:
|
||||
# A permitted admin (admin.cluster.inspect) who isn't the owner /
|
||||
# creator / member of a private project is still 403'd at the row
|
||||
# gate — the permission gates the inspect surface, not the tenancy.
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
self._seed(member=False)
|
||||
owner, err = resolve_workstream_owner(
|
||||
_request_for("bob", permissions=("admin.cluster.inspect",)), "ws-priv"
|
||||
)
|
||||
assert err is None
|
||||
assert owner == "alice"
|
||||
assert err is not None and err.status_code == 403
|
||||
|
||||
def test_missing_ws_still_404s(self, tmp_db: str) -> None:
|
||||
from turnstone.core.web_helpers import resolve_workstream_owner
|
||||
|
||||
@@ -57,6 +57,39 @@ def _auth(
|
||||
return {"Authorization": f"Bearer {_make_jwt(user, scopes=scopes, permissions=permissions)}"}
|
||||
|
||||
|
||||
class TestAssignableScopes:
|
||||
"""``service`` scope is a cross-tenant bypass and must never be
|
||||
GRANTED via a user-facing token mint (admin API or CLI) — otherwise an
|
||||
``admin.users`` holder could self-mint it and see every private
|
||||
project's workstreams. Both mint paths route through
|
||||
:func:`reject_unassignable_scopes`."""
|
||||
|
||||
def test_service_scope_rejected(self) -> None:
|
||||
from turnstone.core.auth import reject_unassignable_scopes
|
||||
|
||||
assert reject_unassignable_scopes("service") is not None
|
||||
assert reject_unassignable_scopes("read,service") is not None
|
||||
assert reject_unassignable_scopes("read,write,approve,service") is not None
|
||||
|
||||
def test_service_not_in_assignable_set(self) -> None:
|
||||
from turnstone.core.auth import ASSIGNABLE_SCOPES, VALID_SCOPES
|
||||
|
||||
assert "service" in VALID_SCOPES # still a valid runtime scope
|
||||
assert "service" not in ASSIGNABLE_SCOPES # but not user-assignable
|
||||
|
||||
def test_ordinary_scopes_accepted(self) -> None:
|
||||
from turnstone.core.auth import reject_unassignable_scopes
|
||||
|
||||
assert reject_unassignable_scopes("read") is None
|
||||
assert reject_unassignable_scopes("read,write,approve") is None
|
||||
|
||||
def test_empty_and_unknown_rejected(self) -> None:
|
||||
from turnstone.core.auth import reject_unassignable_scopes
|
||||
|
||||
assert reject_unassignable_scopes("") is not None
|
||||
assert reject_unassignable_scopes("bogus") is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FakeUI / FakeSession doubles — match the shape the create handler expects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -49,6 +49,7 @@ _ESM_BUNDLES = [
|
||||
_SHARED / "composer_queue.js",
|
||||
_SHARED / "interactive.js",
|
||||
_SHARED / "conversation.js",
|
||||
_SHARED / "redact_credentials.js",
|
||||
]
|
||||
|
||||
# Sink scan: everything except renderer.js — the one sanctioned HTML-string
|
||||
@@ -68,6 +69,7 @@ _ESM_NO_VAR_BUNDLES = [
|
||||
_SHARED / "auth.js",
|
||||
_SHARED / "interactive.js",
|
||||
_SHARED / "conversation.js",
|
||||
_SHARED / "redact_credentials.js",
|
||||
]
|
||||
|
||||
# The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py``
|
||||
@@ -444,7 +446,8 @@ def test_shell_bridges_setrowbadge_for_classic_subsystems() -> None:
|
||||
badge the same way the gear deletion did)."""
|
||||
body = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'setRowBadge } from "./rail.js"' in body, "shell must import setRowBadge from rail.js"
|
||||
assert "notifySessionClosed, setRowBadge }" in body, (
|
||||
ts_shell = body[body.index("window.TS_SHELL = {") :][:200]
|
||||
assert "setRowBadge" in ts_shell, (
|
||||
"TS_SHELL must expose setRowBadge for classic subsystems (the consent-badge bridge)"
|
||||
)
|
||||
|
||||
@@ -1011,7 +1014,8 @@ def test_shell_closes_pane_on_ws_closed() -> None:
|
||||
assert 'pm.getPane("interactive", wsId)' in shell
|
||||
assert "if (p) pm.close(p.id)" in shell, "ws_closed closes the pane, not mark-dead"
|
||||
assert "showDeadBanner" in shell, "the banner lane must survive for non-closed deaths"
|
||||
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge }" in shell, (
|
||||
ts_shell = shell[shell.index("window.TS_SHELL = {") :][:200]
|
||||
assert "panes: pm" in ts_shell and "notifySessionClosed" in ts_shell, (
|
||||
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
|
||||
)
|
||||
app = _CONSOLE_APP.read_text(encoding="utf-8")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import workstreams
|
||||
@@ -576,6 +577,48 @@ class TestSearch:
|
||||
results = backend.search_history_recent(limit=1)
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_history_survives_oversized_row(self, backend):
|
||||
# A multi-MB row of mostly-unique words: on PostgreSQL its full
|
||||
# tsvector exceeds the 1MB hard limit, which used to abort every
|
||||
# search_history scan ("string is too long for tsvector") — one
|
||||
# giant tool dump silently killed history recall entirely.
|
||||
backend.register_workstream("s1")
|
||||
giant = "gargantuan beacon " + " ".join(f"w{i}" for i in range(300_000))
|
||||
assert len(giant) > 2_000_000
|
||||
backend.save_message("s1", "tool", giant)
|
||||
backend.save_message("s1", "user", "hello world")
|
||||
|
||||
results = backend.search_history("hello")
|
||||
assert any("hello" in str(r[3]) for r in results)
|
||||
|
||||
# The oversized row itself stays findable by its head.
|
||||
results = backend.search_history("gargantuan beacon")
|
||||
assert any("gargantuan" in str(r[3]) for r in results)
|
||||
|
||||
def test_search_history_fts_error_falls_back_to_ilike(self, request, backend, monkeypatch):
|
||||
# PostgreSQL only: a failed FTS statement aborts the connection's
|
||||
# autobegun transaction, and the ILIKE fallback runs on that same
|
||||
# connection — without a rollback first it dies with
|
||||
# InFailedSqlTransaction instead of returning results.
|
||||
if request.config.getoption("--storage-backend") != "postgresql":
|
||||
pytest.skip("exercises PostgreSQL aborted-transaction fallback")
|
||||
|
||||
backend.register_workstream("s1")
|
||||
backend.save_message("s1", "user", "hello fallback world")
|
||||
|
||||
real_execute = sa.engine.Connection.execute
|
||||
|
||||
def failing_fts_execute(self, statement, *args, **kwargs):
|
||||
if "to_tsvector" in str(statement):
|
||||
# A genuine server-side error, so the transaction is aborted
|
||||
# exactly as when to_tsvector rejects a row.
|
||||
return real_execute(self, sa.text("SELECT 1/0"))
|
||||
return real_execute(self, statement, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(sa.engine.Connection, "execute", failing_fts_execute)
|
||||
results = backend.search_history("fallback")
|
||||
assert any("fallback" in str(r[3]) for r in results)
|
||||
|
||||
|
||||
# -- Workstream operations -----------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.7.0rc1"
|
||||
__version__ = "1.7.1"
|
||||
|
||||
+20
-3
@@ -78,7 +78,13 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
|
||||
print(f" Name: {args.name}")
|
||||
|
||||
if args.token:
|
||||
from turnstone.core.auth import reject_unassignable_scopes
|
||||
|
||||
scopes = args.scopes or "read,write,approve"
|
||||
scope_err = reject_unassignable_scopes(scopes)
|
||||
if scope_err is not None:
|
||||
print(f"Error: {scope_err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raw = generate_token()
|
||||
tid = uuid.uuid4().hex
|
||||
storage.create_api_token(
|
||||
@@ -96,7 +102,12 @@ def _cmd_create_user(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def _cmd_create_token(args: argparse.Namespace) -> None:
|
||||
from turnstone.core.auth import generate_token, hash_token, token_prefix
|
||||
from turnstone.core.auth import (
|
||||
generate_token,
|
||||
hash_token,
|
||||
reject_unassignable_scopes,
|
||||
token_prefix,
|
||||
)
|
||||
|
||||
storage = _get_storage(args)
|
||||
|
||||
@@ -104,6 +115,12 @@ def _cmd_create_token(args: argparse.Namespace) -> None:
|
||||
print(f"Error: user {args.user} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
scopes = args.scopes or "read,write,approve"
|
||||
scope_err = reject_unassignable_scopes(scopes)
|
||||
if scope_err is not None:
|
||||
print(f"Error: {scope_err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
expires = None
|
||||
if args.expires_days:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -120,12 +137,12 @@ def _cmd_create_token(args: argparse.Namespace) -> None:
|
||||
token_prefix=token_prefix(raw),
|
||||
user_id=args.user,
|
||||
name=args.name or "",
|
||||
scopes=args.scopes,
|
||||
scopes=scopes,
|
||||
expires=expires,
|
||||
)
|
||||
print(f"Token: {raw}")
|
||||
print(f" ID: {tid}")
|
||||
print(f" Scopes: {args.scopes}")
|
||||
print(f" Scopes: {scopes}")
|
||||
if expires:
|
||||
print(f" Expires: {expires}")
|
||||
print(" (Save this token now — it cannot be retrieved again)")
|
||||
|
||||
@@ -1572,9 +1572,12 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
'``kind="coordinator"`` rows), and the tail of the message '
|
||||
"history. Gated on the ``admin.cluster.inspect`` permission "
|
||||
"(granted to ``builtin-admin`` via migration 040; revoke or "
|
||||
"reassign to a custom role for tighter control). ``live`` "
|
||||
"is null on node unreachability / 5xx so callers can degrade "
|
||||
"gracefully."
|
||||
"reassign to a custom role for tighter control). A workstream "
|
||||
"attached to a *private* project stays confidential to its "
|
||||
"members: a permitted caller who isn't its owner / creator / "
|
||||
"project member gets a 404 (same masking as an unknown id). "
|
||||
"``live`` is null on node unreachability / 5xx so callers can "
|
||||
"degrade gracefully."
|
||||
),
|
||||
response_model=ClusterWsDetailResponse,
|
||||
query_params=[
|
||||
|
||||
+151
-34
@@ -1323,7 +1323,7 @@ async def cluster_ws_detail(request: Request) -> JSONResponse:
|
||||
404 masks ownership failures (match :func:`make_detail_handler`).
|
||||
Correlation-id masks unexpected exceptions in the merge path.
|
||||
"""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility, require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
err = require_permission(request, "admin.cluster.inspect")
|
||||
@@ -1337,6 +1337,12 @@ async def cluster_ws_detail(request: Request) -> JSONResponse:
|
||||
if not _VALID_WS_ID_RE.match(ws_id):
|
||||
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
|
||||
|
||||
# ``admin.cluster.inspect`` gates the surface, but a workstream attached
|
||||
# to a private project stays confidential to its members — a permitted
|
||||
# admin who isn't the owner/creator/member sees a 404, same existence
|
||||
# masking as an unknown ws_id below (no private-project oracle).
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
|
||||
# Accept either ``?limit=`` (the canonical name used by
|
||||
# the lifted history factory and the list_workstreams tool) or the
|
||||
# transitional ``?message_limit=`` from earlier phase-3 drafts.
|
||||
@@ -1378,6 +1384,14 @@ async def cluster_ws_detail(request: Request) -> JSONResponse:
|
||||
if row is None:
|
||||
return JSONResponse({"error": "workstream not found"}, status_code=404)
|
||||
|
||||
# Private-project tenancy — the memoized predicate may resolve a project
|
||||
# row + membership from storage, so judge it off the event loop.
|
||||
ws_visible = await asyncio.to_thread(
|
||||
visibility.ws_visible, row.get("project_id") or "", row.get("user_id") or ""
|
||||
)
|
||||
if not ws_visible:
|
||||
return JSONResponse({"error": "workstream not found"}, status_code=404)
|
||||
|
||||
try:
|
||||
live = await _fetch_live_block(request, row, ws_id)
|
||||
except Exception:
|
||||
@@ -1430,14 +1444,15 @@ async def cluster_ws_live_bulk(request: Request) -> JSONResponse:
|
||||
``cluster_ws_detail`` so node-dashboard cache behaviour, coordinator
|
||||
in-process snapshots, and ownership masking stay consistent.
|
||||
|
||||
Permission + ownership semantics match ``cluster_ws_detail``:
|
||||
gated on ``admin.cluster.inspect`` and rows the caller doesn't
|
||||
own surface in ``denied`` rather than ``results`` (so the endpoint
|
||||
can't be used as an existence oracle). Missing ids also route to
|
||||
Permission + tenancy semantics match ``cluster_ws_detail``:
|
||||
gated on ``admin.cluster.inspect``, and rows the caller can't see —
|
||||
private-project workstreams they don't own / aren't a member of —
|
||||
surface in ``denied`` rather than ``results`` (so the endpoint can't
|
||||
be used as a private-project oracle). Missing ids also route to
|
||||
``denied`` for the same reason. ``ids`` over the cap is truncated
|
||||
with ``truncated=true`` so the model / frontend knows to paginate.
|
||||
"""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility, require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
err = require_permission(request, "admin.cluster.inspect")
|
||||
@@ -1446,6 +1461,7 @@ async def cluster_ws_live_bulk(request: Request) -> JSONResponse:
|
||||
storage, err503 = require_storage_or_503(request)
|
||||
if err503 is not None:
|
||||
return err503
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
|
||||
raw_ids = request.query_params.get("ids", "") or ""
|
||||
# Split on comma; strip whitespace; drop empty / invalid entries.
|
||||
@@ -1484,17 +1500,27 @@ async def cluster_ws_live_bulk(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
results: dict[str, dict[str, Any] | None] = {}
|
||||
denied: list[str] = []
|
||||
owned_rows: list[tuple[str, dict[str, Any]]] = []
|
||||
for wid in cleaned:
|
||||
row = rows.get(wid)
|
||||
if row is None:
|
||||
# Missing rows route to ``denied`` rather than ``results``
|
||||
# so the endpoint can't be used as an existence oracle for
|
||||
# ids outside the caller's knowledge.
|
||||
denied.append(wid)
|
||||
continue
|
||||
owned_rows.append((wid, row))
|
||||
|
||||
def _partition() -> tuple[list[tuple[str, dict[str, Any]]], list[str]]:
|
||||
# Missing rows AND private-project rows the caller isn't a member of
|
||||
# both route to ``denied`` rather than ``results`` — neither an
|
||||
# existence oracle for unknown ids nor a private-project oracle for
|
||||
# workstreams the admin can't see. The tenancy predicate resolves
|
||||
# project rows + membership from storage, so this runs off the
|
||||
# event loop.
|
||||
visible: list[tuple[str, dict[str, Any]]] = []
|
||||
hidden: list[str] = []
|
||||
for wid in cleaned:
|
||||
row = rows.get(wid)
|
||||
if row is None or not visibility.ws_visible(
|
||||
row.get("project_id") or "", row.get("user_id") or ""
|
||||
):
|
||||
hidden.append(wid)
|
||||
continue
|
||||
visible.append((wid, row))
|
||||
return visible, hidden
|
||||
|
||||
owned_rows, denied = await asyncio.to_thread(_partition)
|
||||
|
||||
# Fetch live blocks concurrently — ``_fetch_live_block`` already
|
||||
# routes node-backed reads through the per-node dashboard cache,
|
||||
@@ -1604,10 +1630,11 @@ class _ClusterTenancyFilter:
|
||||
|
||||
def __init__(self, visibility: Any) -> None:
|
||||
self._vis = visibility
|
||||
# Bypass principals (service scope / admin.cluster.inspect) get
|
||||
# the payload UNTOUCHED — no row drops, and crucially no
|
||||
# overview recompute (their header should reflect the
|
||||
# collector's own aggregates).
|
||||
# Bypass principals (service scope only — the collector/machine
|
||||
# plumbing) get the payload UNTOUCHED — no row drops, and crucially
|
||||
# no overview recompute (their header should reflect the
|
||||
# collector's own aggregates). Human admins are NOT bypass; they
|
||||
# see the same private-project filtering as any other user.
|
||||
self._bypass = bool(getattr(visibility, "bypass", False))
|
||||
self._hidden: set[str] = set()
|
||||
# wid -> (project_id, ws_owner) awaiting a definitive verdict.
|
||||
@@ -3091,6 +3118,18 @@ async def proxy_api(request: Request) -> Response:
|
||||
# service identity instead. Per-ws + bare events stay on
|
||||
# the user's identity for upstream audit attribution.
|
||||
use_service = path == "events/global"
|
||||
if use_service:
|
||||
# Elevating to the console's SERVICE identity bypasses the
|
||||
# node's per-user filtering, so the raw cross-tenant firehose
|
||||
# must be operator-gated — otherwise any authenticated user
|
||||
# could read every tenant's (incl. private-project) workstream
|
||||
# inventory through the node proxy. admin.cluster.inspect is
|
||||
# the same permission the cluster-inspect surfaces use.
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
perm_err = require_permission(request, "admin.cluster.inspect")
|
||||
if perm_err is not None:
|
||||
return perm_err
|
||||
return await _proxy_sse(
|
||||
request,
|
||||
server_url,
|
||||
@@ -3363,14 +3402,20 @@ async def _resolve_coordinator_or_404(
|
||||
Centralises the manager-first, storage-fallback, 404-mask ladder
|
||||
used by the coord-only verbs (``coordinator_children`` /
|
||||
``coordinator_tasks``). The shared verbs (history, detail, ...)
|
||||
inline the same ladder via :func:`make_history_handler` /
|
||||
:func:`make_detail_handler`. Turnstone is a
|
||||
trusted-team tool — ``user_id`` is metadata, not an access
|
||||
boundary, so this helper no longer gates on row ownership; scope
|
||||
auth (``admin.coordinator``) upstream is the gate.
|
||||
route through :func:`_coordinator_tenant_check` instead (wired onto
|
||||
``coord_endpoint_config.tenant_check``). Turnstone is a trusted-team
|
||||
tool — ``user_id`` is metadata, not an ownership boundary, so this
|
||||
helper does not gate on row ownership; ``admin.coordinator`` upstream
|
||||
gates the surface. It DOES enforce project tenancy, though: a
|
||||
coordinator attached to a PRIVATE project stays confidential to its
|
||||
members, so a non-member (even an ``admin.coordinator`` holder) is
|
||||
404-masked, same as a missing row.
|
||||
"""
|
||||
del user_id # retained in signature for caller-site clarity; not consulted here
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
miss = JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
ws = coord_mgr.get(ws_id) if coord_mgr is not None else None
|
||||
if ws is None:
|
||||
if storage is None:
|
||||
@@ -3386,10 +3431,67 @@ async def _resolve_coordinator_or_404(
|
||||
return None, miss
|
||||
if row is None or row.get("kind") != WorkstreamKind.COORDINATOR:
|
||||
return None, miss
|
||||
# Project tenancy — the predicate may resolve a project row +
|
||||
# membership, so judge it off the event loop.
|
||||
if not await asyncio.to_thread(
|
||||
visibility.ws_visible, row.get("project_id") or "", row.get("user_id") or ""
|
||||
):
|
||||
return None, miss
|
||||
return None, None
|
||||
if not await asyncio.to_thread(
|
||||
visibility.ws_visible, getattr(ws, "project_id", "") or "", ws.user_id or ""
|
||||
):
|
||||
return None, miss
|
||||
return ws, None
|
||||
|
||||
|
||||
def _coordinator_tenant_check(request: Request, ws_id: str, mgr: Any) -> JSONResponse | None:
|
||||
"""Project-tenancy gate for the lifted coordinator verbs.
|
||||
|
||||
Wired onto ``coord_endpoint_config.tenant_check`` (invoked SYNC in a
|
||||
thread) so history / export / detail / set_title / send / approve / …
|
||||
all enforce it. ``admin.coordinator`` gates the coordinator surface
|
||||
cluster-wide, so row OWNERSHIP is not enforced (any operator may drive
|
||||
any coordinator) — but a coordinator attached to a PRIVATE project stays
|
||||
confidential to its members.
|
||||
|
||||
Sync mirror of :func:`_resolve_coordinator_or_404`'s manager-first,
|
||||
storage-fallback, coordinator-kind ladder (the in-memory manager is the
|
||||
existence/kind authority; a storage row covers saved/closed
|
||||
coordinators) plus the project-visibility gate. Everything that fails —
|
||||
unknown id, wrong kind, or a private project the caller can't see —
|
||||
404-masks identically, so the surface is neither an existence nor a
|
||||
private-project oracle. Reusing the manager-first + kind ladder also
|
||||
preserves the coord kind-isolation that :func:`make_set_title_handler`
|
||||
previously got from the ``tenant_check is None`` manager-lookup guard.
|
||||
"""
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
|
||||
miss = JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
# Use the request's configured storage (like cluster_ws_detail /
|
||||
# _resolve_coordinator_or_404) — NOT the global registry, which can
|
||||
# resolve a different/auto-init'd backend and evaluate the tenancy
|
||||
# decision against the wrong DB (fail-open on a missing project row).
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage is None:
|
||||
return miss
|
||||
ws = mgr.get(ws_id) if mgr is not None else None
|
||||
if ws is not None:
|
||||
# coord_mgr only holds coordinators, so kind is implied.
|
||||
project_id = getattr(ws, "project_id", "") or ""
|
||||
owner = ws.user_id or ""
|
||||
else:
|
||||
row = storage.get_workstream(ws_id)
|
||||
if row is None or row.get("kind") != WorkstreamKind.COORDINATOR:
|
||||
return miss
|
||||
project_id = row.get("project_id") or ""
|
||||
owner = row.get("user_id") or ""
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
if not visibility.ws_visible(project_id, ws_owner=owner):
|
||||
return miss
|
||||
return None
|
||||
|
||||
|
||||
def _auth_user_id(request: Request) -> str:
|
||||
"""Thin shim over :func:`turnstone.core.web_helpers.auth_user_id`.
|
||||
|
||||
@@ -5633,14 +5735,14 @@ async def admin_create_token(request: Request) -> JSONResponse:
|
||||
scopes = body.get("scopes", "read,write,approve")
|
||||
expires_days = body.get("expires_days")
|
||||
|
||||
# Validate scopes
|
||||
from turnstone.core.auth import VALID_SCOPES
|
||||
# Validate scopes — ``service`` is NOT user-assignable (it bypasses
|
||||
# private-project tenancy; only ServiceTokenManager / the JWT secret
|
||||
# may mint it).
|
||||
from turnstone.core.auth import reject_unassignable_scopes
|
||||
|
||||
requested = {s.strip() for s in scopes.split(",") if s.strip()}
|
||||
if not requested or not requested.issubset(VALID_SCOPES):
|
||||
return JSONResponse(
|
||||
{"error": "Invalid scopes (allowed: read, write, approve)"}, status_code=400
|
||||
)
|
||||
scope_err = reject_unassignable_scopes(scopes)
|
||||
if scope_err is not None:
|
||||
return JSONResponse({"error": scope_err}, status_code=400)
|
||||
|
||||
expires: str | None = None
|
||||
if expires_days is not None:
|
||||
@@ -13519,11 +13621,25 @@ def create_app(
|
||||
coordinators must be ``open``ed before they can accept
|
||||
attachment operations.
|
||||
"""
|
||||
from turnstone.core.auth import WorkstreamProjectVisibility
|
||||
from turnstone.core.web_helpers import auth_user_id
|
||||
|
||||
ws = mgr.get(ws_id)
|
||||
if ws is None:
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
# Private-project tenancy: a coordinator attached to a private project
|
||||
# serves attachments only to its members — admin.coordinator gates the
|
||||
# surface, not the tenancy. 404-mask non-members like the other verbs.
|
||||
# Use the request's configured storage (not the global registry) so the
|
||||
# visibility decision can't be evaluated against the wrong DB.
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage is None:
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
visibility = WorkstreamProjectVisibility.for_request(request, storage=storage)
|
||||
if not visibility.ws_visible(
|
||||
getattr(ws, "project_id", "") or "", ws_owner=ws.user_id or ""
|
||||
):
|
||||
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
|
||||
return ws.user_id or auth_user_id(request), None
|
||||
|
||||
from turnstone.core.attachments import classify_upload as _coord_classify_upload
|
||||
@@ -13550,7 +13666,8 @@ def create_app(
|
||||
coord_endpoint_config = SessionEndpointConfig(
|
||||
permission_gate=_require_admin_coordinator,
|
||||
manager_lookup=_require_coord_mgr,
|
||||
tenant_check=None, # cluster-wide admin.coordinator gate covers it
|
||||
# admin.coordinator gates the surface; this gates private-project tenancy.
|
||||
tenant_check=_coordinator_tenant_check,
|
||||
not_found_label="coordinator not found",
|
||||
audit_action_prefix="coordinator",
|
||||
supports_attachments=True,
|
||||
|
||||
@@ -1887,6 +1887,49 @@ document.addEventListener("keydown", function (e) {
|
||||
if (!_homeCoordComposer.sendBtn.disabled) submitHomeCoord();
|
||||
});
|
||||
|
||||
// Global pane accelerators for the console — switch panes and jump to the
|
||||
// Dashboard. The per-pane tab-menu actions (close pane, edit/refresh title,
|
||||
// delete) are bound once in shell.js off the active pane's OWN menu, so they
|
||||
// match the standalone automatically; only these shell-level chords live here.
|
||||
// New workstream and Fork are omitted: the console starts work from the
|
||||
// Dashboard and has no interactive fork surface yet.
|
||||
//
|
||||
// Modifier per platform: Ctrl on macOS (the browser owns Cmd), Alt on
|
||||
// Windows/Linux (Ctrl is the browser's own switch-tab / bookmark accelerator).
|
||||
const _CONSOLE_IS_MAC =
|
||||
(navigator.platform && navigator.platform.indexOf("Mac") > -1) || false;
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (document.querySelector("dialog:modal")) return;
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (!pm) return;
|
||||
|
||||
// Ctrl+D: jump to the Dashboard pane. Kept on Ctrl every platform — Alt+D is
|
||||
// the browser's focus-address-bar. Yields to macOS delete-forward in fields.
|
||||
if (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && e.key === "d") {
|
||||
if (window.TS_SHELL.inEditable(e.target)) return;
|
||||
e.preventDefault();
|
||||
pm.openPane("dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
const paneMod = _CONSOLE_IS_MAC
|
||||
? e.ctrlKey && !e.altKey && !e.metaKey
|
||||
: e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!paneMod || e.shiftKey) return;
|
||||
|
||||
// <mod>+1..9: switch among the open conversational panes (mirrors a browser's
|
||||
// Ctrl+1..9); works while composing.
|
||||
if (e.key >= "1" && e.key <= "9") {
|
||||
const tabs = pm.statefulTabs();
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
if (idx < tabs.length) {
|
||||
e.preventDefault();
|
||||
pm.activate(tabs[idx].id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved coordinators — closed sessions persisted on disk. Mirrors the
|
||||
// interactive UI's "Saved Workstreams" table (same /shared/cards.js
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
batchKicker,
|
||||
indexLabel,
|
||||
} from "/shared/conversation.js";
|
||||
import { redactCredentials } from "/shared/redact_credentials.js";
|
||||
|
||||
function buildCoordChrome(root, opts) {
|
||||
opts = opts || {};
|
||||
@@ -533,7 +534,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
/* fall through */
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return esc(rawText);
|
||||
return esc(redactCredentials(rawText));
|
||||
}
|
||||
// Normalize to an array of rows we can linkify.
|
||||
let rows = [];
|
||||
@@ -543,7 +544,7 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
rows = [parsed];
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return "<pre>" + esc(JSON.stringify(parsed, null, 2)) + "</pre>";
|
||||
return "<pre>" + esc(redactCredentials(JSON.stringify(parsed, null, 2))) + "</pre>";
|
||||
}
|
||||
const lines = rows.map((row) => {
|
||||
const safeWs = row.ws_id && WS_ID_RE.test(row.ws_id) ? row.ws_id : null;
|
||||
|
||||
@@ -2709,7 +2709,15 @@
|
||||
aria-atomic="true"
|
||||
></div>
|
||||
<input id="pr-persona-id" type="hidden" />
|
||||
<label for="pr-name">Name</label>
|
||||
<label for="pr-name"
|
||||
>Name
|
||||
<span class="label-hint"
|
||||
>how agents and the CLI launch it — persona=<name> on
|
||||
task_agent / spawn_workstream / spawn_batch, --persona
|
||||
<name> on the CLI. Case doesn't matter; the display
|
||||
name below is only a label in lists</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
id="pr-name"
|
||||
type="text"
|
||||
@@ -3837,6 +3845,14 @@
|
||||
orchestration: true,
|
||||
brandSub: "console",
|
||||
};
|
||||
// Pane accelerators bind to Ctrl on macOS (the browser owns Cmd and
|
||||
// leaves Ctrl free) and to Alt on Windows/Linux (Ctrl is the browser's
|
||||
// own switch-tab accelerator). Must match what app.js / shell.js listen
|
||||
// for on this host.
|
||||
const PANE_MOD =
|
||||
navigator.platform && navigator.platform.indexOf("Mac") > -1
|
||||
? "Ctrl"
|
||||
: "Alt";
|
||||
window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{
|
||||
title: "Navigation",
|
||||
@@ -3856,6 +3872,31 @@
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Panes",
|
||||
keys: [
|
||||
{
|
||||
desc: "Switch pane",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+1</span>\u2026<span class="kb-key">${PANE_MOD}+9</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Close pane",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+W</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Edit title",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+E</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Refresh title",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+R</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Delete workstream",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+X</span>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "General",
|
||||
keys: [
|
||||
@@ -3868,6 +3909,10 @@
|
||||
: "Ctrl") +
|
||||
'</span>+<span class="kb-key">Enter</span>',
|
||||
},
|
||||
{
|
||||
desc: "Toggle dashboard",
|
||||
badge: '<span class="kb-key">Ctrl+D</span>',
|
||||
},
|
||||
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' },
|
||||
{ desc: "Close overlay", badge: '<span class="kb-key">Esc</span>' },
|
||||
],
|
||||
|
||||
+40
-14
@@ -73,6 +73,29 @@ _MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
|
||||
|
||||
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
|
||||
|
||||
# Scopes a principal may be GRANTED via a user-facing token mint (the admin
|
||||
# token API / ``turnstone-admin create-token``). ``service`` is deliberately
|
||||
# excluded: it is a full cross-tenant bypass (see
|
||||
# :meth:`WorkstreamProjectVisibility.for_request`) and must only ever be
|
||||
# minted by :class:`ServiceTokenManager` / operators holding the JWT secret —
|
||||
# never assigned to a user, or an admin could self-grant it and see every
|
||||
# private project's workstreams.
|
||||
ASSIGNABLE_SCOPES: frozenset[str] = VALID_SCOPES - frozenset({"service"})
|
||||
|
||||
|
||||
def reject_unassignable_scopes(scopes_csv: str) -> str | None:
|
||||
"""Validate a user-supplied comma-separated scope string for token mints.
|
||||
|
||||
Returns an error message when the request is empty or names any scope
|
||||
outside :data:`ASSIGNABLE_SCOPES` (notably ``service``), else ``None``.
|
||||
Shared by the admin token API and the CLI so the rule can't drift.
|
||||
"""
|
||||
requested = {s.strip() for s in scopes_csv.split(",") if s.strip()}
|
||||
if not requested or not requested.issubset(ASSIGNABLE_SCOPES):
|
||||
allowed = ", ".join(sorted(ASSIGNABLE_SCOPES))
|
||||
return f"Invalid scopes (allowed: {allowed})"
|
||||
return None
|
||||
|
||||
|
||||
def jwt_version_slot() -> str:
|
||||
"""Return ``major.minor`` from ``__version__`` for JWT version claims.
|
||||
@@ -267,11 +290,13 @@ class WorkstreamProjectVisibility:
|
||||
|
||||
Rules (first match wins):
|
||||
|
||||
* ``bypass`` instances see everything — service-scope callers (the
|
||||
collector and other cluster machinery must never be blinded at the
|
||||
node edge; user-facing filtering happens at the console edge) and
|
||||
holders of ``admin.cluster.inspect`` (the existing cluster-wide
|
||||
workstream-inspect surface).
|
||||
* ``bypass`` instances see everything — but ONLY service-scope callers
|
||||
(the collector and other cluster machinery must never be blinded at
|
||||
the node edge; user-facing filtering happens per-principal at the
|
||||
console edge). No human principal bypasses: a private project's
|
||||
workstreams are confidential even from admins — ``admin.cluster.inspect``
|
||||
still gates the cluster-inspect *surfaces*, but a permitted admin only
|
||||
sees the private-project rows they own or are a member of.
|
||||
* No / dangling ``project_id`` → visible (a deleted project leaves the
|
||||
link behind by design — no row, no privacy to enforce).
|
||||
* Non-private visibility → visible (trusted-team default).
|
||||
@@ -295,22 +320,23 @@ class WorkstreamProjectVisibility:
|
||||
def for_request(cls, request: Any, *, storage: Any = None) -> WorkstreamProjectVisibility:
|
||||
"""Build a filter for an HTTP request's authenticated principal.
|
||||
|
||||
Service-scoped tokens and ``admin.cluster.inspect`` holders get a
|
||||
bypass instance; everyone else filters as themselves.
|
||||
Only service-scoped tokens get a bypass instance (node→console
|
||||
machine plumbing, re-filtered per-user at the console edge);
|
||||
everyone else — admins included — filters as themselves. An admin
|
||||
holding ``admin.cluster.inspect`` reaches the cluster-inspect
|
||||
surfaces but still only sees private-project workstreams they own
|
||||
or belong to.
|
||||
"""
|
||||
auth: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid = str(getattr(auth, "user_id", "") or "")
|
||||
bypass = bool(
|
||||
auth is not None
|
||||
and (auth.has_scope("service") or auth.has_permission("admin.cluster.inspect"))
|
||||
)
|
||||
bypass = bool(auth is not None and auth.has_scope("service"))
|
||||
return cls(uid, bypass=bypass, storage=storage)
|
||||
|
||||
@property
|
||||
def bypass(self) -> bool:
|
||||
"""True when this principal sees everything (service scope /
|
||||
``admin.cluster.inspect``) — callers that transform payloads
|
||||
(not just drop rows) use this to leave them untouched."""
|
||||
"""True when this principal sees everything (service scope only) —
|
||||
callers that transform payloads (not just drop rows) use this to
|
||||
leave them untouched."""
|
||||
return self._bypass
|
||||
|
||||
def _resolve_storage(self) -> Any:
|
||||
|
||||
+130
-4
@@ -6,12 +6,19 @@ trajectory) and the per-provider translators (which own format only — the
|
||||
*valid* for an LLM round-trip, so every translator can assume a well-formed
|
||||
input and stay a pure format mapping.
|
||||
|
||||
This module owns the two provider-neutral lowering passes:
|
||||
This module owns the three provider-neutral lowering passes:
|
||||
|
||||
* **fold** (representation) — operator-context ``system`` turns are folded into
|
||||
the preceding turn as nonce-fenced ``[start system-reminder]`` blocks for models
|
||||
without native mid-conversation system support (native models keep them
|
||||
inline). See :func:`fold_system_turns`.
|
||||
* **legalize** (validity) — normalizing any tool-call ``arguments`` that isn't a
|
||||
JSON-object string (an unterminated string from a non-``length`` truncation, an
|
||||
empty ``""``, a bare scalar) to ``"{}"`` so a strict renderer (e.g. vLLM's
|
||||
``deepseek_v4``, which ``json.loads`` the arguments at request-render time)
|
||||
can't reject the whole request. Mutates the transient wire copy only — the
|
||||
canonical trajectory keeps the raw output. See
|
||||
:func:`sanitize_tool_call_arguments`.
|
||||
* **repair** (validity) — synthesizing cancellation results for orphaned client
|
||||
tool calls. See :func:`repair_wire_messages`.
|
||||
|
||||
@@ -45,13 +52,16 @@ their own.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core import fence
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.output_guard import redact_credentials
|
||||
from turnstone.core.trajectory import EffectStatus, Turn, dicts_from_turns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
# The "you cannot tell whether it ran" clause, shared by every cancel
|
||||
# disposition surface (this wire-repair fallback AND the session-layer
|
||||
@@ -161,6 +171,122 @@ def repair_wire_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Legalize — a tool call's ``arguments`` must be a JSON-object string on the wire.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def wire_valid_arguments(arguments: Any) -> bool:
|
||||
"""True when *arguments* is a string that decodes to a JSON object.
|
||||
|
||||
A tool call carries ``arguments`` as an opaque JSON string, and a strict
|
||||
renderer re-parses it at request-render time (vLLM's ``deepseek_v4``
|
||||
``_postprocess_messages`` does ``json.loads`` on it), so anything that isn't a
|
||||
string decoding to a JSON *object* — an unterminated string from a
|
||||
non-``length`` truncation, an empty ``""``, a bare scalar/array, a raw ``dict``
|
||||
that never got serialized — makes the provider reject the whole request.
|
||||
Shared by the wire legalizer here and the session-layer accumulator's integrity
|
||||
check so the two can't drift on what "valid" means.
|
||||
"""
|
||||
if not isinstance(arguments, str):
|
||||
return False
|
||||
try:
|
||||
# json.loads raises JSONDecodeError (already a ValueError, so listing both
|
||||
# was redundant) on malformed JSON, and RecursionError on deeply-nested
|
||||
# JSON — catch both so this predicate is total for any string input.
|
||||
return isinstance(json.loads(arguments), dict)
|
||||
except (json.JSONDecodeError, RecursionError):
|
||||
return False
|
||||
|
||||
|
||||
# All C0 control chars (tab/newline/CR included) plus DEL, collapsed to a space so
|
||||
# a preview stays a single log line — stricter than ``audit._scrub_string``, which
|
||||
# keeps tab/newline because audit detail is JSON-dumped and rendered multi-line.
|
||||
# Built via chr()/range() rather than literal ``\xNN`` escapes to keep control
|
||||
# bytes out of this source file.
|
||||
_ARGS_PREVIEW_CONTROL_RE = re.compile(
|
||||
"[" + re.escape("".join(chr(c) for c in range(0x20)) + chr(0x7F)) + "]"
|
||||
)
|
||||
|
||||
|
||||
def tool_args_preview(arguments: Any) -> str:
|
||||
"""A short, credential-scrubbed, single-line preview of a tool call's raw
|
||||
``arguments`` (any type), safe to emit into logs.
|
||||
|
||||
Tool arguments are model/user-controlled and can carry secrets (a token in a
|
||||
bash command, a password in a connection string) or raw control characters
|
||||
(CR/LF → multi-line / log-injection artifacts). Mirroring
|
||||
``audit._scrub_string``: :func:`redact_credentials` runs over the *full* value
|
||||
first — so a secret straddling the 120-char cut isn't half-shown past the
|
||||
pattern's reach — then every control char collapses to a space, then the result
|
||||
is capped. Shared by the wire legalizer and the session-layer
|
||||
``stream.tool_args_malformed`` warning so both log sites are equally safe.
|
||||
"""
|
||||
text = arguments if isinstance(arguments, str) else repr(arguments)
|
||||
return _ARGS_PREVIEW_CONTROL_RE.sub(" ", redact_credentials(text))[:120]
|
||||
|
||||
|
||||
def _legalized_arguments(arguments: Any) -> str | None:
|
||||
"""A wire-valid replacement for *arguments*, or ``None`` if already valid.
|
||||
|
||||
A raw ``dict`` (an internal shape that reached the wire seat) is serialized;
|
||||
anything else that fails :func:`wire_valid_arguments` collapses to ``"{}"``.
|
||||
The value is cosmetic on replay — a malformed call was already answered with a
|
||||
"retry with valid JSON" result, and the model consumes that result, not its own
|
||||
prior arguments — so an empty object drops nothing a strict renderer would keep.
|
||||
"""
|
||||
if wire_valid_arguments(arguments):
|
||||
return None
|
||||
if isinstance(arguments, dict):
|
||||
try:
|
||||
return json.dumps(arguments)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
return "{}"
|
||||
return "{}"
|
||||
|
||||
|
||||
def sanitize_tool_call_arguments(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return *messages* with every assistant tool call's ``arguments`` made
|
||||
wire-valid — the legalize pass (see the module docstring).
|
||||
|
||||
The stream accumulator commits ``arguments`` verbatim, and the only guard that
|
||||
drops a malformed tool call is ``finish_reason == "length"``
|
||||
(``ChatSession._stream_response``); a model that emits invalid JSON with a
|
||||
``stop`` / ``tool_calls`` finish reason slips through, and one such turn then
|
||||
poison-pills every later request that replays it on a strict renderer. This
|
||||
legalizes each offending ``arguments`` to a JSON-object string.
|
||||
|
||||
Faithful and cheap, exactly like :func:`repair_wire_messages`: the canonical
|
||||
``Turn`` trajectory keeps the raw model output (this mutates only the transient
|
||||
wire copy), and the pass is copy-on-write + identity-preserving — a
|
||||
conversation with no malformed call is returned unchanged (same object).
|
||||
"""
|
||||
out: list[dict[str, Any]] | None = None # copy-on-write: None until first fix
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant" or not msg.get("tool_calls"):
|
||||
continue
|
||||
repaired: list[dict[str, Any]] | None = None
|
||||
for ci, tc in enumerate(msg["tool_calls"]):
|
||||
fn = tc.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
replacement = _legalized_arguments(fn.get("arguments"))
|
||||
if replacement is None:
|
||||
continue # already wire-valid — leave byte-for-byte untouched
|
||||
if repaired is None:
|
||||
repaired = list(msg["tool_calls"])
|
||||
log.debug(
|
||||
"wire.tool_args_legalized",
|
||||
tool=fn.get("name", "?"),
|
||||
call_id=tc.get("id", ""),
|
||||
raw_preview=tool_args_preview(fn.get("arguments")),
|
||||
)
|
||||
repaired[ci] = {**tc, "function": {**fn, "arguments": replacement}}
|
||||
if repaired is not None:
|
||||
if out is None:
|
||||
out = list(messages)
|
||||
out[idx] = {**msg, "tool_calls": repaired}
|
||||
return messages if out is None else out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fold — operator-context representation (A); runs BEFORE repair on the wire.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -225,7 +351,7 @@ def fold_system_turns(
|
||||
# authorship. Degrade (still fold) rather than crash the turn —
|
||||
# the harm is OOD voice, not a trust breach (the nonce still
|
||||
# gates operator trust regardless of host turn).
|
||||
logger.warning(
|
||||
log.warning(
|
||||
"operator-context system turn (_source=%s) is folding onto "
|
||||
"an assistant turn; operator context should follow a "
|
||||
"user/tool turn",
|
||||
|
||||
+945
-465
File diff suppressed because it is too large
Load Diff
@@ -145,9 +145,35 @@ class ModelRegistry:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
if alias not in self._clients:
|
||||
cfg = self._models[alias]
|
||||
self._clients[alias] = create_client(
|
||||
cfg.provider, base_url=cfg.base_url, api_key=cfg.api_key
|
||||
)
|
||||
try:
|
||||
self._clients[alias] = create_client(
|
||||
cfg.provider, base_url=cfg.base_url, api_key=cfg.api_key
|
||||
)
|
||||
except ValueError:
|
||||
# create_client's own misconfig errors already carry
|
||||
# remediation text — pass through untouched.
|
||||
raise
|
||||
except Exception as exc:
|
||||
# SDK construction can fail on environment problems the
|
||||
# config never sees — e.g. httpx resolving a CA-bundle
|
||||
# path that a venv rebuild deleted (FileNotFoundError).
|
||||
# Routes map ValueError to a 503 with the message;
|
||||
# anything else surfaces as an opaque 500, so re-type
|
||||
# here where the alias is known. The ValueError text is
|
||||
# echoed to HTTP callers, so it carries only the
|
||||
# exception TYPE — arbitrary SDK exception text can
|
||||
# embed filesystem paths; the full detail goes to the
|
||||
# server log instead.
|
||||
log.warning(
|
||||
"Client construction failed for model alias %r (provider %s)",
|
||||
alias,
|
||||
cfg.provider,
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(
|
||||
f"failed to construct {cfg.provider} client for model "
|
||||
f"alias {alias!r}: {type(exc).__name__} (details in server log)"
|
||||
) from exc
|
||||
return self._clients[alias]
|
||||
|
||||
def get_provider(self, alias: str) -> LLMProvider:
|
||||
|
||||
@@ -58,6 +58,17 @@ class OAuthSSRFError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class OAuthSSRFPrivateAddressError(OAuthSSRFError):
|
||||
"""A hostname resolved to a non-public address, specifically.
|
||||
|
||||
A distinct subclass so callers with an operator-facing opt-in
|
||||
(``[oidc] allow_private_network``) can catch this case and append the
|
||||
remediation hint, while callers with no such opt-in (``mcp_oauth``,
|
||||
where endpoint URLs come from untrusted remote-server metadata) keep
|
||||
catching :class:`OAuthSSRFError` and stay strict.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -92,13 +103,23 @@ def effective_port(parsed: urllib.parse.ParseResult) -> int | None:
|
||||
return {"http": 80, "https": 443}.get(parsed.scheme)
|
||||
|
||||
|
||||
def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
def validate_url_no_ssrf(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""Run the scheme/userinfo/SSRF checks shared by issuer and discovered URLs.
|
||||
|
||||
Returns the parsed URL on success. Raises :class:`OAuthSSRFError` on
|
||||
failure. The ``allow_http`` flag is the only knob: when ``True``,
|
||||
``http://`` is accepted *if* the hostname is also a localhost form;
|
||||
when ``False``, only ``https://`` is accepted.
|
||||
failure. Two knobs: ``allow_http=True`` accepts ``http://`` *if* the
|
||||
hostname is also a localhost form (when ``False``, only ``https://``
|
||||
is accepted); ``allow_private=True`` accepts hostnames resolving to
|
||||
private-range addresses (RFC 1918, ULA, CGNAT, loopback) for
|
||||
operator-trusted URLs — a self-hosted IdP on an internal network.
|
||||
Even with ``allow_private``, link-local, multicast, unspecified, and
|
||||
reserved addresses stay refused: cloud metadata services
|
||||
(169.254.169.254) are the canonical SSRF target, and no legitimate
|
||||
IdP lives in those ranges. Non-public rejections raise the
|
||||
:class:`OAuthSSRFPrivateAddressError` subclass so callers that *have*
|
||||
an opt-in can point the operator at it.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
@@ -127,8 +148,19 @@ def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseRes
|
||||
raise OAuthSSRFError(
|
||||
f"endpoint hostname resolved to invalid IP {sockaddr[0]!r}: {hostname}"
|
||||
) from exc
|
||||
if not addr.is_global and not is_localhost(hostname):
|
||||
raise OAuthSSRFError(f"endpoint URL resolves to non-public address ({addr}): {url}")
|
||||
if addr.is_global or is_localhost(hostname):
|
||||
continue
|
||||
if allow_private:
|
||||
if addr.is_link_local or addr.is_multicast or addr.is_unspecified or addr.is_reserved:
|
||||
raise OAuthSSRFError(
|
||||
f"endpoint URL resolves to a link-local/multicast/"
|
||||
f"unspecified/reserved address ({addr}), refused even "
|
||||
f"with private addresses allowed: {url}"
|
||||
)
|
||||
continue
|
||||
raise OAuthSSRFPrivateAddressError(
|
||||
f"endpoint URL resolves to non-public address ({addr}): {url}"
|
||||
)
|
||||
|
||||
return parsed
|
||||
|
||||
@@ -139,6 +171,7 @@ def validate_discovered_endpoint(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Validate an endpoint URL pulled from an OIDC/OAuth discovery document.
|
||||
|
||||
@@ -146,11 +179,12 @@ def validate_discovered_endpoint(
|
||||
constraint: the endpoint host must equal the issuer host, be in the
|
||||
well-known trust map, or be in the operator-supplied
|
||||
``trusted_endpoint_hosts``. Effective port (with scheme defaults
|
||||
applied) and scheme must match the issuer.
|
||||
applied) and scheme must match the issuer. ``allow_private`` forwards
|
||||
to :func:`validate_url_no_ssrf`.
|
||||
|
||||
Raises :class:`OAuthSSRFError` on validation failure.
|
||||
"""
|
||||
parsed = validate_url_no_ssrf(url, allow_http=allow_http)
|
||||
parsed = validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
|
||||
|
||||
issuer_hostname = (issuer_parsed.hostname or "").lower()
|
||||
endpoint_hostname = (parsed.hostname or "").lower()
|
||||
@@ -182,7 +216,9 @@ def validate_discovered_endpoint(
|
||||
)
|
||||
|
||||
|
||||
async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
async def validate_url_no_ssrf_async(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""Async variant of :func:`validate_url_no_ssrf` for hot-path callers.
|
||||
|
||||
The synchronous variant calls ``socket.getaddrinfo``, which blocks
|
||||
@@ -191,7 +227,9 @@ async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.pa
|
||||
:func:`asyncio.to_thread` to keep the loop responsive. This wrapper
|
||||
centralises that wrapping so callers don't repeat the idiom.
|
||||
"""
|
||||
return await asyncio.to_thread(validate_url_no_ssrf, url, allow_http=allow_http)
|
||||
return await asyncio.to_thread(
|
||||
validate_url_no_ssrf, url, allow_http=allow_http, allow_private=allow_private
|
||||
)
|
||||
|
||||
|
||||
async def validate_discovered_endpoint_async(
|
||||
@@ -200,6 +238,7 @@ async def validate_discovered_endpoint_async(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Async variant of :func:`validate_discovered_endpoint`."""
|
||||
await asyncio.to_thread(
|
||||
@@ -208,12 +247,14 @@ async def validate_discovered_endpoint_async(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private=allow_private,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS",
|
||||
"OAuthSSRFError",
|
||||
"OAuthSSRFPrivateAddressError",
|
||||
"effective_port",
|
||||
"is_localhost",
|
||||
"sanitize_log_text",
|
||||
|
||||
+50
-8
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
OAuthSSRFError,
|
||||
OAuthSSRFPrivateAddressError,
|
||||
is_localhost,
|
||||
)
|
||||
from turnstone.core.oauth_ssrf import (
|
||||
@@ -105,7 +106,7 @@ class OIDCConfig:
|
||||
Startup-config fields (set by :func:`load_oidc_config`):
|
||||
``enabled``, ``issuer``, ``client_id``, ``client_secret``, ``scopes``,
|
||||
``provider_name``, ``role_claim``, ``role_map``, ``password_enabled``,
|
||||
``redirect_base``, ``trusted_endpoint_hosts``.
|
||||
``redirect_base``, ``trusted_endpoint_hosts``, ``allow_private_network``.
|
||||
|
||||
Discovery-derived fields (set by :func:`discover_oidc`; empty before
|
||||
discovery completes):
|
||||
@@ -124,6 +125,10 @@ class OIDCConfig:
|
||||
password_enabled: bool = True
|
||||
redirect_base: str = ""
|
||||
trusted_endpoint_hosts: tuple[str, ...] = ()
|
||||
# Opt-in for self-hosted IdPs on internal networks: permit the issuer
|
||||
# (and its same-origin discovered endpoints) to resolve to private
|
||||
# addresses. Link-local/multicast/reserved stay refused regardless.
|
||||
allow_private_network: bool = False
|
||||
# Discovered from .well-known/openid-configuration
|
||||
authorization_endpoint: str = ""
|
||||
token_endpoint: str = ""
|
||||
@@ -189,6 +194,9 @@ def load_oidc_config() -> OIDCConfig:
|
||||
password_enabled = _env_or_cfg_bool(
|
||||
"TURNSTONE_OIDC_PASSWORD_ENABLED", cfg, "password_enabled", True
|
||||
)
|
||||
allow_private_network = _env_or_cfg_bool(
|
||||
"TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", cfg, "allow_private_network", False
|
||||
)
|
||||
|
||||
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
|
||||
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
|
||||
@@ -259,7 +267,12 @@ def load_oidc_config() -> OIDCConfig:
|
||||
enabled = bool(issuer and client_id and client_secret)
|
||||
|
||||
if enabled:
|
||||
log.info("OIDC enabled: issuer=%s provider=%s", issuer, provider_name)
|
||||
log.info(
|
||||
"OIDC enabled: issuer=%s provider=%s%s",
|
||||
issuer,
|
||||
provider_name,
|
||||
" (private-network IdP allowed)" if allow_private_network else "",
|
||||
)
|
||||
else:
|
||||
log.debug("OIDC not configured (issuer/client_id/client_secret incomplete)")
|
||||
|
||||
@@ -275,6 +288,7 @@ def load_oidc_config() -> OIDCConfig:
|
||||
password_enabled=password_enabled,
|
||||
redirect_base=redirect_base,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private_network=allow_private_network,
|
||||
)
|
||||
|
||||
|
||||
@@ -286,26 +300,44 @@ def load_oidc_config() -> OIDCConfig:
|
||||
# converting :class:`OAuthSSRFError` to :class:`OIDCError`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Appended to every private-address rejection in this module (issuer and
|
||||
# discovered endpoints alike): the login-flow URLs are operator-configured,
|
||||
# so pointing the operator at the opt-in is safe here — unlike ``mcp_oauth``,
|
||||
# where the URLs come from untrusted remote-server metadata and no such
|
||||
# opt-in exists.
|
||||
_PRIVATE_NETWORK_HINT = (
|
||||
" — to allow a self-hosted IdP on a private network, set "
|
||||
"allow_private_network = true in the [oidc] section of config.toml "
|
||||
"(or TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true)"
|
||||
)
|
||||
|
||||
def _validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
|
||||
|
||||
def _validate_url_no_ssrf(
|
||||
url: str, *, allow_http: bool, allow_private: bool = False
|
||||
) -> urllib.parse.ParseResult:
|
||||
"""OIDC-flavoured wrapper around :func:`oauth_ssrf.validate_url_no_ssrf`."""
|
||||
try:
|
||||
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http)
|
||||
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
|
||||
except OAuthSSRFPrivateAddressError as exc:
|
||||
raise OIDCError(f"{exc}{_PRIVATE_NETWORK_HINT}") from exc
|
||||
except OAuthSSRFError as exc:
|
||||
raise OIDCError(str(exc)) from exc
|
||||
|
||||
|
||||
def validate_issuer_url(url: str) -> None:
|
||||
def validate_issuer_url(url: str, *, allow_private: bool = False) -> None:
|
||||
"""Validate an OIDC issuer URL to prevent SSRF.
|
||||
|
||||
Rejects:
|
||||
- Non-HTTPS URLs (except localhost for development)
|
||||
- URLs with embedded credentials (userinfo)
|
||||
- Hostnames that resolve to private/internal/loopback IP addresses
|
||||
- Hostnames that resolve to private/internal/loopback IP addresses,
|
||||
unless ``allow_private`` is set (the ``allow_private_network``
|
||||
opt-in for self-hosted IdPs; link-local/multicast/reserved
|
||||
addresses stay refused regardless)
|
||||
|
||||
Raises :class:`OIDCError` on validation failure.
|
||||
"""
|
||||
_validate_url_no_ssrf(url, allow_http=True)
|
||||
_validate_url_no_ssrf(url, allow_http=True, allow_private=allow_private)
|
||||
|
||||
|
||||
def validate_discovered_endpoint(
|
||||
@@ -314,6 +346,7 @@ def validate_discovered_endpoint(
|
||||
*,
|
||||
allow_http: bool,
|
||||
trusted_endpoint_hosts: frozenset[str],
|
||||
allow_private: bool = False,
|
||||
) -> None:
|
||||
"""Validate an endpoint pulled from an IdP discovery document.
|
||||
|
||||
@@ -340,7 +373,12 @@ def validate_discovered_endpoint(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_endpoint_hosts,
|
||||
allow_private=allow_private,
|
||||
)
|
||||
except OAuthSSRFPrivateAddressError as exc:
|
||||
# A discovered endpoint (or trusted host) resolving private is fixed
|
||||
# by the same opt-in as the issuer — carry the hint here too.
|
||||
raise OIDCError(f"{exc}{_PRIVATE_NETWORK_HINT}") from exc
|
||||
except OAuthSSRFError as exc:
|
||||
raise OIDCError(str(exc)) from exc
|
||||
|
||||
@@ -367,7 +405,9 @@ async def discover_oidc(
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
try:
|
||||
issuer_parsed = _validate_url_no_ssrf(config.issuer, allow_http=True)
|
||||
issuer_parsed = _validate_url_no_ssrf(
|
||||
config.issuer, allow_http=True, allow_private=config.allow_private_network
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC issuer URL rejected: %s", exc)
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
@@ -422,6 +462,7 @@ async def discover_oidc(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_hosts,
|
||||
allow_private=config.allow_private_network,
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC discovered %s rejected (url=%s): %s", name, endpoint_url, exc)
|
||||
@@ -434,6 +475,7 @@ async def discover_oidc(
|
||||
issuer_parsed,
|
||||
allow_http=allow_http,
|
||||
trusted_endpoint_hosts=trusted_hosts,
|
||||
allow_private=config.allow_private_network,
|
||||
)
|
||||
except OIDCError as exc:
|
||||
log.warning(
|
||||
|
||||
@@ -94,10 +94,16 @@ _RE_PRIVATE_KEY_BLOCK = re.compile(
|
||||
# ``redact_credentials`` — error persistence, audit details,
|
||||
# coordinator inspect/wait surfaces. The structural form ``[^:@\s]+:
|
||||
# [^@\s]+@`` is specific enough that ``https://example.com:8080/path``
|
||||
# (host:port without ``@``) doesn't match.
|
||||
# (host:port without ``@``) doesn't match. The optional ``+suffix``
|
||||
# covers SQLAlchemy dialect+driver URLs (``postgresql+psycopg2``,
|
||||
# ``postgresql+asyncpg``, ``mysql+pymysql``) and ``mongodb+srv`` —
|
||||
# enumerating drivers is a losing game, the suffix shape isn't.
|
||||
# Schemes are case-insensitive per RFC 3986, hence IGNORECASE:
|
||||
# ``POSTGRESQL://`` leaks the same password ``postgresql://`` does.
|
||||
_RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb|redis|amqp|sqlite|https?)"
|
||||
r"(?:postgresql|mysql|mongodb|rediss?|amqps?|sqlite|https?)(?:\+[a-z0-9]*)?"
|
||||
r"://[^:@\s]+:[^@\s]+@",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
_RE_ENV_SECRET_KEY = re.compile(
|
||||
@@ -109,7 +115,20 @@ _RE_ENV_SECRET_KEY = re.compile(
|
||||
_RE_JSON_SECRET = re.compile(
|
||||
r'"(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|'
|
||||
r"token|access_token|refresh_token|auth_token|private_key|"
|
||||
r'client_secret|webhook_secret|signing_key|encryption_key)"\s*:\s*"([^"]{8,})"',
|
||||
r"client_secret|webhook_secret|signing_key|encryption_key|"
|
||||
r"x_api_key|x-api-key|"
|
||||
r'authorization)"\s*:\s*"([^"]{8,})"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Single-quoted sibling — Python dict reprs / JS object literals emit single
|
||||
# quotes (e.g. {'Authorization': 'Bearer ...'}); the double-quoted form above
|
||||
# misses them. Same key set, same 8-char value floor, group(1) == value.
|
||||
_RE_JSON_SECRET_SQ = re.compile(
|
||||
r"'(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|"
|
||||
r"token|access_token|refresh_token|auth_token|private_key|"
|
||||
r"client_secret|webhook_secret|signing_key|encryption_key|"
|
||||
r"x_api_key|x-api-key|"
|
||||
r"authorization)'\s*:\s*'([^']{8,})'",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -121,9 +140,33 @@ _CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"gho_[a-zA-Z0-9]{36}"), "api_key"),
|
||||
(re.compile(r"AKIA[0-9A-Z]{16}"), "api_key"),
|
||||
(re.compile(r"AIza[a-zA-Z0-9_\-]{35}"), "api_key"),
|
||||
(re.compile(r"Bearer\s+[a-zA-Z0-9._~+/=\-]{20,}"), "api_key"),
|
||||
(re.compile(r"token=[a-zA-Z0-9]{20,}"), "api_key"),
|
||||
(re.compile(r"key=[a-zA-Z0-9]{20,}"), "api_key"),
|
||||
(re.compile(r"Bearer\s+[a-zA-Z0-9._~+/=\-]{20,}", re.IGNORECASE), "api_key"),
|
||||
# Specific credential key suffixes so these swallow the whole
|
||||
# access_token=/api_key=/secret_key=/auth_token= assignment instead of
|
||||
# chewing only the tail into a garbled "access_[REDACTED:api_key]", while
|
||||
# NOT matching innocent identifiers like monkey=, turkey=, over_tokenized=.
|
||||
# The trailing _? allows both snake_case and compact forms (api_key / apikey).
|
||||
# Bare key=/token= are included as alternatives so standalone assignments like
|
||||
# key=<20+ chars> still match.
|
||||
# Multi-segment keys like secret_access_key and aws_secret_access_key are
|
||||
# included explicitly so the prefix doesn't leak as "secret_".
|
||||
(
|
||||
re.compile(
|
||||
r"(?:(?:api|secret|session|auth|encryption|signing|private|public|access|"
|
||||
r"secret_access|aws_secret_access)_?key|"
|
||||
r"(?<![a-zA-Z0-9_])key)="
|
||||
r"[a-zA-Z0-9]{20,}"
|
||||
),
|
||||
"api_key",
|
||||
),
|
||||
(
|
||||
re.compile(
|
||||
r"(?:(?:access|refresh|auth|api|session|bearer|secret)_?token|"
|
||||
r"(?<![a-zA-Z0-9_])token)="
|
||||
r"[a-zA-Z0-9]{20,}"
|
||||
),
|
||||
"api_key",
|
||||
),
|
||||
]
|
||||
|
||||
# -- Priority 3: Encoded / obfuscated payloads (MEDIUM) --------------------
|
||||
@@ -412,7 +455,7 @@ _BUILTIN_OG_PATTERNS: list[OutputGuardPatternDef] = [
|
||||
name="credential_bearer",
|
||||
category="credentials",
|
||||
risk_level="high",
|
||||
compiled=re.compile(r"Bearer\s+[a-zA-Z0-9._~+/=\-]{20,}"),
|
||||
compiled=re.compile(r"Bearer\s+[a-zA-Z0-9._~+/=\-]{20,}", re.IGNORECASE),
|
||||
flag_name="credential_leak",
|
||||
annotation="Output contains what appears to be an API key or token.",
|
||||
is_credential=True,
|
||||
@@ -423,7 +466,11 @@ _BUILTIN_OG_PATTERNS: list[OutputGuardPatternDef] = [
|
||||
name="credential_token_param",
|
||||
category="credentials",
|
||||
risk_level="high",
|
||||
compiled=re.compile(r"token=[a-zA-Z0-9]{20,}"),
|
||||
compiled=re.compile(
|
||||
r"(?:(?:access|refresh|auth|api|session|bearer|secret)_?token|"
|
||||
r"(?<![a-zA-Z0-9_])token)="
|
||||
r"[a-zA-Z0-9]{20,}"
|
||||
),
|
||||
flag_name="credential_leak",
|
||||
annotation="Output contains what appears to be an API key or token.",
|
||||
is_credential=True,
|
||||
@@ -434,7 +481,12 @@ _BUILTIN_OG_PATTERNS: list[OutputGuardPatternDef] = [
|
||||
name="credential_key_param",
|
||||
category="credentials",
|
||||
risk_level="high",
|
||||
compiled=re.compile(r"key=[a-zA-Z0-9]{20,}"),
|
||||
compiled=re.compile(
|
||||
r"(?:(?:api|secret|session|auth|encryption|signing|private|public|access|"
|
||||
r"secret_access|aws_secret_access)_?key|"
|
||||
r"(?<![a-zA-Z0-9_])key)="
|
||||
r"[a-zA-Z0-9]{20,}"
|
||||
),
|
||||
flag_name="credential_leak",
|
||||
annotation="Output contains what appears to be an API key or token.",
|
||||
is_credential=True,
|
||||
@@ -545,8 +597,8 @@ def _check_credentials(
|
||||
|
||||
for pattern, _label in _CREDENTIAL_PATTERNS:
|
||||
if pattern.search(text):
|
||||
if "credential_leak" not in flags:
|
||||
flags.append("credential_leak")
|
||||
_add_flag(flags, "credential_leak")
|
||||
if "Output contains what appears to be an API key or token." not in ann:
|
||||
ann.append("Output contains what appears to be an API key or token.")
|
||||
found = True
|
||||
risk = "high"
|
||||
@@ -574,7 +626,7 @@ def _check_credentials(
|
||||
found = True
|
||||
risk = "high"
|
||||
|
||||
if _RE_JSON_SECRET.search(text):
|
||||
if _RE_JSON_SECRET.search(text) or _RE_JSON_SECRET_SQ.search(text):
|
||||
_add_flag(flags, "credential_leak")
|
||||
flags.append("json_secret_leak")
|
||||
ann.append(
|
||||
@@ -622,6 +674,7 @@ def _redact_credentials(text: str) -> str:
|
||||
return full[:start] + "[REDACTED:secret]" + full[end:]
|
||||
|
||||
result = _RE_JSON_SECRET.sub(_redact_json_secret, result)
|
||||
result = _RE_JSON_SECRET_SQ.sub(_redact_json_secret, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -816,7 +869,7 @@ def _check_credentials_complex(
|
||||
found = True
|
||||
risk = "high"
|
||||
|
||||
if _RE_JSON_SECRET.search(text):
|
||||
if _RE_JSON_SECRET.search(text) or _RE_JSON_SECRET_SQ.search(text):
|
||||
_add_flag(flags, "credential_leak")
|
||||
_add_flag(flags, "json_secret_leak")
|
||||
ann.append(
|
||||
@@ -855,6 +908,7 @@ def _redact_credentials_complex(text: str) -> str:
|
||||
return full[:start] + "[REDACTED:secret]" + full[end:]
|
||||
|
||||
result = _RE_JSON_SECRET.sub(_redact_json_secret, result)
|
||||
result = _RE_JSON_SECRET_SQ.sub(_redact_json_secret, result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,39 @@ class PersonaSnapshot:
|
||||
}
|
||||
|
||||
|
||||
def _enabled_personas(storage: Any) -> list[dict[str, Any]]:
|
||||
"""Enabled persona rows, or ``[]`` when listing fails.
|
||||
|
||||
Swallowing here keeps the forgiving-lookup and error-enrichment paths
|
||||
from introducing raise paths the exact-match lookup never had (the CLI
|
||||
calls ``resolve_persona_for_kind`` uncaught).
|
||||
"""
|
||||
try:
|
||||
return list(storage.list_personas())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def persona_names_for_kind(storage: Any, kind: str) -> list[str]:
|
||||
"""Enabled persona names applying to ``kind`` — default first, then A→Z.
|
||||
|
||||
The default carries a ``" (default)"`` suffix so error text and tool
|
||||
descriptions read the same way everywhere.
|
||||
"""
|
||||
rows = [r for r in _enabled_personas(storage) if kind in (r.get("applies_to_kinds") or [])]
|
||||
rows.sort(key=lambda r: (not r.get("is_default"), str(r.get("name") or "")))
|
||||
return [
|
||||
str(r["name"]) + (" (default)" if r.get("is_default") else "")
|
||||
for r in rows
|
||||
if r.get("name")
|
||||
]
|
||||
|
||||
|
||||
def _available_for_kind(storage: Any, kind: str) -> str:
|
||||
names = persona_names_for_kind(storage, kind)
|
||||
return f" Available for {kind}: {', '.join(names)}." if names else ""
|
||||
|
||||
|
||||
def resolve_persona_for_kind(
|
||||
storage: Any, name: str, kind: str
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
@@ -79,14 +112,57 @@ def resolve_persona_for_kind(
|
||||
(per-org personas, a new kind) cannot leave the surfaces disagreeing.
|
||||
``storage is None`` reports a distinct storage-unavailable error — a
|
||||
storage outage must never masquerade as "unknown persona".
|
||||
|
||||
Lookup is forgiving: exact name first (stored names are lowercase slugs,
|
||||
create-path validated), then the lowercased input, then a case-insensitive
|
||||
match on display names. ``display_name`` carries no uniqueness
|
||||
constraint, so the fallback is deliberately narrow: candidates are the
|
||||
ENABLED personas ELIGIBLE FOR ``kind`` (the label the caller saw came
|
||||
from a kind-filtered surface — picker or injected tool description — so
|
||||
a same-label persona of another kind must neither block nor win), and
|
||||
the match is accepted only when exactly one candidate remains; duplicates
|
||||
refuse loudly, naming the candidate slugs. Callers must stamp/emit the
|
||||
returned row's ``name``, never the input, so a forgiven variant can't
|
||||
leak into ``workstream_config`` or approval chrome. Failure messages
|
||||
enumerate the kind's valid names: tool descriptions render the persona
|
||||
list at session start, so this is how a caller with a stale list (or a
|
||||
typo) self-corrects.
|
||||
"""
|
||||
if storage is None:
|
||||
return None, "persona storage unavailable"
|
||||
row = storage.get_persona_by_name(name)
|
||||
wanted = name.strip()
|
||||
row = storage.get_persona_by_name(wanted)
|
||||
if row is None and wanted != wanted.lower():
|
||||
row = storage.get_persona_by_name(wanted.lower())
|
||||
if row is None and wanted:
|
||||
# The non-empty gate is load-bearing: display_name defaults to "", so
|
||||
# a whitespace-only input would otherwise match every blank-labelled
|
||||
# persona and silently stamp an envelope the caller never named.
|
||||
target = wanted.lower()
|
||||
matches = [
|
||||
r
|
||||
for r in _enabled_personas(storage)
|
||||
if kind in (r.get("applies_to_kinds") or [])
|
||||
and str(r.get("display_name") or "").strip().lower() == target
|
||||
]
|
||||
if len(matches) == 1:
|
||||
row = matches[0]
|
||||
elif len(matches) > 1:
|
||||
slugs = ", ".join(sorted(str(m["name"]) for m in matches))
|
||||
return None, (
|
||||
f"Persona name {name!r} matches more than one display name "
|
||||
f"(personas: {slugs}); use the exact name"
|
||||
)
|
||||
if not row or not row.get("enabled", False):
|
||||
return None, f"Persona not found or disabled: {name}"
|
||||
# ``!r`` matters: forgiven inputs include whitespace-only and
|
||||
# trailing-space typos, which an unquoted interpolation renders
|
||||
# invisible in CLI output and logs.
|
||||
return None, f"Persona not found or disabled: {name!r}.{_available_for_kind(storage, kind)}"
|
||||
if kind not in (row.get("applies_to_kinds") or []):
|
||||
return None, f"Persona {name!r} does not apply to kind {kind!r}"
|
||||
return None, (
|
||||
f"Persona {row['name']!r} does not apply to kind {kind!r}."
|
||||
f"{_available_for_kind(storage, kind)}"
|
||||
)
|
||||
return row, ""
|
||||
|
||||
|
||||
|
||||
+222
-54
@@ -60,6 +60,9 @@ from turnstone.core.lowering import (
|
||||
drop_empty_user_turns,
|
||||
fold_system_turns,
|
||||
repair_wire_messages,
|
||||
sanitize_tool_call_arguments,
|
||||
tool_args_preview,
|
||||
wire_valid_arguments,
|
||||
)
|
||||
from turnstone.core.memory import (
|
||||
count_messages,
|
||||
@@ -148,6 +151,7 @@ from turnstone.core.tools import (
|
||||
PRIMARY_KEY_MAP,
|
||||
TASK_AGENT_TOOLS,
|
||||
TASK_AUTO_TOOLS,
|
||||
TOOLS,
|
||||
merge_mcp_tools,
|
||||
)
|
||||
from turnstone.core.trajectory import (
|
||||
@@ -2488,61 +2492,180 @@ class ChatSession:
|
||||
self._render_agent_tool_descriptions()
|
||||
self._rebuild_tool_search()
|
||||
|
||||
def _render_agent_tool_descriptions(self) -> None:
|
||||
"""Inject the live alias list into the ``model`` parameter description
|
||||
on the task_agent tool.
|
||||
# Tools whose ``persona`` parameter names an interactive-kind persona —
|
||||
# task_agent sub-agents and spawned children are always interactive.
|
||||
_PERSONA_ARG_TOOLS = ("task_agent", "spawn_workstream", "spawn_batch")
|
||||
|
||||
Lets the calling LLM see which aliases are valid right now.
|
||||
Called on session init and on registry reload (via
|
||||
``refresh_agent_tool_schemas``). No-op when no registry is
|
||||
configured (CLI single-model case).
|
||||
def _persona_catalog_line(self) -> str:
|
||||
"""One sentence enumerating the enabled interactive-kind personas.
|
||||
|
||||
This is the persona DISCOVERY path for calling LLMs: without it a
|
||||
coordinator or interactive agent has no way to learn which names the
|
||||
``persona=`` argument accepts. Returns ``""`` when storage is not up
|
||||
(never auto-initializes it — this runs at session construction),
|
||||
listing fails, or nothing applies; the resolve-time error, which
|
||||
enumerates the live names, remains the self-correction path for lists
|
||||
rendered before a persona edit.
|
||||
"""
|
||||
from turnstone.core.storage import get_storage, is_storage_initialized
|
||||
|
||||
if not is_storage_initialized():
|
||||
return ""
|
||||
try:
|
||||
rows = [
|
||||
r
|
||||
for r in get_storage().list_personas()
|
||||
if "interactive" in (r.get("applies_to_kinds") or [])
|
||||
]
|
||||
except Exception:
|
||||
log.debug("persona_catalog.list_failed", exc_info=True)
|
||||
return ""
|
||||
if not rows:
|
||||
return ""
|
||||
rows.sort(key=lambda r: (not r.get("is_default"), str(r.get("name") or "")))
|
||||
# Descriptions help the model pick by purpose, but an operator shelf
|
||||
# with dozens of personas would bloat every request — past 25, names
|
||||
# still enumerate completely and only the prose is dropped.
|
||||
include_desc = len(rows) <= 25
|
||||
parts = []
|
||||
for r in rows:
|
||||
entry = f"`{r['name']}`"
|
||||
if r.get("is_default"):
|
||||
entry += " (default)"
|
||||
if include_desc:
|
||||
desc = " ".join(str(r.get("description") or "").split())
|
||||
if len(desc) > 96:
|
||||
desc = desc[:95].rstrip() + "…"
|
||||
if desc:
|
||||
entry += f" — {desc}"
|
||||
parts.append(entry)
|
||||
return "Available personas: " + "; ".join(parts) + "."
|
||||
|
||||
def _render_agent_tool_descriptions(self) -> None:
|
||||
"""Inject live option lists into agent-tool parameter descriptions.
|
||||
|
||||
Two lists, one mechanism:
|
||||
|
||||
- model aliases → ``task_agent.model`` (skipped when no registry is
|
||||
configured — the CLI single-model case);
|
||||
- enabled interactive-kind personas → the ``persona`` parameter on
|
||||
task_agent / spawn_workstream / spawn_batch, so the calling LLM can
|
||||
discover valid names instead of guessing (children and sub-agents
|
||||
are always interactive-kind).
|
||||
|
||||
Lets the calling LLM see which values are valid right now. Called on
|
||||
session init and on registry reload (via
|
||||
``refresh_agent_tool_schemas``); personas listed reflect that render
|
||||
moment — resolve errors enumerate the live set, so a stale list
|
||||
self-corrects on the next attempt.
|
||||
|
||||
Replaces affected tool dicts with deep copies so the module-level
|
||||
tool-list constants stay untouched across sessions.
|
||||
tool-list constants stay untouched across sessions. Both rewrites
|
||||
rebuild their full description text every render (persona text from
|
||||
the pristine ``TOOLS`` base) — never append to the previous render's
|
||||
output, so a list that shrinks to nothing clears rather than
|
||||
lingering stale.
|
||||
|
||||
task_agent lives in ``self._tools`` (the main session's tool set) —
|
||||
not in ``self._task_tools``, which is what *sub-agents* see
|
||||
(sub-agents don't get delegation tools to avoid infinite recursion).
|
||||
"""
|
||||
if self._registry is None:
|
||||
return
|
||||
# Hide ``default`` from the alias list — the LLM reads the English
|
||||
# word and picks it explicitly, which routes to whichever model
|
||||
# carries that alias rather than the operator-configured per-role
|
||||
# default (task_alias). Omitting ``model=`` already selects the
|
||||
# per-role default; offering the literal name as an alternative
|
||||
# invites the bypass.
|
||||
aliases = sorted(a for a in self._registry.list_aliases() if a != "default")
|
||||
aliases: list[str] = []
|
||||
if self._registry is not None:
|
||||
aliases = sorted(a for a in self._registry.list_aliases() if a != "default")
|
||||
aliases_str = ", ".join(f"`{a}`" for a in aliases)
|
||||
persona_line = self._persona_catalog_line()
|
||||
persona_base: dict[str, str] = {}
|
||||
for tool in TOOLS:
|
||||
fn = tool.get("function") or {}
|
||||
if fn.get("name") in self._PERSONA_ARG_TOOLS:
|
||||
prop = self._persona_property(fn.get("parameters", {}).get("properties", {}))
|
||||
persona_base[fn["name"]] = (prop or {}).get("description", "")
|
||||
|
||||
# task_agent's model-alias description is tool-independent — compute
|
||||
# it once. Always the full text (never appended) so a reload that
|
||||
# drops every alias clears stale names rather than leaving them.
|
||||
if aliases:
|
||||
model_desc = (
|
||||
"Optional model alias to run this task_agent on. "
|
||||
"Omit to use the operator-configured task model. "
|
||||
f"Available aliases: {aliases_str}."
|
||||
)
|
||||
else:
|
||||
model_desc = (
|
||||
"Optional model alias to run this task_agent on. "
|
||||
"Omit to use the current session model. "
|
||||
"(No alternative aliases configured in this session.)"
|
||||
)
|
||||
|
||||
new_tools: list[dict[str, Any]] = []
|
||||
changed = False
|
||||
for tool in self._tools:
|
||||
fn = tool.get("function") or {}
|
||||
name = fn.get("name", "")
|
||||
if name != "task_agent":
|
||||
props = fn.get("parameters", {}).get("properties", {})
|
||||
rewrite_model = name == "task_agent" and self._registry is not None and "model" in props
|
||||
persona_prop = (
|
||||
self._persona_property(props) if name in self._PERSONA_ARG_TOOLS else None
|
||||
)
|
||||
persona_desc = ""
|
||||
if persona_prop is not None:
|
||||
base = persona_base.get(name, "")
|
||||
persona_desc = f"{base} {persona_line}".strip() if persona_line else base
|
||||
# Decide whether anything differs BEFORE copying. An idempotent
|
||||
# render — nothing to inject, or the same aliases and personas as
|
||||
# last time — must neither fork ``self._tools`` nor deepcopy the
|
||||
# tool, so such sessions keep sharing the pristine module-level
|
||||
# tool list (e.g. ``INTERACTIVE_TOOLS``). A reload that drops a
|
||||
# prior render's text differs here and takes the rewrite path.
|
||||
model_changed = rewrite_model and props["model"].get("description") != model_desc
|
||||
persona_changed = (
|
||||
persona_prop is not None and persona_prop.get("description") != persona_desc
|
||||
)
|
||||
if not model_changed and not persona_changed:
|
||||
new_tools.append(tool)
|
||||
continue
|
||||
new_tool = copy.deepcopy(tool)
|
||||
props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
|
||||
if "model" in props:
|
||||
# Always rewrite — a reload that filters down to no
|
||||
# alternatives (only ``default`` remains in the registry)
|
||||
# must clear any stale alias names left over from a prior
|
||||
# render, not return early and leave them in place.
|
||||
if aliases:
|
||||
props["model"]["description"] = (
|
||||
"Optional model alias to run this task_agent on. "
|
||||
"Omit to use the operator-configured task model. "
|
||||
f"Available aliases: {aliases_str}."
|
||||
)
|
||||
else:
|
||||
props["model"]["description"] = (
|
||||
"Optional model alias to run this task_agent on. "
|
||||
"Omit to use the current session model. "
|
||||
"(No alternative aliases configured in this session.)"
|
||||
)
|
||||
new_props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
|
||||
if model_changed:
|
||||
new_props["model"]["description"] = model_desc
|
||||
if persona_changed:
|
||||
new_prop = self._persona_property(new_props)
|
||||
if new_prop is not None:
|
||||
new_prop["description"] = persona_desc
|
||||
new_tools.append(new_tool)
|
||||
self._tools = new_tools
|
||||
changed = True
|
||||
# Reassign only when a description changed; a fully idempotent render
|
||||
# leaves ``self._tools`` (and any shared constant it points at)
|
||||
# untouched.
|
||||
if changed:
|
||||
self._tools = new_tools
|
||||
|
||||
@staticmethod
|
||||
def _persona_property(props: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Locate the ``persona`` schema dict inside a tool's properties.
|
||||
|
||||
task_agent and spawn_workstream carry it top-level; spawn_batch
|
||||
nests it per-child under ``children.items.properties``. Returns the
|
||||
live (mutable) dict so the render loop can rewrite its description,
|
||||
or ``None`` when the tool has no persona parameter.
|
||||
"""
|
||||
top = props.get("persona")
|
||||
if isinstance(top, dict):
|
||||
return top
|
||||
# Every isinstance gate matters: name-colliding MCP tools ride the
|
||||
# same merged list, and list-form ``items`` is legal JSON Schema.
|
||||
children = props.get("children")
|
||||
items = children.get("items") if isinstance(children, dict) else None
|
||||
sub = items.get("properties") if isinstance(items, dict) else None
|
||||
nested = sub.get("persona") if isinstance(sub, dict) else None
|
||||
return nested if isinstance(nested, dict) else None
|
||||
|
||||
def refresh_agent_tool_schemas(self) -> None:
|
||||
"""Public entry point: re-render the task_agent tool
|
||||
@@ -4287,11 +4410,19 @@ class ChatSession:
|
||||
*after* the fold so the fold-path wake turn, which the nudge folds into
|
||||
and thereby fills, is kept.
|
||||
|
||||
Before that, :func:`turnstone.core.lowering.sanitize_tool_call_arguments`
|
||||
legalizes any tool-call ``arguments`` that isn't a JSON-object string (a
|
||||
model can emit an unterminated one with a non-``length`` finish reason, and
|
||||
a strict renderer like vLLM's ``deepseek_v4`` ``json.loads`` it and 400s the
|
||||
whole request) — on the wire copy only, so the canonical trajectory keeps
|
||||
the raw output.
|
||||
|
||||
Finally, :func:`turnstone.core.lowering.repair_wire_messages`
|
||||
synthesizes cancellation results for any orphaned client tool calls so
|
||||
the provider translator (the ``C`` layer) never sees an unanswered
|
||||
tool call — this is the sole send-time orphan repair; the translators
|
||||
carry none. Identity-preserving when nothing is orphaned.
|
||||
carry none. Both final passes are identity-preserving when there is
|
||||
nothing to fix.
|
||||
"""
|
||||
# The lowering passes (fold / drop / repair) are dict-native and the
|
||||
# provider translators consume the same dict projection, so the wire prep
|
||||
@@ -4312,7 +4443,8 @@ class ChatSession:
|
||||
nonce=self._envelope_nonce,
|
||||
)
|
||||
dropped = drop_empty_user_turns(folded)
|
||||
return repair_wire_messages(dropped)
|
||||
legalized = sanitize_tool_call_arguments(dropped)
|
||||
return repair_wire_messages(legalized)
|
||||
|
||||
def _emit_state(self, state: str) -> None:
|
||||
"""Notify UI of a workstream state transition.
|
||||
@@ -6545,11 +6677,29 @@ class ChatSession:
|
||||
|
||||
if tool_calls_acc:
|
||||
self._ensure_tool_call_ids(tool_calls_acc)
|
||||
msg["tool_calls"] = [tool_calls_acc[i] for i in sorted(tool_calls_acc)]
|
||||
ordered = [tool_calls_acc[i] for i in sorted(tool_calls_acc)]
|
||||
msg["tool_calls"] = ordered
|
||||
# Non-destructive integrity signal: the length-guard above drops tool
|
||||
# calls only on ``finish_reason == "length"``, so a model that emits
|
||||
# invalid-JSON arguments with a ``stop`` / ``tool_calls`` finish reason
|
||||
# commits them verbatim. We keep the raw output (the canonical Turn
|
||||
# stays a faithful record; the wire copy is legalized by
|
||||
# ``lowering.sanitize_tool_call_arguments``) and only flag it here — so a
|
||||
# model-quality problem is visible at the moment it happens, not merely
|
||||
# as a downstream wire legalization on every replay.
|
||||
for tc in ordered:
|
||||
raw_args = tc["function"].get("arguments")
|
||||
if not wire_valid_arguments(raw_args):
|
||||
log.warning(
|
||||
"stream.tool_args_malformed",
|
||||
tool=tc["function"].get("name", "?"),
|
||||
call_id=tc.get("id", ""),
|
||||
raw_preview=tool_args_preview(raw_args),
|
||||
)
|
||||
log.info(
|
||||
"stream.tool_calls",
|
||||
count=len(tool_calls_acc),
|
||||
tools=[tool_calls_acc[i]["function"]["name"] for i in sorted(tool_calls_acc)],
|
||||
count=len(ordered),
|
||||
tools=[tc["function"]["name"] for tc in ordered],
|
||||
)
|
||||
|
||||
# Store raw provider content blocks for multi-turn preservation
|
||||
@@ -9811,6 +9961,10 @@ class ChatSession:
|
||||
"unavailable). Retry, or omit `persona` for the default identity."
|
||||
),
|
||||
}
|
||||
# Canonical slug from the resolved row — forgiving resolution may
|
||||
# have matched a case variant or a display name, and the approval
|
||||
# header + item stamp must carry the persona's real name.
|
||||
persona_arg = snap.name
|
||||
persona_prompt = snap.prompt
|
||||
persona_tools = snap.tools
|
||||
persona_mcp = snap.mcp
|
||||
@@ -10564,7 +10718,7 @@ class ChatSession:
|
||||
target_node = self._flatten_spawn_arg(args.get("target_node"), 64)
|
||||
persona = self._flatten_spawn_arg(args.get("persona"), 64)
|
||||
if persona:
|
||||
persona_err = self._validate_child_persona(persona)
|
||||
persona, persona_err = self._validate_child_persona(persona)
|
||||
if persona_err:
|
||||
return self._coord_tool_error(call_id, "spawn_workstream", persona_err)
|
||||
if skill:
|
||||
@@ -10605,26 +10759,32 @@ class ChatSession:
|
||||
"persona": persona,
|
||||
}
|
||||
|
||||
def _validate_child_persona(self, persona: str) -> str:
|
||||
def _validate_child_persona(self, persona: str) -> tuple[str, str]:
|
||||
"""Prep-time gate for a spawn's ``persona`` arg.
|
||||
|
||||
Returns an error string (empty = valid). Children are always
|
||||
``kind=interactive`` — see ``CoordinatorClient.spawn``. The
|
||||
receiving node's create handler re-resolves through the SAME
|
||||
shared rule (``resolve_persona_for_kind``) and stamps; this gate
|
||||
just turns an inevitable HTTP 400 into a clean tool error the
|
||||
model can react to. Best-effort: a storage blip defers the
|
||||
verdict to the create handler rather than blocking the spawn.
|
||||
Returns ``(canonical_name, error)`` — an empty error means valid, and
|
||||
``canonical_name`` is the resolved row's slug (forgiving resolution
|
||||
accepts case variants and unique display names, but the wire, the
|
||||
preview chrome, and the child's stamp must carry the real name).
|
||||
Children are always ``kind=interactive`` — see
|
||||
``CoordinatorClient.spawn``. The receiving node's create handler
|
||||
re-resolves through the SAME shared rule
|
||||
(``resolve_persona_for_kind``) and stamps; this gate just turns an
|
||||
inevitable HTTP 400 into a clean tool error the model can react to.
|
||||
Best-effort: a storage blip defers the verdict to the create handler
|
||||
rather than blocking the spawn (the raw name rides through).
|
||||
"""
|
||||
try:
|
||||
storage = get_storage()
|
||||
if storage is None:
|
||||
return ""
|
||||
_row, err = resolve_persona_for_kind(storage, persona, "interactive")
|
||||
return persona, ""
|
||||
row, err = resolve_persona_for_kind(storage, persona, "interactive")
|
||||
except Exception:
|
||||
log.debug("spawn.persona_precheck_failed persona=%s", persona, exc_info=True)
|
||||
return ""
|
||||
return err
|
||||
log.debug("spawn.persona_precheck_failed", persona=persona, exc_info=True)
|
||||
return persona, ""
|
||||
if err or row is None:
|
||||
return persona, err or f"unknown persona {persona!r}"
|
||||
return str(row["name"]), ""
|
||||
|
||||
def _exec_spawn_workstream(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
call_id = item["call_id"]
|
||||
@@ -10717,8 +10877,10 @@ class ChatSession:
|
||||
normalised: list[dict[str, Any]] = []
|
||||
preview_rows: list[str] = []
|
||||
# Persona prechecks hit storage; a fan-out batch usually repeats one
|
||||
# persona across all children, so memoize per prepare call.
|
||||
persona_verdicts: dict[str, str] = {}
|
||||
# persona across all children, so memoize per prepare call. Keyed by
|
||||
# the raw arg, valued ``(canonical_slug, error)`` — rows spelling the
|
||||
# same forgiven variant land on the same stamped name.
|
||||
persona_verdicts: dict[str, tuple[str, str]] = {}
|
||||
skill_verdicts: dict[str, str] = {}
|
||||
for idx, raw in enumerate(raw_children):
|
||||
if not isinstance(raw, dict):
|
||||
@@ -10749,8 +10911,14 @@ class ChatSession:
|
||||
# failing the whole batch.
|
||||
if persona not in persona_verdicts:
|
||||
persona_verdicts[persona] = self._validate_child_persona(persona)
|
||||
if persona_verdicts[persona]:
|
||||
spec["_error"] = persona_verdicts[persona]
|
||||
canonical, persona_err = persona_verdicts[persona]
|
||||
if persona_err:
|
||||
spec["_error"] = persona_err
|
||||
else:
|
||||
# Preview chrome below reads the local too — keep both
|
||||
# on the canonical slug.
|
||||
persona = canonical
|
||||
spec["persona"] = canonical
|
||||
if skill and not spec.get("_error"):
|
||||
# Same principal-load-only risk gate as spawn_workstream,
|
||||
# surfaced per-row (partial-success) rather than failing the
|
||||
|
||||
@@ -1735,6 +1735,16 @@ def make_open_handler(
|
||||
return JSONResponse({"error": cfg.not_found_label}, status_code=404)
|
||||
ws_id = resolved
|
||||
|
||||
# Tenancy gate BEFORE the already-loaded shortcut and before
|
||||
# ``mgr.open`` rehydrates — otherwise ``open`` is a private-project
|
||||
# existence/metadata oracle (it returns the auto-titled name) and an
|
||||
# unauthorized resurrection of a closed private workstream into the
|
||||
# pool. Interactive wires ownership, coord wires project tenancy.
|
||||
if cfg.tenant_check is not None:
|
||||
err_tenant = await asyncio.to_thread(cfg.tenant_check, request, ws_id, mgr)
|
||||
if err_tenant is not None:
|
||||
return err_tenant
|
||||
|
||||
# Already-loaded shortcut — both kinds return the same
|
||||
# ``{ws_id, name, already_loaded: true}`` shape.
|
||||
existing = mgr.get(ws_id)
|
||||
|
||||
@@ -178,6 +178,13 @@ from turnstone.core.workstream import BULK_CLOSE_STATE_VALUES, WorkstreamKind
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# PostgreSQL rejects any tsvector larger than 1MB ("string is too long for
|
||||
# tsvector"), and search_history computes tsvectors inline per row — so one
|
||||
# oversized row would abort the whole scan and every search with it. Worst
|
||||
# case a tsvector runs ~4x its input (unique short lexemes + position data),
|
||||
# so 250K chars keeps even pathological rows safely under the limit.
|
||||
_FTS_INPUT_CAP_CHARS = 250_000
|
||||
|
||||
|
||||
def _resolve_pg_listen_url(override: str, sqlalchemy_url: str) -> str:
|
||||
"""Resolve the URL used by the dedicated LISTEN connection.
|
||||
@@ -1289,26 +1296,31 @@ class PostgreSQLBackend:
|
||||
scope_params["excl_ws"] = exclude_ws_id
|
||||
scope_params["excl_after"] = -1 if exclude_after is None else exclude_after
|
||||
with self._conn() as conn:
|
||||
# Use PostgreSQL full-text search if search_vector column exists
|
||||
# Full-text search over an inline tsvector (there is no indexed
|
||||
# search_vector column). The input is capped — see
|
||||
# _FTS_INPUT_CAP_CHARS — so a single giant row (multi-MB tool
|
||||
# dumps exist) cannot trip PostgreSQL's 1MB tsvector limit and
|
||||
# abort every search; oversized rows stay findable by their head.
|
||||
try:
|
||||
return list(
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT c.timestamp, c.ws_id, c.role, c.content, c.tool_name "
|
||||
"FROM conversations c "
|
||||
"WHERE to_tsvector('english', COALESCE(c.content, '')) "
|
||||
"WHERE to_tsvector('english', left(COALESCE(c.content, ''), :fts_cap)) "
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
# Exclude compaction-checkpoint markers (resume-only
|
||||
# summary artifacts); IS DISTINCT FROM is NULL-safe so
|
||||
# normal rows (_source NULL) are not dropped.
|
||||
"AND c._source IS DISTINCT FROM :compaction_source "
|
||||
+ scope_sql
|
||||
+ "ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
+ "ORDER BY ts_rank(to_tsvector('english', left(COALESCE(c.content, ''), :fts_cap)), "
|
||||
" plainto_tsquery('english', :query)) DESC "
|
||||
"LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{
|
||||
"query": query,
|
||||
"fts_cap": _FTS_INPUT_CAP_CHARS,
|
||||
"compaction_source": _COMPACTION_SOURCE,
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
@@ -1317,6 +1329,11 @@ class PostgreSQLBackend:
|
||||
).fetchall()
|
||||
)
|
||||
except Exception:
|
||||
# The failed statement aborted the connection's autobegun
|
||||
# transaction; PostgreSQL then refuses every command until a
|
||||
# rollback, so without this the fallback can never run
|
||||
# (InFailedSqlTransaction).
|
||||
conn.rollback()
|
||||
# Fallback to ILIKE
|
||||
return list(
|
||||
conn.execute(
|
||||
|
||||
@@ -320,8 +320,8 @@ def resolve_workstream_owner(
|
||||
is still not enforced here. The one row-level check this performs
|
||||
is PROJECT tenancy: a workstream attached to a *private* project is
|
||||
only reachable by the project's owner/members, the workstream's own
|
||||
creator, service-scope callers, and ``admin.cluster.inspect``
|
||||
holders — everyone else gets a 403 (see
|
||||
creator, and service-scope callers — everyone else, admins included,
|
||||
gets a 403 (see
|
||||
:class:`turnstone.core.auth.WorkstreamProjectVisibility`). Returns
|
||||
``(owner_user_id, None)`` on success — the persisted owner id,
|
||||
which attachments should be filed under so existing storage shape
|
||||
|
||||
@@ -12,9 +12,10 @@ VLLM_IMAGE=vllm/vllm-openai:latest # AMD: a ROCm vLLM build that targets gfx1
|
||||
LITELLM_TAG=main-stable
|
||||
|
||||
# === models (HF ids; vLLM streams them from the HF cache) ====================
|
||||
# NVIDIA (Blackwell) runs FP8 natively. AMD RDNA3.5 has no FP8 matmul — use the
|
||||
# bf16 qwen there (Qwen/Qwen3.6-27B) and expect bf16-speed.
|
||||
QWEN_MODEL=Qwen/Qwen3.6-27B-FP8
|
||||
# NVIDIA (Blackwell) runs NVFP4 natively — 4-bit weights halve memory vs FP8.
|
||||
# For AMD RDNA3.5, use the FP8 checkpoint (Qwen/Qwen3.6-27B-FP8) instead of NVFP4;
|
||||
# see the README for other Strix Halo adjustments.
|
||||
QWEN_MODEL=nvidia/Qwen3.6-27B-NVFP4
|
||||
GEMMA_MODEL=google/gemma-4-12b-it
|
||||
RERANKER_MODEL=Qwen/Qwen3-Reranker-4B
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ one port; the reranker is reached directly (it speaks the `/rerank` wire format)
|
||||
|
||||
| Service | Model | Role | Turnstone reaches it via |
|
||||
|---|---|---|---|
|
||||
| `vllm-qwen` | Qwen 3.6 27B (FP8) | reasoning + tools | LiteLLM `/v1/messages` (Anthropic) |
|
||||
| `vllm-qwen` | Qwen 3.6 27B (NVFP4) | reasoning + tools | LiteLLM `/v1/messages` (Anthropic) |
|
||||
| `vllm-gemma` | Gemma 4 12B | vision + audio (omni) | LiteLLM `/v1/chat/completions` (OpenAI) |
|
||||
| `vllm-reranker` | Qwen3-Reranker 4B | retrieval rerank | `:8002/rerank` (direct) |
|
||||
| `litellm` | — | gateway (both chat routes) | `:4000` |
|
||||
@@ -37,10 +37,10 @@ they're cached on disk.
|
||||
no equivalent in the Anthropic Messages API, so Turnstone gates audio roles to
|
||||
OpenAI-SDK providers. Vision works on either lane; audio works only here.
|
||||
|
||||
**Validated shape (GB10, 128 GiB):** qwen full **256K @ ~1.4×** (MTP spec-decode +
|
||||
runai_streamer), gemma full **131072 @ ~2×**, reranker **@ ~1.4×**; ~117/121 GiB
|
||||
used. Two hard rules on one unified-memory card (rationale in *Troubleshooting*):
|
||||
**drop the page cache before `up`**, and **start sequentially** (enforced via
|
||||
**Validated shape (GB10, 128 GiB):** qwen full **256K** (MTP spec-decode + NVFP4
|
||||
4-bit), gemma full **131072**, reranker; ~117/121 GiB used.
|
||||
Two hard rules on one unified-memory card (rationale in *Troubleshooting*): **drop
|
||||
the page cache before `up`**, and **start sequentially** (enforced via
|
||||
`depends_on: service_healthy`) so each model profiles against clean memory.
|
||||
|
||||
## Requirements
|
||||
@@ -71,8 +71,8 @@ watch -n5 'docker compose ps'
|
||||
Verify the KV pools and a round-trip on each route:
|
||||
|
||||
```sh
|
||||
docker compose logs vllm-qwen | grep "Maximum concurrency" # ~1.4x @ 262144
|
||||
docker compose logs vllm-gemma | grep "Maximum concurrency" # ~2x @ 131072
|
||||
docker compose logs vllm-qwen | grep "Maximum concurrency" # output varies by GPU/memory
|
||||
docker compose logs vllm-gemma | grep "Maximum concurrency"
|
||||
docker compose logs vllm-reranker | grep "Maximum concurrency"
|
||||
|
||||
# qwen — Anthropic route
|
||||
@@ -97,13 +97,8 @@ Same compose, with these changes:
|
||||
[kyuz0/amd-strix-halo-vllm-toolboxes](https://github.com/kyuz0/amd-strix-halo-vllm-toolboxes)
|
||||
or the TheRock-ROCm build in [hec-ovi/vllm-qwen](https://github.com/hec-ovi/vllm-qwen)
|
||||
— then set `VLLM_IMAGE` to it.
|
||||
2. **Qwen weights & loader** — gfx1151 has no FP8 matmul; recent ROCm/vLLM *can*
|
||||
load an FP8 checkpoint but compute falls back to BF16 speed and it's rough.
|
||||
Prefer BF16: set `QWEN_MODEL=Qwen/Qwen3.6-27B` in `.env`. Then, in the
|
||||
`vllm-qwen` command in `docker-compose.yml`, lower `--max-model-len` toward
|
||||
131072 (bf16 27B weights ≈ 54 GiB, less KV room) and remove the
|
||||
`--load-format runai_streamer` line (it may not help on ROCm; drop it if it
|
||||
errors). Those two are compose literals, not `.env` vars.
|
||||
2. **Qwen model** — use the original FP8 checkpoint: set
|
||||
`QWEN_MODEL=Qwen/Qwen3.6-27B-FP8` in `.env`.
|
||||
3. **GPU access** — ROCm doesn't use the `deploy:` nvidia reservation. In
|
||||
`docker-compose.yml`, **delete the `deploy:` block** on *each* vLLM service and
|
||||
replace it with:
|
||||
@@ -163,15 +158,18 @@ default = "qwen"
|
||||
|
||||
## Tuning notes
|
||||
|
||||
- **`runai_streamer` on qwen only.** It cut qwen's weight load **166 s → ~1 s**
|
||||
(~26×). But its streaming buffers add memory that breaks the *small* models'
|
||||
tight KV budgets ("No available memory for the cache blocks"), so gemma and the
|
||||
reranker use the default loader.
|
||||
- **MTP spec-decode on qwen** (`--speculative-config '{"method":"mtp",…}'`): qwen3.6
|
||||
has a built-in MTP head, giving ~1.6× decode (≈8→13 tok/s) at ~84% acceptance,
|
||||
no draft model. Pair with `--max-num-batched-tokens 8192`.
|
||||
- **qwen 0.50 default-KV holds full 256K (~1.4×).** `--kv-cache-dtype fp8` is the
|
||||
reserve lever (halves KV) if you need to give the others more room.
|
||||
- **`runai_streamer` on qwen only** (`--load-format runai_streamer`): streams
|
||||
weights from disk, cutting load time substantially vs the default loader. Use it
|
||||
ONLY on this big model — its streaming buffers add memory that can break the
|
||||
small models' tight KV budgets, so gemma and the reranker use the default
|
||||
loader.
|
||||
- **MTP spec-decode on qwen** (`--speculative-config '{"method":"mtp","num_speculative_tokens":2}'`): qwen3.6
|
||||
has a built-in MTP head for speculative decoding, no draft model needed. Pair
|
||||
with `--max-num-batched-tokens 8192`.
|
||||
- **`--max-num-seqs 8`**: this is a scheduler limit, not a memory allocator —
|
||||
bumping it allows more concurrent requests to be interleaved, improving
|
||||
throughput under load. Independent of the weight format; tune for your
|
||||
workload.
|
||||
- **Shape:** gemma-4-12B (full-quality perception, `sliding_window` keeps long-ctx
|
||||
KV cheap) + a light **4B** reranker fit alongside qwen; the 8B reranker or
|
||||
gemma-4-E4B are the levers if you need to trade quality for memory.
|
||||
@@ -180,9 +178,7 @@ default = "qwen"
|
||||
|
||||
**`No available memory for the cache blocks` (a model won't start).** Its util
|
||||
left no room for KV after weights. Raise that model's `--gpu-memory-utilization`,
|
||||
or free memory elsewhere (qwen is the big tenant — drop its util or add
|
||||
`--kv-cache-dtype fp8`). This is also what `runai_streamer` triggers on small
|
||||
models — keep it on qwen only.
|
||||
or free memory elsewhere (qwen is the big tenant — drop its util).
|
||||
|
||||
**`max seq len (X) larger than available KV cache (Y)`.** Same family: not enough
|
||||
KV for the context. First check you dropped the page cache before `up`; then raise
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
# repos; first `up` downloads them, then they're cached on disk under HF_CACHE).
|
||||
# This file targets NVIDIA/CUDA; for AMD ROCm see README.md.
|
||||
#
|
||||
# Validated on a GB10 Spark (128 GiB): qwen 256K @1.4x, gemma 131072 @2x,
|
||||
# reranker @1.4x, ~117/121 GiB used. Two hard rules on one unified-memory card
|
||||
# Validated on a GB10 Spark (128 GiB): qwen 256K, gemma 131072, reranker,
|
||||
# ~117/121 GiB used. Two hard rules on one unified-memory card
|
||||
# (see README -> Troubleshooting): drop the page cache before `up`, and start
|
||||
# sequentially (enforced via depends_on) so each model profiles clean memory.
|
||||
#
|
||||
@@ -20,15 +20,14 @@
|
||||
name: turnstone-inference
|
||||
|
||||
services:
|
||||
# --- reasoning + tools : Qwen 3.6 27B (MTP spec-decode, full 256K) ----------
|
||||
# runai_streamer cuts weight load ~26x (166s -> ~1s). Use it ONLY on this big
|
||||
# model: its streaming buffers add memory that breaks the small models' tight
|
||||
# KV budgets. Default KV holds 256K here; add --kv-cache-dtype fp8 if tight.
|
||||
# --- reasoning + tools : Qwen 3.6 27B (NVFP4, MTP spec-decode, full 256K) ----
|
||||
# max-num-seqs is a scheduler limit, not a memory allocator — bump from the
|
||||
# default 2 to 8 for better throughput under concurrent load.
|
||||
vllm-qwen:
|
||||
image: ${VLLM_IMAGE:-vllm/vllm-openai:latest}
|
||||
command:
|
||||
- --model
|
||||
- ${QWEN_MODEL:-Qwen/Qwen3.6-27B-FP8}
|
||||
- ${QWEN_MODEL:-nvidia/Qwen3.6-27B-NVFP4}
|
||||
- --served-model-name
|
||||
- qwen3.6-27b
|
||||
- --host
|
||||
@@ -45,22 +44,27 @@ services:
|
||||
- --max-num-batched-tokens
|
||||
- "8192"
|
||||
- --max-num-seqs
|
||||
- "2"
|
||||
- "8"
|
||||
- --enable-prefix-caching
|
||||
- --speculative-config
|
||||
- '{"method": "mtp", "num_speculative_tokens": 1}'
|
||||
- '{"method": "mtp", "num_speculative_tokens": 2}'
|
||||
- --reasoning-parser
|
||||
- qwen3
|
||||
- --enable-auto-tool-choice
|
||||
- --tool-call-parser
|
||||
- qwen3_coder
|
||||
- --default-chat-template-kwargs
|
||||
- '{"preserve_thinking": true}'
|
||||
- '{"preserve_thinking": true, "enable_thinking": true}'
|
||||
environment:
|
||||
HF_HOME: /hf
|
||||
HF_TOKEN: ${HF_TOKEN:-}
|
||||
TRITON_CACHE_DIR: /root/.triton/cache
|
||||
TORCHINDUCTOR_CACHE_DIR: /root/.cache/torch/inductor
|
||||
volumes:
|
||||
- ${HF_CACHE:-./hf-cache}:/hf
|
||||
- ${COMPILE_CACHE:-./compile-cache}/triton:/root/.triton/cache
|
||||
- ${COMPILE_CACHE:-./compile-cache}/torch:/root/.cache/torch/inductor
|
||||
- ${COMPILE_CACHE:-./compile-cache}/flashinfer:/root/.cache/flashinfer
|
||||
ports:
|
||||
- "${QWEN_PORT:-8000}:8000"
|
||||
healthcheck:
|
||||
@@ -78,7 +82,8 @@ services:
|
||||
capabilities: [gpu]
|
||||
restart: unless-stopped
|
||||
|
||||
# --- perception : Gemma 4 12B (vision + audio) via the OpenAI lane ----------
|
||||
# --- perception : Gemma 4 12B (vision + audio) via the OpenAI lane -----------
|
||||
# FP16 KV cache is fine here — sliding_window keeps KV cheap at 131K context.
|
||||
vllm-gemma:
|
||||
build:
|
||||
context: .
|
||||
@@ -134,7 +139,7 @@ services:
|
||||
capabilities: [gpu]
|
||||
restart: unless-stopped
|
||||
|
||||
# --- retrieval : Qwen3-Reranker 4B (Cohere/Jina /rerank, direct) ------------
|
||||
# --- retrieval : Qwen3-Reranker 4B (Cohere/Jina /rerank, direct) ---------------
|
||||
# Light 4B reranker so it co-resides with the full 12B perception model. Its
|
||||
# chat template ships in the repo (vLLM loads it); add --chat-template if your
|
||||
# build needs it explicitly.
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// never innerHTML. Builders return a detached element and let the caller append
|
||||
// + scroll, so they stay pane- and transport-agnostic.
|
||||
|
||||
import { redactCredentials } from "./redact_credentials.js";
|
||||
|
||||
// ANSI / CSI escape stripper — a tool that emits control sequences (bash through
|
||||
// MCP, or a child node) must land as readable text in the result block.
|
||||
// Null-safe: a non-string argument coerces to "" rather than throwing.
|
||||
@@ -241,7 +243,7 @@ export function buildConvRow(item, opts) {
|
||||
if (opts.argsText) {
|
||||
const args = document.createElement("span");
|
||||
args.className = "conv-row-args";
|
||||
args.textContent = opts.argsText;
|
||||
args.textContent = redactCredentials(opts.argsText);
|
||||
call.appendChild(args);
|
||||
}
|
||||
row.appendChild(call);
|
||||
@@ -264,9 +266,9 @@ export function buildConvCmd(item) {
|
||||
const dollar = document.createElement("span");
|
||||
dollar.className = "conv-row-cmd-dollar";
|
||||
dollar.textContent = "$ ";
|
||||
cmd.append(dollar, cmdText);
|
||||
cmd.append(dollar, redactCredentials(cmdText));
|
||||
} else {
|
||||
cmd.textContent = cmdText;
|
||||
cmd.textContent = redactCredentials(cmdText);
|
||||
}
|
||||
frag.appendChild(cmd);
|
||||
}
|
||||
@@ -280,8 +282,12 @@ export function buildConvCmd(item) {
|
||||
// list can throw RangeError mid-paint (engines cap spread arity around
|
||||
// 65k args), killing the tool card — and the approval gate — for the
|
||||
// batch. Appended incrementally for the same reason.
|
||||
// Redact credentials ONCE on the full text before splitting, rather than
|
||||
// running 7 regex sweeps per line (~2800 passes at 400 lines).
|
||||
const MAX_PREVIEW_LINES = 400;
|
||||
let lines = stripAnsi(item.preview).split("\n");
|
||||
const raw = stripAnsi(item.preview);
|
||||
const redacted = redactCredentials(raw);
|
||||
let lines = redacted.split("\n");
|
||||
const omitted = lines.length - MAX_PREVIEW_LINES;
|
||||
if (omitted > 0) lines = lines.slice(0, MAX_PREVIEW_LINES);
|
||||
lines.forEach((line, i) => {
|
||||
@@ -632,7 +638,7 @@ export function buildConvResult(output, opts) {
|
||||
" chars total — truncated for display)";
|
||||
}
|
||||
const body = document.createElement("span");
|
||||
body.textContent = pretty;
|
||||
body.textContent = redactCredentials(pretty);
|
||||
block.appendChild(body);
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
batchKicker,
|
||||
indexLabel,
|
||||
} from "./conversation.js";
|
||||
import { redactCredentials } from "./redact_credentials.js";
|
||||
import { authFetch } from "./auth.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { Composer } from "./composer.js";
|
||||
@@ -3656,22 +3657,6 @@ function _formatRuntime(item) {
|
||||
return h > 0 ? h + "h " + m + "m" : m + "m";
|
||||
}
|
||||
|
||||
function _redactApiKeys(text) {
|
||||
// Query-string style: api_key=VALUE
|
||||
let redacted = text.replace(
|
||||
/(?:api_key|apiKey|api-key|token)=[^&\s"]+/g,
|
||||
function (m) {
|
||||
return m.split("=")[0] + "=***";
|
||||
},
|
||||
);
|
||||
// JSON style: "api_key": "VALUE"
|
||||
redacted = redacted.replace(
|
||||
/(["'](?:api_key|apiKey|api-key|token)["']\s*:\s*["'])([^"']*)(['"])/gi,
|
||||
"$1***$3",
|
||||
);
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function _tryPrettyJson(text) {
|
||||
let obj;
|
||||
try {
|
||||
@@ -3679,7 +3664,7 @@ function _tryPrettyJson(text) {
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return _redactApiKeys(JSON.stringify(obj, null, 2));
|
||||
return redactCredentials(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4146,7 +4131,7 @@ function renderToolOutput(stripped, isError) {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.textContent = _redactApiKeys(stripped);
|
||||
out.textContent = redactCredentials(stripped);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -4181,7 +4166,7 @@ function buildMediaEmbed(media, rawJson) {
|
||||
// Collapsed raw JSON for inspection (with redacted API keys)
|
||||
const raw = document.createElement("div");
|
||||
raw.className = "tool-output";
|
||||
raw.textContent = _tryPrettyJson(rawJson) || _redactApiKeys(rawJson);
|
||||
raw.textContent = _tryPrettyJson(rawJson) || redactCredentials(rawJson);
|
||||
makeCollapsible(raw);
|
||||
wrapper.appendChild(raw);
|
||||
|
||||
@@ -4306,7 +4291,7 @@ function buildMcpErrorEmbed(err, rawJson, onConsent) {
|
||||
details.appendChild(summary);
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "tool-output";
|
||||
pre.textContent = _tryPrettyJson(rawJson) || _redactApiKeys(rawJson);
|
||||
pre.textContent = _tryPrettyJson(rawJson) || redactCredentials(rawJson);
|
||||
details.appendChild(pre);
|
||||
wrapper.appendChild(details);
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// redact_credentials.js — client-side credential redaction for tool call cards.
|
||||
//
|
||||
// Visual-only censorship of credentials in tool output BEFORE it hits the DOM.
|
||||
// Mirrors the backend patterns in turnstone/core/output_guard.py so the
|
||||
// frontend and backend redaction stay consistent.
|
||||
//
|
||||
// ES module — imported by conversation.js (shared substrate) and by
|
||||
// interactive.js directly (which also replaces its legacy _redactApiKeys).
|
||||
// Pure function, no DOM dependency, safe to test via `node -e`.
|
||||
//
|
||||
// Patterns (in order of application):
|
||||
// 1. PEM private key blocks → [REDACTED:private_key]
|
||||
// 2. Connection strings → user:[REDACTED:password]@host
|
||||
// 3. Well-known API key formats → [REDACTED:api_key]
|
||||
// (sk-proj-, sk-, ghp_, gho_, AKIA, AIza, Bearer token, token=, key=)
|
||||
// 4. Query-string api_key/token → key=*** (backward compat)
|
||||
// 5. JSON-style key/value → "key": "***" (backward compat)
|
||||
// 6. JSON secret keys → "secret": "[REDACTED:secret]"
|
||||
// 7. ENV secret lines → SECRET_KEY=[REDACTED:secret]
|
||||
//
|
||||
// A single prefilter scan (_RE_PREFILTER) bails out before all of the
|
||||
// above when the text cannot contain any credential — the common case
|
||||
// for plain-log tool output.
|
||||
//
|
||||
// House style: no innerHTML, no DOM access, no side-effects.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PEM private key blocks (multiline, whole-block replacement)
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_PRIVATE_KEY_BLOCK =
|
||||
/-----BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE\s+KEY-----/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection strings — preserves protocol + user, redacts only the password
|
||||
// postgresql://user:pass@host → postgresql://user:[REDACTED:password]@host
|
||||
// https://user:token@api.example.com → https://user:[REDACTED:password]@api.example.com
|
||||
// The optional +suffix covers SQLAlchemy dialect+driver URLs
|
||||
// (postgresql+psycopg2, postgresql+asyncpg, mysql+pymysql) and
|
||||
// mongodb+srv — enumerating drivers is a losing game, the suffix
|
||||
// shape isn't. Schemes are case-insensitive per RFC 3986 (/i):
|
||||
// POSTGRESQL:// leaks the same password postgresql:// does.
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_CONNECTION_STRING =
|
||||
/(?:postgresql|mysql|mongodb|rediss?|amqps?|sqlite|https?)(?:\+[a-z0-9]*)?:\/\/[^:@\s]+:[^@\s]+@/gi;
|
||||
|
||||
const _RE_CONN_USERINFO = /:\/\/([^:@\s]+):([^@\s]+)@/;
|
||||
|
||||
function _redactConnPassword(match) {
|
||||
return match.replace(_RE_CONN_USERINFO, "://$1:[REDACTED:password]@");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Well-known API key / token formats (ordered most-specific first)
|
||||
// ---------------------------------------------------------------------------
|
||||
const _CREDENTIAL_REPLACEMENTS = [
|
||||
// OpenAI project-scoped keys sk-proj-xxxxxxxxxx...
|
||||
[/sk-proj-[a-zA-Z0-9\-]{20,}/g, "[REDACTED:api_key]"],
|
||||
// OpenAI standard keys sk-xxxxxxxxxx...
|
||||
[/sk-[a-zA-Z0-9]{20,}/g, "[REDACTED:api_key]"],
|
||||
// GitHub personal access tokens ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
[/ghp_[a-zA-Z0-9]{36}/g, "[REDACTED:api_key]"],
|
||||
// GitHub OAuth tokens gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
[/gho_[a-zA-Z0-9]{36}/g, "[REDACTED:api_key]"],
|
||||
// AWS access key IDs AKIAxxxxxxxxxxxxxxxx
|
||||
[/AKIA[0-9A-Z]{16}/g, "[REDACTED:api_key]"],
|
||||
// Google API keys AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
[/AIza[a-zA-Z0-9_\-]{35}/g, "[REDACTED:api_key]"],
|
||||
// Bearer tokens — min 20 chars of JWT/opaque token (scheme is
|
||||
// case-insensitive per RFC 7235, so match bearer/BEARER too)
|
||||
[/Bearer\s+[a-zA-Z0-9._~+/=\-]{20,}/gi, "[REDACTED:api_key]"],
|
||||
// token=<value> (20+). Specific credential key prefixes only — not
|
||||
// unbounded [a-zA-Z0-9_]* which would match innocent identifiers
|
||||
// like "monkey=" or "turkey=". Bare "token=" included via negative
|
||||
// lookbehind so standalone assignments still match (token=abcdef...)
|
||||
// without matching word suffixes like "over_tokenized=".
|
||||
[
|
||||
/(?:(?:access|refresh|auth|api|session|bearer|secret)_?token|(?<![a-zA-Z0-9_])token)=[a-zA-Z0-9]{20,}/g,
|
||||
"[REDACTED:api_key]",
|
||||
],
|
||||
// key=<value> (20+). Same bounded prefix approach: api_key=/secret_key= etc.
|
||||
// but not monkey= or turkey=. Bare "key=" included with negative lookbehind.
|
||||
// Multi-segment keys secret_access_key / aws_secret_access_key included explicitly.
|
||||
[
|
||||
/(?:(?:api|secret|session|auth|encryption|signing|private|public|access|secret_access|aws_secret_access)_?key|(?<![a-zA-Z0-9_])key)=[a-zA-Z0-9]{20,}/g,
|
||||
"[REDACTED:api_key]",
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query-string api_key / token / secret / password / auth redaction
|
||||
// ?api_key=abc123 → ?api_key=*** (legacy _redactApiKeys compat)
|
||||
// &secret=value → &secret=***
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_QUERY_CRED =
|
||||
/(?:api_key|apiKey|api-key|(?<![a-zA-Z0-9_])token|secret|password|auth)=[^&\s"]+/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-style simple redaction (legacy _redactApiKeys compat)
|
||||
// {"api_key": "abc"} → {"api_key": "***"}
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_JSON_STYLE_CRED =
|
||||
/(["'](?:api_key|apiKey|api-key|token)["']\s*:\s*["'])([^"']*)(['"])/gi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON secret keys — comprehensive set matching backend
|
||||
// "api_key": "sk-abcdefghijklmnopqrst" → "api_key": "[REDACTED:secret]"
|
||||
// ---------------------------------------------------------------------------
|
||||
// Double-quoted form (standard JSON). The single-quoted sibling below covers
|
||||
// Python dict reprs / JS object literals, e.g. {'Authorization': 'Bearer ...'}.
|
||||
// $1 captures the key + colon + opening quote; only the value is replaced, so
|
||||
// the key stays intact even when value == key name. /i already covers casing,
|
||||
// so keys are listed once (no separate |Authorization alternative needed).
|
||||
const _RE_JSON_SECRET_DQ =
|
||||
/("(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|token|access_token|refresh_token|auth_token|private_key|client_secret|webhook_secret|signing_key|encryption_key|x_api_key|x-api-key|authorization)"\s*:\s*")[^"]{8,}"/gi;
|
||||
const _RE_JSON_SECRET_SQ =
|
||||
/('(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|token|access_token|refresh_token|auth_token|private_key|client_secret|webhook_secret|signing_key|encryption_key|x_api_key|x-api-key|authorization)'\s*:\s*')[^']{8,}'/gi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ENV secret line redaction — matches the backend's two-regex pipeline
|
||||
// SECRET_KEY=abc123 → SECRET_KEY=[REDACTED:secret]
|
||||
// DATABASE_URL=postgres://… → DATABASE_URL=[REDACTED:secret]
|
||||
// FOO=bar → not redacted (no secret-bearing key name)
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_ENV_SECRET_LINE = /[A-Z][A-Z_0-9]+=\S+/g;
|
||||
const _RE_ENV_SECRET_KEY =
|
||||
/(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|DSN)(?:_|$)|(?:^|_)KEY(?:_|$)|^(?:DATABASE_URL|TURNSTONE_DB_URL|DB_URL)$/i;
|
||||
|
||||
function _redactEnvLine(match) {
|
||||
const eqIdx = match.indexOf("=");
|
||||
if (eqIdx < 0) return match;
|
||||
const key = match.slice(0, eqIdx);
|
||||
if (_RE_ENV_SECRET_KEY.test(key)) {
|
||||
return key + "=[REDACTED:secret]";
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prefilter — one early-exit scan deciding whether the pipeline can match.
|
||||
// MUST remain a superset of every pattern above: each pattern requires at
|
||||
// least one of these substrings, so skipping on a prefilter miss is sound.
|
||||
// Anchor → patterns:
|
||||
// = env lines, key=/token= assignments, query-string creds
|
||||
// " ' JSON-style and JSON-secret forms
|
||||
// @ connection-string userinfo
|
||||
// -----BEGIN PEM private key blocks
|
||||
// sk- ghp_ gho_ AKIA AIza bearer well-known key prefixes ("bearer" is
|
||||
// case-insensitive per RFC 7235; /i over-approximates the
|
||||
// case-sensitive prefixes, which only costs a full scan)
|
||||
// Adding a pattern above without an anchor here is a SILENT REDACTION
|
||||
// BYPASS — extend this regex and the runtime smoke test together
|
||||
// (tests/test_app_js.py::test_redact_credentials_runtime_smoke).
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_PREFILTER = /[='"@]|-----BEGIN|sk-|ghp_|gho_|AKIA|AIza|bearer/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Redact known credential patterns in a string for display.
|
||||
*
|
||||
* Matches the backend's output_guard._redact_credentials patterns, applied
|
||||
* in priority order so more-specific patterns take precedence. Pure function,
|
||||
* no side-effects.
|
||||
*
|
||||
* @param {string} text - The raw text to redact
|
||||
* @returns {string} Text with credential values replaced by redaction markers
|
||||
*/
|
||||
export function redactCredentials(text) {
|
||||
if (!text) return text;
|
||||
|
||||
let result = String(text);
|
||||
|
||||
// Fast bailout — most tool output (plain logs, timestamps, table data)
|
||||
// carries no anchor substring; one early-exit scan skips the sixteen
|
||||
// replace passes below. Soundness argument lives on _RE_PREFILTER.
|
||||
if (!_RE_PREFILTER.test(result)) return result;
|
||||
|
||||
// 1. PEM private key blocks (whole-block removal)
|
||||
result = result.replace(_RE_PRIVATE_KEY_BLOCK, "[REDACTED:private_key]");
|
||||
|
||||
// 2. Connection string passwords (preserve user)
|
||||
result = result.replace(_RE_CONNECTION_STRING, _redactConnPassword);
|
||||
|
||||
// 3. Well-known API key / token formats
|
||||
for (const [re, replacement] of _CREDENTIAL_REPLACEMENTS) {
|
||||
result = result.replace(re, replacement);
|
||||
}
|
||||
|
||||
// 4. Query-string credential params (backward compat with _redactApiKeys)
|
||||
result = result.replace(_RE_QUERY_CRED, (m) => {
|
||||
const eq = m.indexOf("=");
|
||||
return eq >= 0 ? m.slice(0, eq) + "=***" : m;
|
||||
});
|
||||
|
||||
// 5. JSON-style simple redaction (backward compat with _redactApiKeys)
|
||||
// NOTE: runs BEFORE step 6 so small values (< 8 chars) under api_key/token
|
||||
// keys still get redacted. Authorization keys are intentionally omitted
|
||||
// here so step 6's comprehensive regex handles them with the full
|
||||
// [REDACTED:secret] marker instead.
|
||||
result = result.replace(_RE_JSON_STYLE_CRED, "$1***$3");
|
||||
|
||||
// 6. JSON secret key values (double- and single-quoted; backend-parity).
|
||||
// $1 is the key + colon + opening quote; only the value is replaced.
|
||||
result = result.replace(_RE_JSON_SECRET_DQ, '$1[REDACTED:secret]"');
|
||||
result = result.replace(_RE_JSON_SECRET_SQ, "$1[REDACTED:secret]'");
|
||||
|
||||
// 7. ENV secret lines
|
||||
result = result.replace(_RE_ENV_SECRET_LINE, _redactEnvLine);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -253,6 +253,68 @@ function postWsVerb(base, wsId, verb, body) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Pane accelerators -----------------------------------------------------
|
||||
// ONE source of truth for the pane keyboard shortcuts, shared by BOTH the
|
||||
// tab-menu key badges (below) and the keydown handler (in mountShell), so a
|
||||
// badge can never advertise a chord the handler doesn't actually listen for.
|
||||
//
|
||||
// The modifier is chosen per platform: Ctrl on macOS (the browser owns Cmd and
|
||||
// leaves Ctrl free) and Alt on Windows/Linux (there Ctrl IS the browser's own
|
||||
// new-tab / close-tab / switch-tab accelerator and never reaches the page).
|
||||
const IS_MAC =
|
||||
(navigator.platform && navigator.platform.indexOf("Mac") > -1) || false;
|
||||
const PANE_MOD_LABEL = IS_MAC ? "Ctrl" : "Alt";
|
||||
|
||||
// The per-pane menu actions that also carry a shortcut, keyed by a stable id
|
||||
// (the menu item's `accel`). `letter` is matched case-insensitively; `shift`
|
||||
// gates the Shift-family. Close-pane is the one non-Shift chord.
|
||||
const PANE_MENU_ACCELS = {
|
||||
"close-pane": { letter: "w", shift: false },
|
||||
"edit-title": { letter: "e", shift: true },
|
||||
"refresh-title": { letter: "r", shift: true },
|
||||
fork: { letter: "f", shift: true },
|
||||
delete: { letter: "x", shift: true },
|
||||
};
|
||||
|
||||
// The badge string for a menu accel, e.g. "Alt+Shift+E" — platform-correct.
|
||||
function paneAccelBadge(id) {
|
||||
const a = PANE_MENU_ACCELS[id];
|
||||
if (!a) return "";
|
||||
return (
|
||||
PANE_MOD_LABEL + (a.shift ? "+Shift" : "") + "+" + a.letter.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// True when `e` carries the pane modifier and no other primary modifier — on
|
||||
// Windows/Linux AltGr surfaces as Ctrl+Alt, so this keeps accented-character
|
||||
// entry (and the browser's own Ctrl chords) from firing pane shortcuts.
|
||||
function paneModDown(e) {
|
||||
return IS_MAC
|
||||
? e.ctrlKey && !e.altKey && !e.metaKey
|
||||
: e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
}
|
||||
|
||||
// Which per-pane menu accel (if any) a keydown triggers, else null.
|
||||
function paneAccelFor(e) {
|
||||
if (!paneModDown(e)) return null;
|
||||
const k = e.key.toLowerCase();
|
||||
for (const id in PANE_MENU_ACCELS) {
|
||||
const a = PANE_MENU_ACCELS[id];
|
||||
if (!!e.shiftKey === a.shift && k === a.letter) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when focus is in an editable element. On macOS Ctrl+T / Ctrl+D are the
|
||||
// Cocoa "transpose" / "delete-forward" text bindings, so each surface's
|
||||
// creation and dashboard chords must yield to text editing while a field is
|
||||
// focused. Exposed on TS_SHELL so both app.js surfaces share ONE definition.
|
||||
function inEditable(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||||
}
|
||||
|
||||
// Tab-action menu items for a conversational pane — the three-verb close plus
|
||||
// the per-persona verbs. Pane-type-derived AND deployment-aware, in two lanes:
|
||||
// the classic verb GLOBALS where they exist (the standalone's ui/static app.js,
|
||||
@@ -275,11 +337,15 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
if (typeof G.refreshWorkstreamTitle === "function")
|
||||
items.push({
|
||||
label: "Refresh title",
|
||||
accel: "refresh-title",
|
||||
key: paneAccelBadge("refresh-title"),
|
||||
action: () => G.refreshWorkstreamTitle(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Refresh title",
|
||||
accel: "refresh-title",
|
||||
key: paneAccelBadge("refresh-title"),
|
||||
action: () =>
|
||||
postWsVerb(base, wsId, "refresh-title")
|
||||
.then((r) =>
|
||||
@@ -292,12 +358,15 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
if (typeof G.editWorkstreamTitle === "function")
|
||||
items.push({
|
||||
label: "Edit title",
|
||||
key: "Ctrl+Shift+E",
|
||||
accel: "edit-title",
|
||||
key: paneAccelBadge("edit-title"),
|
||||
action: () => G.editWorkstreamTitle(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Edit title",
|
||||
accel: "edit-title",
|
||||
key: paneAccelBadge("edit-title"),
|
||||
action: () => {
|
||||
const f = findWs(wsId, false);
|
||||
const cur = (f && (f.ws.name || f.ws.title)) || "";
|
||||
@@ -319,7 +388,8 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
if (typeof G.forkWorkstream === "function")
|
||||
items.push({
|
||||
label: "Fork",
|
||||
key: "Ctrl+Shift+F",
|
||||
accel: "fork",
|
||||
key: paneAccelBadge("fork"),
|
||||
action: () => G.forkWorkstream(wsId),
|
||||
});
|
||||
}
|
||||
@@ -334,7 +404,8 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
// Close pane — drop the tab, leave the session running (PaneManager-level).
|
||||
items.push({
|
||||
label: "Close pane",
|
||||
key: "Ctrl+W",
|
||||
accel: "close-pane",
|
||||
key: paneAccelBadge("close-pane"),
|
||||
action: () => pm.close(pane.id),
|
||||
});
|
||||
// Close workstream — stop the session itself (distinct from closing the tab).
|
||||
@@ -346,13 +417,16 @@ function convTabMenu(pane, pm, wsId, opts) {
|
||||
if (typeof G.confirmDeleteWorkstream === "function")
|
||||
items.push({
|
||||
label: "Delete",
|
||||
key: "Ctrl+Shift+X",
|
||||
accel: "delete",
|
||||
key: paneAccelBadge("delete"),
|
||||
cls: "destructive",
|
||||
action: () => G.confirmDeleteWorkstream(wsId),
|
||||
});
|
||||
else if (base != null)
|
||||
items.push({
|
||||
label: "Delete",
|
||||
accel: "delete",
|
||||
key: paneAccelBadge("delete"),
|
||||
cls: "destructive",
|
||||
action: () => {
|
||||
if (!window.confirm("Delete this session? This cannot be undone."))
|
||||
@@ -510,6 +584,33 @@ async function mountShell() {
|
||||
});
|
||||
pm.onActiveChange(() => setDrawer(false));
|
||||
|
||||
// Pane keyboard accelerators (shared by every surface): the per-pane tab-menu
|
||||
// actions — close pane, edit/refresh title, fork, delete — driven off the
|
||||
// ACTIVE conversational pane's OWN menu, so the chord runs the exact action
|
||||
// its badge advertises and each surface contributes only the items it
|
||||
// supports (the console omits Fork; a non-conversational pane has no tabMenu
|
||||
// and is skipped). The global accels — new, switch, dashboard — live in each
|
||||
// surface's app.js. None of these chords overlap in-field text editing
|
||||
// (close is Mod+W; the rest are Mod+Shift+…), so no typing guard is needed.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (document.querySelector("dialog:modal")) return;
|
||||
const accel = paneAccelFor(e);
|
||||
if (!accel) return;
|
||||
const active = pm.getActive();
|
||||
if (!active) return;
|
||||
const pane = pm.getPane(active.type, active.rawId);
|
||||
if (!pane || typeof pane.tabMenu !== "function") return;
|
||||
let item;
|
||||
try {
|
||||
item = (pane.tabMenu() || []).find((it) => it.accel === accel);
|
||||
} catch (err) {
|
||||
return; // a pane whose menu throws simply has no accelerators
|
||||
}
|
||||
if (!item || typeof item.action !== "function") return;
|
||||
e.preventDefault();
|
||||
item.action();
|
||||
});
|
||||
|
||||
// Split controls (the revived split-view): they act on the FOCUSED pane.
|
||||
// Split right / split down open a second cell beside/below it, filled with
|
||||
// the most-recently-used backgrounded tab; Unsplit returns to one pane and
|
||||
@@ -574,7 +675,12 @@ async function mountShell() {
|
||||
pm.registerType("admin", () => {
|
||||
const pane = new ShellPane({ type: "admin", title: "Admin", glyph: "⚙" });
|
||||
pane.tabMenu = () => [
|
||||
{ label: "Close pane", key: "Ctrl+W", action: () => pm.close(pane.id) },
|
||||
{
|
||||
label: "Close pane",
|
||||
accel: "close-pane",
|
||||
key: paneAccelBadge("close-pane"),
|
||||
action: () => pm.close(pane.id),
|
||||
},
|
||||
];
|
||||
pane.onMount = function () {
|
||||
if (viewAdminEl) {
|
||||
@@ -911,7 +1017,13 @@ async function mountShell() {
|
||||
// in ui/static/app.js) stamp a count chip on a Manage row without importing the
|
||||
// ESM rail module — the shell is its module bridge. Generic: the rail owns the
|
||||
// chip mechanism, the caller owns what the count means.
|
||||
window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge };
|
||||
window.TS_SHELL = {
|
||||
panes: pm,
|
||||
caps,
|
||||
notifySessionClosed,
|
||||
setRowBadge,
|
||||
inEditable,
|
||||
};
|
||||
|
||||
// Login fan-out: app.js owns the single window.onLoginSuccess (the Tier-1
|
||||
// reconnect, set at load). Wrap it in a tiny registry so EVERY conversational
|
||||
|
||||
+37
-43
@@ -2098,6 +2098,20 @@ function _formatRelativeTimestamp(iso) {
|
||||
}
|
||||
}
|
||||
|
||||
// The GLOBAL pane accelerators — new workstream, switch, and dashboard. The
|
||||
// per-pane tab-menu actions (close pane, edit/refresh title, fork, delete) are
|
||||
// bound once in shell.js off the active pane's own menu, so they stay identical
|
||||
// across the standalone and the console; only these roster/shell-level chords
|
||||
// live here.
|
||||
//
|
||||
// The modifier is chosen per platform: Ctrl on macOS (the browser owns Cmd and
|
||||
// leaves Ctrl free) and Alt on Windows/Linux (there Ctrl IS the browser's own
|
||||
// new-tab / switch-tab accelerator and never reaches the page).
|
||||
const IS_MAC =
|
||||
(navigator.platform && navigator.platform.indexOf("Mac") > -1) || false;
|
||||
|
||||
// The typing guard (`inEditable`) lives on TS_SHELL — shell.js is the single
|
||||
// source of truth for these keyboard helpers, shared by both surfaces.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
// Defer while a document-modal hatch dialog is open — native dialogs own
|
||||
// their Escape, and global shortcuts must not fire under the top layer.
|
||||
@@ -2108,60 +2122,40 @@ document.addEventListener("keydown", function (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+D: toggle dashboard
|
||||
if (e.ctrlKey && e.key === "d") {
|
||||
// Ctrl+D: toggle dashboard. Left on Ctrl for every platform — it is
|
||||
// cancelable everywhere (the Cmd+D / Ctrl+D bookmark dialog), whereas Alt+D
|
||||
// is the browser's "focus the address bar" and can't be reclaimed. Yields to
|
||||
// text editing (macOS delete-forward) while a field is focused.
|
||||
if (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && e.key === "d") {
|
||||
if (window.TS_SHELL && window.TS_SHELL.inEditable(e.target)) return;
|
||||
e.preventDefault();
|
||||
toggleDashboard();
|
||||
return;
|
||||
}
|
||||
// Ctrl+T: new tab
|
||||
if (e.ctrlKey && e.key === "t") {
|
||||
|
||||
// The pane modifier: Ctrl on macOS, Alt elsewhere. Require it WITHOUT the
|
||||
// other primary modifier — on Windows/Linux AltGr surfaces as Ctrl+Alt, and
|
||||
// this guard keeps accented-character entry from firing Alt shortcuts.
|
||||
const paneMod = IS_MAC
|
||||
? e.ctrlKey && !e.altKey && !e.metaKey
|
||||
: e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!paneMod || e.shiftKey) return;
|
||||
|
||||
// <mod>+T: new workstream. Yields to text editing (macOS transpose) while a
|
||||
// field is focused.
|
||||
if (e.key.toLowerCase() === "t") {
|
||||
if (window.TS_SHELL && window.TS_SHELL.inEditable(e.target)) return;
|
||||
e.preventDefault();
|
||||
newWorkstream();
|
||||
return;
|
||||
}
|
||||
// Ctrl+1..9: switch tabs
|
||||
if (e.ctrlKey && e.key >= "1" && e.key <= "9") {
|
||||
// <mod>+1..9: switch workstreams. No text-binding overlap, so it works even
|
||||
// while composing — mirroring a browser's own Ctrl+1..9.
|
||||
if (e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(e.key) - 1;
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
const wsIds = Object.keys(workstreams);
|
||||
if (idx < wsIds.length) switchTab(wsIds[idx]);
|
||||
return;
|
||||
}
|
||||
// Workstream action shortcuts — only preventDefault when a workstream
|
||||
// is active, so native browser shortcuts (e.g. Ctrl+Shift+R hard reload)
|
||||
// still work when no workstream is focused.
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const wsActionKey = e.key.toLowerCase();
|
||||
const activeWsId = !dashboardVisible && getCurrentWsId();
|
||||
if (wsActionKey === "e" && activeWsId) {
|
||||
e.preventDefault();
|
||||
editWorkstreamTitle();
|
||||
return;
|
||||
}
|
||||
if (wsActionKey === "f" && activeWsId) {
|
||||
e.preventDefault();
|
||||
forkWorkstream();
|
||||
return;
|
||||
}
|
||||
// X not D — D conflicts with Chrome DevTools
|
||||
if (
|
||||
wsActionKey === "x" &&
|
||||
activeWsId &&
|
||||
Object.keys(workstreams).length > 1
|
||||
) {
|
||||
e.preventDefault();
|
||||
confirmDeleteWorkstream();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Ctrl+W: close current workstream tab
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === "w") {
|
||||
if (Object.keys(workstreams).length > 1) {
|
||||
e.preventDefault();
|
||||
closeWorkstream(getCurrentWsId());
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -557,25 +557,45 @@
|
||||
orchestration: false,
|
||||
brandSub: "server",
|
||||
};
|
||||
// Pane accelerators bind to Ctrl on macOS (where the browser owns Cmd
|
||||
// and leaves Ctrl free) and to Alt on Windows/Linux (where Ctrl is the
|
||||
// browser's own new-tab/close-tab/switch-tab accelerator). The overlay
|
||||
// labels must match whatever app.js actually listens for on this host.
|
||||
const PANE_MOD =
|
||||
navigator.platform && navigator.platform.indexOf("Mac") > -1
|
||||
? "Ctrl"
|
||||
: "Alt";
|
||||
window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{
|
||||
title: "Workstreams",
|
||||
keys: [
|
||||
{
|
||||
desc: "New workstream",
|
||||
badge: '<span class="kb-key">Ctrl+T</span>',
|
||||
badge: `<span class="kb-key">${PANE_MOD}+T</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Refresh title",
|
||||
badge: '<span class="kb-key">Ctrl+Shift+R</span>',
|
||||
desc: "Close pane",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+W</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Switch workstream",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+1</span>…<span class="kb-key">${PANE_MOD}+9</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Edit title",
|
||||
badge: '<span class="kb-key">Ctrl+Shift+E</span>',
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+E</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Refresh title",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+R</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Fork workstream",
|
||||
badge: '<span class="kb-key">Ctrl+Shift+F</span>',
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+F</span>`,
|
||||
},
|
||||
{
|
||||
desc: "Delete workstream",
|
||||
badge: `<span class="kb-key">${PANE_MOD}+Shift+X</span>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -615,6 +635,10 @@
|
||||
{
|
||||
title: "General",
|
||||
keys: [
|
||||
{
|
||||
desc: "Toggle dashboard",
|
||||
badge: '<span class="kb-key">Ctrl+D</span>',
|
||||
},
|
||||
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -193,7 +193,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.115.1"
|
||||
version = "0.116.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -205,9 +205,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/e2/e27b1e70b1ddcda72362d6a26c9c699a46173d48a6c7ba966e8cc97a6c4d/anthropic-0.115.1.tar.gz", hash = "sha256:040287319abb909acf1cc49d83c0405dc0b1121ef257034b02c2b54151cf2446", size = 949185, upload-time = "2026-07-01T21:54:19.05Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/3c/501c58a8f8c68811079e218a25d8672fe4fb9a650a4655e499e24d9375e9/anthropic-0.115.1-py3-none-any.whl", hash = "sha256:685fa94964c1b9428f6a76e42d0dbae49aa2016f5ad386a96becac04c5e80ef9", size = 957006, upload-time = "2026-07-01T21:54:17.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/dd/2a1e81cf1b163acc340afc4ec74ed1d86f5eed1a809fabdeed3e0997b346/anthropic-0.116.0-py3-none-any.whl", hash = "sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256", size = 956896, upload-time = "2026-07-02T19:08:08.756Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -410,72 +410,100 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -501,86 +529,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.14.3"
|
||||
version = "7.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -590,14 +618,14 @@ toml = [
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.2.2"
|
||||
version = "6.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/35/96ad0a71eb0b27ab4476a7ed23facd0713d82da9c911edc8af7f34a62d6a/croniter-6.2.3.tar.gz", hash = "sha256:fb129986ef7e2c44e3f4c9f503da83ad914d2afa48f40a43ee3dca4b5c41d476", size = 166174, upload-time = "2026-07-02T14:34:22.166Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/dd/6466498a8b69754cffbd7237ce4c66446ca5ffcf53fb397d437666f056d2/croniter-6.2.3-py3-none-any.whl", hash = "sha256:137a97001b4d52fb71c10b750e303db79e6e42d40fff8ff77126102176c9f786", size = 46446, upload-time = "2026-07-02T14:34:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2447,7 +2475,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.7.0rc1"
|
||||
version = "1.7.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2541,11 +2569,11 @@ provides-extras = ["test", "dev", "discord", "slack", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2571,15 +2599,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.49.0"
|
||||
version = "0.50.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user