mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78f7b12c2d | |||
| 647939fe4d | |||
| 457b01737a | |||
| a40ff249ec | |||
| 36419a9809 | |||
| 0c2c534c86 | |||
| 5fded65b82 | |||
| 7da731cbe1 | |||
| 8ed86ae7ab | |||
| ed30e4f0bf | |||
| 62f62ae624 | |||
| 5ae6c2316f | |||
| d793adb24c | |||
| 3cf94dd80f | |||
| 0422f9214a | |||
| 4428e185e5 | |||
| c5ff3147ce | |||
| bfcfb0c791 | |||
| cbf5c5f3b6 | |||
| 31a1d5c3ee | |||
| 93a7486cc2 | |||
| 56624f9597 | |||
| 16a68ae6d6 | |||
| 2d4cb6fea9 |
@@ -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 }}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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.
|
||||
|
||||
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"
|
||||
|
||||
+170
-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,76 @@ 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"
|
||||
)
|
||||
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 +1800,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 == {}
|
||||
|
||||
@@ -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,47 @@ 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_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")
|
||||
|
||||
+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
@@ -96,7 +96,7 @@ _RE_PRIVATE_KEY_BLOCK = re.compile(
|
||||
# [^@\s]+@`` is specific enough that ``https://example.com:8080/path``
|
||||
# (host:port without ``@``) doesn't match.
|
||||
_RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb|redis|amqp|sqlite|https?)"
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|sqlite|https?)"
|
||||
r"://[^:@\s]+:[^@\s]+@",
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
@@ -109,7 +109,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 +134,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 +449,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 +460,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 +475,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 +591,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 +620,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 +668,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 +863,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 +902,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, ""
|
||||
|
||||
|
||||
|
||||
+181
-36
@@ -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,43 +2492,114 @@ 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", "")
|
||||
|
||||
new_tools: list[dict[str, Any]] = []
|
||||
for tool in self._tools:
|
||||
fn = tool.get("function") or {}
|
||||
name = fn.get("name", "")
|
||||
if name != "task_agent":
|
||||
rewrite_model = name == "task_agent" and self._registry is not None
|
||||
rewrite_persona = name in self._PERSONA_ARG_TOOLS
|
||||
if not rewrite_model and not rewrite_persona:
|
||||
new_tools.append(tool)
|
||||
continue
|
||||
new_tool = copy.deepcopy(tool)
|
||||
props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
|
||||
if "model" in props:
|
||||
if rewrite_model and "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
|
||||
@@ -2541,9 +2616,34 @@ class ChatSession:
|
||||
"Omit to use the current session model. "
|
||||
"(No alternative aliases configured in this session.)"
|
||||
)
|
||||
if rewrite_persona:
|
||||
prop = self._persona_property(props)
|
||||
if prop is not None:
|
||||
base = persona_base.get(name, "")
|
||||
prop["description"] = f"{base} {persona_line}".strip() if persona_line else base
|
||||
new_tools.append(new_tool)
|
||||
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
|
||||
description to reflect the current ModelRegistry state, and
|
||||
@@ -4287,11 +4387,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 +4420,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 +6654,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 +9938,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 +10695,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 +10736,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 +10854,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 +10888,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)
|
||||
|
||||
@@ -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,174 @@
|
||||
// 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]
|
||||
//
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
const _RE_CONNECTION_STRING =
|
||||
/(?:postgresql\+?(?:psycopg)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|sqlite|https?):\/\/[^:@\s]+:[^@\s]+@/g;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
|
||||
// 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]]
|
||||
@@ -801,9 +829,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" },
|
||||
@@ -811,9 +837,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||
@@ -821,9 +845,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||
@@ -831,9 +853,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||
@@ -841,18 +861,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||
@@ -860,9 +876,7 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||
@@ -2541,11 +2555,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 +2585,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