mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c35e9e29 | |||
| 862eb99cdb | |||
| 25b97bebdf | |||
| ee5ca9a242 | |||
| dd8543fce9 | |||
| 667942024f | |||
| 78831bbe91 | |||
| d44d7eb1a8 | |||
| 876c7d8cb3 | |||
| 98823eb769 | |||
| 4d708c30ac | |||
| 6d60ff7634 | |||
| be662c6134 | |||
| 3ef3f24c7f | |||
| db903f482a | |||
| 6aeffd1845 | |||
| a02b093733 | |||
| f311555026 | |||
| 45d95a2c1f | |||
| a2d9d9832a | |||
| ab123c6cfc | |||
| 8ad17666b9 | |||
| 03fc0861a6 | |||
| a22fb2f395 | |||
| cdcd040da2 | |||
| 834d62c9d4 | |||
| 342a77fe5c | |||
| fd7a447ef9 | |||
| 552ee3c590 | |||
| e99d3ee139 | |||
| 4f0fc3f219 | |||
| dc701986f7 | |||
| bedd25fbe7 | |||
| 251a912275 | |||
| d48902fd01 | |||
| 702ac43d0e | |||
| 01f83dc90f | |||
| 2463c480c2 | |||
| 2a3dfbc6fb | |||
| 6c3b3cc098 | |||
| 0dc52f05ee | |||
| 02929c0d00 | |||
| b2add19c56 | |||
| 5ce1873e9e | |||
| 7698a928c5 | |||
| 4e2eea2f86 | |||
| a6752cb645 | |||
| 06ba4e8d4f | |||
| 94dcaf34fd | |||
| 1a2a689033 | |||
| c02f960d0a | |||
| bfde387206 | |||
| 27d112ff60 | |||
| aeab2535b1 | |||
| 4638d22bd0 | |||
| ee3bd1dcf2 | |||
| ae3a83ccce | |||
| 0f17433e1f | |||
| 043554bb2f | |||
| 8389808add | |||
| 6cbef4f633 | |||
| 2b6dde4f7e | |||
| fbe31b9885 | |||
| ef13f40cf5 | |||
| bfa1b104cf | |||
| 324a1d1a35 | |||
| 95ab88ff6f | |||
| d29840f985 | |||
| 44c0b9c340 | |||
| cdbdf3dc2b | |||
| 8aabb061c2 | |||
| 8bd638569f | |||
| 251dc44a46 | |||
| efd0a1d000 | |||
| 2f93c39fd3 | |||
| f27ce104c6 | |||
| 20a61b692b | |||
| 1966107efe | |||
| d5b2fe6e45 | |||
| 1569819750 | |||
| 4107a30148 | |||
| d06d88b83f | |||
| c411aac939 | |||
| 104715b650 | |||
| 59a9899149 | |||
| 4da7c3b91c | |||
| 012f4e3e16 | |||
| 3636724848 | |||
| eeda5ac312 | |||
| be872b840f | |||
| ee94ae8ba1 |
+25
-27
@@ -14,8 +14,8 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install pre-commit
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install mypy
|
||||
@@ -35,31 +35,29 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Cap a hung run at 30 min instead of riding GitHub's 6-hour default
|
||||
# (a flaky-hang run otherwise streams -v output for hours). Was 20;
|
||||
# the suite's growth (~9.7k tests, coverage-instrumented, 3-version
|
||||
# matrix) started brushing the old cap on healthy runs.
|
||||
timeout-minutes: 30
|
||||
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
|
||||
# (a flaky-hang run otherwise streams -v output for hours).
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
# Node is required by tests/test_renderer_js.py — without
|
||||
# explicit setup, that suite silently skips if the runner
|
||||
# image happens not to ship Node, masking regressions in
|
||||
# the browser-side renderer.
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
# -v lists each test id as it starts (pytest prints the nodeid at
|
||||
# logstart), so a hang names the culprit on the last line instead of
|
||||
# riding the job timeout with only a trail of "..." dots.
|
||||
- run: pytest tests/ -m "not live and not e2e_recovery" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
@@ -68,7 +66,7 @@ jobs:
|
||||
|
||||
test-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
@@ -84,23 +82,23 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live and not e2e_recovery" --storage-backend=postgresql -v
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
wheel-completeness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
@@ -153,8 +151,8 @@ jobs:
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -162,11 +160,11 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: uv sync --frozen --all-extras
|
||||
@@ -190,8 +188,8 @@ jobs:
|
||||
run:
|
||||
working-directory: sdk/typescript
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: npm ci
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post the review + inline comments
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
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
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
) || (
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
) || (
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
|
||||
) || (
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post comments/reviews when @-mentioned on a PR
|
||||
issues: write # post comments when @-mentioned on an issue
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
with:
|
||||
python-version: "3.14"
|
||||
@@ -58,12 +58,12 @@ jobs:
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- run: python -m build
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1
|
||||
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.tag.outputs.skip == 'false'
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
# Floor and ceiling of the example's requires-python (>=3.11).
|
||||
python-version: ["3.11", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[test,dev]"
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
|
||||
+36
-393
@@ -14,408 +14,51 @@ experimental line:
|
||||
|
||||
Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.7.4]
|
||||
|
||||
A feature-bearing patch for the 1.7 line, rolling up work that had stabilised
|
||||
on `main`. No schema migrations (head stays 066) and no new configuration knobs.
|
||||
|
||||
### Added
|
||||
|
||||
- **Compaction is visible now: lifecycle events, a progress bar, and a
|
||||
persistent transcript card.** Context compaction (manual `/compact` and
|
||||
auto) emits a first-class `compaction` SSE event
|
||||
(`start` / `progress` / `end` — see the API reference) instead of loose
|
||||
info lines. The web UI renders an in-transcript card with a real progress
|
||||
bar (determinate `part k of N` during chunked summarization, indeterminate
|
||||
for single-call compactions) that settles into a result card — token delta
|
||||
plus the summary behind a fold — in both the interactive pane and the
|
||||
coordinator viewer. The result survives reloads: the persisted compaction
|
||||
marker now projects through `/history` as a `role="system"`,
|
||||
`source="compaction"` entry (resume/export/search unchanged), stamped with
|
||||
the end event's id so repaint and SSE replay can't double-render. The
|
||||
marker's `meta` additionally records `before_tokens` / `after_tokens` /
|
||||
`trigger`. Python and TypeScript SDKs gain a typed `CompactionEvent`.
|
||||
|
||||
- **One provider transport: every model call now streams (#831).**
|
||||
The per-adapter non-streaming entry (`create_completion`) is retired;
|
||||
single-shot lanes — judges, titles, compaction, web-fetch extraction,
|
||||
perception, eval, optimizer — sample through the same streaming entry
|
||||
the chat loop uses and accumulate via one shared drain, so request
|
||||
shaping can no longer drift between the two consumption styles. Two
|
||||
operator-visible consequences: long single-shot generations (a thinking
|
||||
model composing a title, a slow local judge) no longer sit in a single
|
||||
blocking read that can hit client read-timeouts — the same reason the
|
||||
Anthropic adapter already streamed internally — and judge timeouts now
|
||||
*abort* the underlying HTTP read instead of abandoning a worker thread
|
||||
on a dead call. Because every call now streams, an alias pointed at a
|
||||
model or org that cannot stream (OpenAI's verified-org streaming
|
||||
entitlement, a gateway api-version predating `stream_options` — e.g.
|
||||
older Azure OpenAI deployments) fails at request time where 1.7's
|
||||
non-streaming single-shot call succeeded; remediation is on the
|
||||
serving side (verify the org, bump the api-version/gateway) — there is
|
||||
deliberately no per-model non-streaming fallback left to configure. These lanes are also complete-or-error now: a stream
|
||||
that ends without any finish signal is treated as a generation that
|
||||
died mid-response and retried, instead of storing the partial text as
|
||||
a clean result (previously a half-generated compaction summary could
|
||||
silently replace real history). Caveats: these lanes now carry the
|
||||
same `stream_options: {include_usage: true}` the chat loop always
|
||||
sent — OpenAI-compatible servers old enough to *ignore* it stop
|
||||
producing usage rows on these lanes, and servers strict enough to
|
||||
*reject* unknown fields (pre-2024 llama.cpp/proxy builds) will 400 —
|
||||
such a server already couldn't serve turnstone's chat loop, but a
|
||||
judge/utility alias pointed at one worked on 1.7 and needs to move to
|
||||
a current server. Transient mid-stream deaths (connection drop, proxy
|
||||
hiccup) are re-issued in place up to twice with exponential backoff —
|
||||
the retry the SDK's request loop used to provide these lanes
|
||||
invisibly. Each lane accepts its own terminal marker (Anthropic
|
||||
`message_stop`, Responses terminal events); a lax server/gateway that
|
||||
never sends any terminal signal needs
|
||||
`{"finish_reason_optional": true}` in the model definition's
|
||||
capabilities JSON, which restores 1.7's tolerance (clean end-of-stream
|
||||
after output = completion) for that model on every lane — without it
|
||||
such streams fail as died-mid-generation, because SSE gives no way to
|
||||
tell the two apart and the default favors catching truncation. The
|
||||
unread `supports_streaming` capability flag (and its admin tile) is
|
||||
gone; the o-series models it described are dropped from the capability
|
||||
table entirely (see Removed).
|
||||
|
||||
- **One turn interface for every model call: `core/model_turn.py` (#827).**
|
||||
Judges (intent + output guard), perception, title generation, compaction,
|
||||
web-fetch extraction, the eval harness, the optimizer's meta lanes, and
|
||||
task agents all advance a trajectory through the same plant-call
|
||||
primitive the agent seam pioneered — Turn IR in, one shared lowering
|
||||
(argument sanitize → minted-id restore → vLLM reasoning attach), one
|
||||
shared re-ingest (blank-id repair → native-lane finalize). The judges'
|
||||
hand-built OpenAI-dict path is gone, and with it the Gemini judge's
|
||||
tool-blindness: evidence tools now work on Google models because the
|
||||
native lane round-trips `thought_signature` (with pairwise repair for
|
||||
blank-id compat responses). Provider adapters still take lowered wire
|
||||
dicts — the transport collapse and main-loop migration are tracked as
|
||||
#831 / #832.
|
||||
|
||||
- **task_agent keeps its model's reasoning across its own tool loop — on
|
||||
every provider lane.** A task agent's replayed turns now carry the
|
||||
provider-native reasoning lane the model produced — Anthropic thinking
|
||||
blocks with their signatures (commercial or an anthropic-compatible
|
||||
server), OpenAI Responses reasoning items, Gemini `thought_signature`
|
||||
fidelity blocks, and the reasoning text a vLLM `--reasoning-parser` /
|
||||
llama.cpp `reasoning_format` surfaces on the Chat Completions lane —
|
||||
instead of each turn being rebuilt from text + tool calls with the
|
||||
reasoning dropped. On a thinking model this restores reasoning continuity
|
||||
across the agent's own multi-turn tool use. On the wire the agent's
|
||||
session-minted sub-tool ids are mapped back to the provider's own ids
|
||||
(`restore_provider_tool_ids`), so the native block — replayed verbatim,
|
||||
its signature never touched — the `tool_calls` mirror, and each tool
|
||||
result always agree; internally the minted ids still key the live card,
|
||||
recall, and the cancel ledger unchanged. Replay honors the same per-model
|
||||
`replay_reasoning_to_model` flag the main loop uses on every lane: the
|
||||
vLLM Chat-Completions field replay keeps its server-type gate, and
|
||||
llama.cpp stays capture-only, matching main-loop behavior. The native
|
||||
lane is finalized by the same shared builder as the main loop's, so the
|
||||
two harnesses cannot drift.
|
||||
|
||||
- **Background shells: `bash` gains `run_in_background`, plus `bash_output` /
|
||||
`kill_shell`.** Setting `run_in_background=true` starts the command as a
|
||||
detached shell and returns immediately with a `bash_N` handle — "start a dev
|
||||
server, use it in a later call" is back as an explicit opt-in (the shape
|
||||
follows the convention the major coding agents converged on). `bash_output`
|
||||
returns only output produced since the previous read (optionally filtered by
|
||||
a regex) plus status and exit code; `kill_shell` terminates the shell's
|
||||
whole process group. Output is buffered per shell with a drop-oldest cap, so
|
||||
a chatty server can't grow memory unbounded. When a background shell exits,
|
||||
a system notice lands at the next seam (waking an idle workstream if
|
||||
needed). Shells survive a generation cancel, die with the workstream, and
|
||||
never outlive a task_agent that started them; anything a background shell
|
||||
itself backgrounds is still reaped when that shell exits — the no-leak
|
||||
guarantee below is unchanged.
|
||||
- **Background shells for the `bash` tool** — `run_in_background=true` starts a
|
||||
command as a detached shell and returns a `bash_N` handle; new `bash_output`
|
||||
(delta output since last read, optional regex filter, status/exit code) and
|
||||
`kill_shell` (terminates the shell's process group) tools manage it. Output is
|
||||
buffered with a drop-oldest cap, a system notice lands when a shell exits, and
|
||||
shells die with their workstream — never outliving a `task_agent` that started
|
||||
them.
|
||||
- **`task_agent` carries the model's native reasoning across its own tool loop** —
|
||||
a task agent's replayed turns now preserve the provider-native reasoning lane
|
||||
(Anthropic thinking blocks with signatures, OpenAI reasoning items, Gemini
|
||||
`thought_signature`, vLLM/llama.cpp reasoning text) instead of rebuilding each
|
||||
turn from text alone, restoring reasoning continuity for thinking models.
|
||||
- **Model-shelf response controls** — the console model shelf exposes verbosity
|
||||
and reasoning-mode controls per identity.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking (1.8): compaction feedback moved from `info` events to the
|
||||
typed `compaction` SSE event.** Pre-1.8 SSE/SDK clients that ignore
|
||||
unknown event types no longer see compaction lines (they are
|
||||
deliberately not dual-emitted — dual emission would double-render on
|
||||
every current client). Consume the `compaction` lifecycle event (see
|
||||
the API reference and the `CompactionEvent` SDK type); embedders
|
||||
driving `ChatSession` through a duck-typed `SessionUI` are unaffected
|
||||
(the classic `on_info` lines are restored for them — see Fixed).
|
||||
|
||||
- **Sampling knobs (temperature, reasoning effort) now ride one assignment
|
||||
scheme: per-model alias value → operator-stored global setting → the
|
||||
model definition's declared default (effort only) → field omitted.**
|
||||
Turnstone previously manufactured values onto every unconfigured
|
||||
request — a hidden `temperature: 0.5` and a `reasoning_effort: "medium"`
|
||||
baked in at three layers — overriding serving-side defaults like a vLLM
|
||||
model's `generation_config`. Unconfigured installs now send neither
|
||||
field and the inference engine's own defaults rule; `model.temperature`
|
||||
is blank by default ("inherit each model's own default") and
|
||||
`model.reasoning_effort` defaults to the empty "inherit" choice. The
|
||||
per-model → global resolution lives in one shared resolver used by the
|
||||
session factories, the `/model` switch, and every `model_turn` lane, so
|
||||
the same alias samples identically on every surface. CLI
|
||||
`--temperature` / `--reasoning-effort` likewise default to inherit.
|
||||
|
||||
**Upgrade notes:**
|
||||
- The empty (`""`) reasoning-effort choice changed meaning from
|
||||
"explicitly disable thinking" to "inherit the model/serving default".
|
||||
On local manual-thinking models (e.g. Qwen templates with
|
||||
`enable_thinking`), a stored `""` previously sent
|
||||
`enable_thinking: false`; it now sends nothing, so the template's own
|
||||
default (often thinking ON) applies. Use **`none`** to actually
|
||||
disable reasoning.
|
||||
- Workstreams saved by earlier versions carry the old defaults
|
||||
(`temperature=0.5`, `reasoning_effort=medium`) in their persisted
|
||||
config and keep that exact behavior on resume; they pick up the new
|
||||
inherit semantics the next time you change the model or a sampling
|
||||
knob in that workstream. New workstreams inherit from the start.
|
||||
|
||||
### Removed
|
||||
|
||||
- **O-series and pre-5.4 GPT-5 rows dropped from the OpenAI capability
|
||||
table.** `o1`, `o1-mini`, `o3`, `o3-mini`, `o3-pro`, `o4-mini`,
|
||||
`gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`, `gpt-5.1`,
|
||||
`gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.2-pro`, and `gpt-5.3` no longer
|
||||
have built-in capability rows — OpenAI has retired these model ids
|
||||
from the API, so the rows described contracts no request can reach
|
||||
anymore. The table floor is now `gpt-5.4`; the search-api and
|
||||
audio/STT/TTS rows are unchanged. An alias still pinning a retired id
|
||||
fails at OpenAI itself; any other unlisted commercial id resolves to
|
||||
the generic commercial defaults (temperature sent, no declared
|
||||
reasoning-effort vocabulary, 200K window) — declare the contract on
|
||||
the model definition's capabilities JSON if you run one, or move to a
|
||||
current model.
|
||||
- **GPT-5.6 aligned with the GA API surface** — the Responses provider matches
|
||||
GPT-5.6's GA shape (typed `reasoning.mode`, `prompt_cache_options`,
|
||||
cache-write accounting); the `openai` floor moves to `>=2.45`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A failed worker-thread spawn no longer wedges the workstream — at
|
||||
either spawn site — and never masquerades as success.** If
|
||||
`Thread.start()` itself raised (thread exhaustion, out-of-memory), the
|
||||
dispatcher had already claimed the worker slot but the flag's only
|
||||
clearer lived in the never-started thread — the workstream looked idle
|
||||
forever while every subsequent message queued behind a worker that
|
||||
didn't exist, until an operator force-cancel. The claim is now rolled
|
||||
back under the lock and the error propagates, so the workstream is
|
||||
dispatchable again as soon as resources recover. Affected every
|
||||
dispatch path (sends, wakes, retries, deferred-send drain, init). The
|
||||
same failure at the deferred-send drain's own spawn rolls back the
|
||||
just-accepted entry and answers the retryable `queue_full` (previously
|
||||
a 500 landed *after* the entry was registered — an invisible,
|
||||
unretractable phantom that later dispatched as duplicate turns), and a
|
||||
`/command` whose worker never spawned now answers **503**
|
||||
`{"status": "error"}` instead of the generic 200 ok that told SDK
|
||||
callers their `/clear` or `/resume` had applied.
|
||||
|
||||
- **Manual `/compact` from the web UI: no phantom user turn, no frozen
|
||||
server, cancellable.** A slash command typed into the web composer no
|
||||
longer renders as a user chat bubble (it echoes as a distinct command
|
||||
chip — commands aren't conversation turns and were never persisted as
|
||||
such). `/compact` itself now dispatches onto the workstream's worker
|
||||
slot instead of running inline on the server's event loop — previously a
|
||||
long compaction froze every SSE stream on the node for its whole
|
||||
duration, which is also why its own progress only ever arrived as one
|
||||
burst after the fact. The manual path carries `send()`'s full generation
|
||||
discipline (`compact_now()`): a force-abandoned compaction goes stale
|
||||
instead of swapping history under a successor turn — and retires at its
|
||||
next checkpoint instead of running out its remaining summary calls,
|
||||
with its late lifecycle events fenced off (`compaction_id` on every
|
||||
event, `superseded` on end events — both in the SDKs) so they can't
|
||||
animate, tear down, re-title, or falsely narrate a successor's card or
|
||||
activity pill; a cancel aimed at it is consumed on exit (previously it
|
||||
bricked every `/compact` retry until the next message); a Stop click on
|
||||
an idle session can't pre-abort the next compaction; a Stop that lands
|
||||
in the completion tail — after the last cancel check, or during a retry
|
||||
backoff (which now aborts immediately instead of sleeping it out) — is
|
||||
honored rather than silently eaten; and Stop now aborts the in-flight
|
||||
summary HTTP call itself (the compaction lane registers its stream in
|
||||
the same abort seam the main loop uses), so cancelling a compaction is
|
||||
immediate instead of waiting out a model call.
|
||||
|
||||
- **Sends during a command window are deferred, ordered, bounded, and
|
||||
honestly rendered — never silently truncated or lost.** Messages sent
|
||||
while any slash command holds the worker slot are **deferred**: answered
|
||||
`{"status": "queued", "msg_id"}` immediately and dispatched as ordinary
|
||||
full-fidelity sends (attachments and sender identity included) when the
|
||||
command finishes — never routed through the mid-turn interjection
|
||||
queue, whose semantics are turn-shaped: previously a send during a
|
||||
manual `/compact` was silently truncated to 2,000 characters, a second
|
||||
participant in a shared workstream was locked out with a misleading
|
||||
"another participant's turn" 409 for the whole compaction, and a
|
||||
message queued across a `/resume`/`/new` could be answered into the
|
||||
post-swap workstream. Because the response is immediate,
|
||||
timeout-bounded callers — the coordinator's `send_message`, the console
|
||||
proxy, SDKs, anything behind a stock reverse proxy — can no longer lose
|
||||
a message to a multi-minute command window; the deferred send is
|
||||
retractable until dispatch via the same `DELETE .../send` used for
|
||||
queued interjections (node-local, in-memory — the API reference
|
||||
documents the at-most-once durability contract). Deferred responses
|
||||
carry `"deferred": true`; the pending list is the **order authority**
|
||||
(a fresh send — or a coordinator dispatch, or a queued-nudge wake —
|
||||
lines up behind acknowledged entries instead of overtaking them, with
|
||||
the two-term barrier defined once on the workstream so the wake gate
|
||||
also honors a claimed entry whose dispatch is mid-flight, and the gate
|
||||
re-arms at the drain's exit even when everything pending was
|
||||
retracted); acceptance is **bounded** (10 pending per workstream — the
|
||||
interjection queue's own backpressure contract; the 11th answers the
|
||||
retryable `queue_full` instead of pinning attachment bytes without
|
||||
limit and then running one unattended turn per entry); a dispatch
|
||||
crash re-queues the entry instead of eating an acknowledged message,
|
||||
and a drain thread that fails to *start* rolls the acceptance back and
|
||||
answers `queue_full` rather than parking a phantom the client can
|
||||
neither see nor retract; each dispatch emits a pane-tier
|
||||
`message_dispatched` event (`folded: true` for interjection fold-ins)
|
||||
so queued-bubble UI keeps its retract affordance exactly until the
|
||||
message truly leaves — including when the send was accepted by a pane
|
||||
that believed the workstream idle, which now renders a real queued
|
||||
chip instead of a sent-looking bubble, releases the composer (a
|
||||
deferred send has no running worker to wait on), and cleans up fully
|
||||
when the send is refused or the chip retracted instead of stranding
|
||||
the pane in Stop mode. Dismissing a queued bubble — interjection or
|
||||
deferred — is a server-confirmed `DELETE`, and retracting a deferred
|
||||
send that carried attachments tells the user they were discarded
|
||||
instead of silently expiring them.
|
||||
|
||||
- **Slash commands hold the worker slot with a loud contract.**
|
||||
A `/compact` raced against an in-flight turn is refused with an
|
||||
explicit busy response. Every other slash command runs through the same
|
||||
worker slot too — mutual exclusion against sends, a running compaction,
|
||||
and each other, with a busy answer replacing the old silent interleave —
|
||||
while the endpoint still awaits quick commands' completion off-loop
|
||||
(without parking an executor thread per request); the post-command pane
|
||||
refreshes (`clear_ui` after `/clear`/`/new`/`/resume`, the
|
||||
workstream-name sync) ride the worker itself, so a command that
|
||||
outlives the endpoint's 25s response backstop still refreshes every
|
||||
pane on completion (the backstop sits under the console proxy's 30s
|
||||
client timeout so the degraded `running` answer can actually traverse
|
||||
a proxied pane, which now surfaces it instead of silence; the
|
||||
`/command` response contract — `ok` / `running`, with busy refusals
|
||||
answering a loud HTTP 409 rather than a silent 200 — is now documented
|
||||
in the API reference and the OpenAPI spec).
|
||||
|
||||
- **Compaction status stays truthful across every UI surface.** Manual
|
||||
compaction
|
||||
success also refreshes the status line/context pill immediately (parity
|
||||
with auto-compaction), compaction failures keep feeding the typed
|
||||
`error` event and the node error counter (while a CLI Ctrl-C reports as
|
||||
cancelled, not a failure), one Stop prints one notice (a cancelled
|
||||
auto-compaction no longer stacks "Compaction cancelled." on top of
|
||||
send's own "[Generation cancelled]"), the workstream activity pill
|
||||
shows "Compacting context…" for the whole summarize phase, restores
|
||||
cleanly afterwards, and can no longer be stranded by a force-stopped
|
||||
compaction (a new turn's generation claim breaks a stale latch). Every
|
||||
retry backoff on the session (stream retries, task agents, notify
|
||||
delivery, compaction) now aborts immediately on Stop via one shared
|
||||
cancel-aware helper instead of sleeping out its exponential delay.
|
||||
|
||||
- **Compaction failures report exactly once, to the right owner.** A
|
||||
compaction failure reports
|
||||
exactly once (auto-compaction errors defer to the turn's fatal handler
|
||||
instead of doubling the red row and the error metric), failed-end
|
||||
notice suppression is computed once by the emitter (a `notice` bool on
|
||||
the end event — in the SDKs — replaces hand-synced client policy), and
|
||||
a manual `/compact` failure no longer crashes the CLI REPL. `/compact`
|
||||
on a workstream showing the `error` badge restores the badge on exit
|
||||
instead of stamping `idle` over it (the compaction neither retried nor
|
||||
resolved the failed turn). A force-cancelled initial send that
|
||||
completes late still delivers its scheduled-run completion
|
||||
notification (the only completion signal unattended workstreams have);
|
||||
the other post-command pane refreshes and error notices remain
|
||||
owner-guarded, so a force-cancelled wedged command that unwedges late
|
||||
can't wipe panes or inject stray notices into a successor turn.
|
||||
|
||||
- **Pre-1.8 embedder UIs keep their compaction lines.** Embedders
|
||||
driving `ChatSession` with a pre-1.8 duck-typed `SessionUI`
|
||||
(no `on_compaction` hook) get the classic `on_info` compaction lines
|
||||
back — threshold notice, `part k/N`, retry waits, token delta +
|
||||
summary box — instead of silent history swaps. (See the breaking
|
||||
event-contract note under **Changed** for SSE/SDK clients.)
|
||||
|
||||
- **Static MCP servers: a pushed catalog change no longer wedges the shared
|
||||
session (#839).** The static-path `*/list_changed` handler awaited its
|
||||
catalog refresh inline in the SDK's receive loop, but the refresh's own
|
||||
request can only be answered by that (now parked) loop — the refresh never
|
||||
completed, and every user's in-flight calls on the shared per-node session
|
||||
stalled behind it, unbounded, until the health loop's ping timeout tore the
|
||||
transport down (which was also the only way the changed catalog ever
|
||||
landed). Push refreshes now run as spawned tasks — debounced, coalesced per
|
||||
(server, kind), bounded by the connect timeout, and serialized on the
|
||||
per-server connect lock — and the manual and post-reconnect refreshes
|
||||
publish under that same lock, so a slower publisher can no longer land a
|
||||
staler catalog over a fresher one. Every teardown path now also clears the
|
||||
notification debounce stamp, so a reconnected server's first push refreshes
|
||||
immediately. Push-refresh debouncing is now per (server, kind) on BOTH the
|
||||
static and per-user pool paths — a tools push no longer swallows a prompts
|
||||
push arriving in the same 5-second window. A change genuinely lost to the
|
||||
debounce window (a same-kind push landing after the prior refresh finished,
|
||||
which the server will never re-announce) is recovered by an automatic
|
||||
health-tick retry rather than staying invisible until an unrelated push or
|
||||
a reconnect. The resource-refresh fan-out on both paths no longer orphans
|
||||
its sibling list call when one of the pair fails fast — the real error
|
||||
surfaces immediately (not masked as a 30-second timeout) and the surviving
|
||||
sibling is cancelled and reaped, under a bounded grace, inside the scope. A
|
||||
push refresh that fails while the connection stays up is likewise retried on
|
||||
the next health-loop tick until one completes — previously a single
|
||||
transient blip left the shared catalog stale for every user on the node
|
||||
until an operator intervened. An operator `/mcp refresh` no longer parks
|
||||
behind a busy per-server connect lock (a slow reconnect attempt could eat
|
||||
the whole 30-second refresh budget and fail the pass for every healthy
|
||||
server behind it) — the busy server is skipped on both the connected and
|
||||
disconnected branches, reported distinctly as "skipped" rather than as a
|
||||
false "no changes", the skip arms the automatic retry, and a
|
||||
force-reconnect drops the session up front so queued push refreshes can't
|
||||
starve it. Static-path resource and prompt catalogs are now size-capped
|
||||
like the pool path's (and like static tools) at discovery and on every
|
||||
refresh, so a misbehaving server's push can't balloon the node's merged
|
||||
catalogs. Deleting or reconfiguring a server can no longer leave it
|
||||
half-removed: the config removal and all cleanup are serialized under the
|
||||
connect lock (a cancelled removal completes its cleanup rather than
|
||||
stranding a live session and published catalog with the config already
|
||||
gone), and `reconcile_sync` retries a removal that timed out instead of
|
||||
marking it done — previously a DB-driven delete of a busy server could be a
|
||||
silent, permanent no-op until process restart. A refresh outcome now
|
||||
threads consistently to every operator surface off one source of truth
|
||||
(the per-server `last_refresh_outcome`): a busy-skip and a genuine failure
|
||||
are each reported distinctly from a real "no changes" — `/mcp refresh`
|
||||
prints "skipped" or "failed" rather than a false "no changes", and the
|
||||
node-internal refresh endpoint returns `202 skipped` instead of a
|
||||
misleading `200 ok` for a refresh that never ran. A single-kind push
|
||||
refresh no longer paints the whole server healthy: because the
|
||||
error/outcome state is server-scoped, a successful tools push while the
|
||||
prompts catalog is still broken (or vice versa) no longer clears the
|
||||
failure — only a full refresh pass declares "ok".
|
||||
|
||||
- **OpenAI Responses streaming: truncated and refused responses no longer
|
||||
vanish.** A response that hit `max_output_tokens` terminates the stream
|
||||
with `response.incomplete`, which the stream consumer did not handle —
|
||||
the turn was mislabeled `finish_reason: stop` and its final usage and
|
||||
collected output items were dropped. Refusal parts had no streaming
|
||||
handler at all, so a refusal rendered as empty content instead of the
|
||||
`[Refused: …]` text the non-streaming path produced. Both now match:
|
||||
truncation maps to `length` with usage/items intact, refusals render
|
||||
in content. Applies to the chat loop and every drained single-shot
|
||||
lane (#831).
|
||||
|
||||
- **task_agent: sub-tool ids no longer alias across a local model's reused
|
||||
ids.** A local model that reissues per-response sequential tool-call ids
|
||||
(`call_0` every turn) made two of a task agent's steps share one id — the
|
||||
live card collapsed both onto one DOM row while `/history` recall kept them
|
||||
apart, so the two views disagreed. Sub-tool ids are now minted
|
||||
`{parent}::r{run}s{step}::{id}`, unique within the session (across an
|
||||
agent's turns and across concurrent or sequential runs), and that one id
|
||||
keys the nesting registry, the live rows, recall, and the cancel ledger.
|
||||
On the wire the agent's self-built history carries the provider's own ids,
|
||||
restored from the mint map (see the reasoning-lane entry under Added), and
|
||||
malformed tool-call arguments are legalized the same way the main loop's
|
||||
wire prep does.
|
||||
|
||||
- **bash tool: never hang on a backgrounded child.** A command that left a
|
||||
long-lived process running (`server &`, a daemon) could wedge the whole
|
||||
workstream forever — the tool read stdout/stderr to EOF, which never arrived
|
||||
because the child inherited the pipe, and the timeout watchdog bailed once the
|
||||
foreground `bash` had exited. The tool now waits on the tracked process
|
||||
(bounded by the tool timeout) and terminates its whole process group on
|
||||
return, so the call always completes. Undecodable output is preserved
|
||||
(`errors="replace"`) instead of being dropped as a spurious error.
|
||||
- **Behavior change:** a process the command backgrounds no longer survives
|
||||
the call — nothing persists across bash invocations. (First-class
|
||||
"run this in the background" support landed separately — see
|
||||
`run_in_background` under Added.)
|
||||
- **`bash` never hangs on a backgrounded child** — a command that left a
|
||||
long-lived process running no longer wedges the workstream; the tool waits on
|
||||
the tracked process (bounded by the timeout) and reaps its whole process group.
|
||||
- **`task_agent` sub-tool ids are session-unique** — ids are minted
|
||||
`{parent}::r{run}s{step}::{id}` so a local model reissuing sequential ids
|
||||
(`call_0` each turn) no longer aliases two steps onto one live-card row while
|
||||
`/history` keeps them apart.
|
||||
- **Judge completions honour model-definition capabilities** — a judge's
|
||||
completion now threads its model's declared capabilities instead of assuming a
|
||||
default surface.
|
||||
- **`create-admin` CLI** — adds an explicit admin-creation command; `run.sh` no
|
||||
longer onboards into a role-less user.
|
||||
- **Install script Docker handling** — installs Docker on distros
|
||||
`get.docker.com` rejects, and gates that path by `$ID` instead of trapping all
|
||||
failures.
|
||||
|
||||
## [1.7.3]
|
||||
|
||||
|
||||
+2
-6
@@ -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.29 /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
|
||||
@@ -60,12 +60,8 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
WORKDIR /data
|
||||
RUN chown turnstone:turnstone /data
|
||||
|
||||
# Workspace mount point — bind-mount a host directory here. The env var
|
||||
# surfaces the path in the model's shell/file tool descriptions
|
||||
# (config.get_workspace_dir); without it the mount is invisible to the
|
||||
# model, whose cwd is /data below.
|
||||
# Workspace mount point — bind-mount a host directory here
|
||||
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
|
||||
ENV TURNSTONE_WORKSPACE=/workspace
|
||||
|
||||
USER turnstone
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ appVersion: "0.3.0"
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~18.8.0
|
||||
version: ~18.7.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
|
||||
+28
-132
@@ -467,46 +467,6 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
||||
{"type": "info", "message": "Session cleared."}
|
||||
```
|
||||
|
||||
**`compaction`** -- context-compaction lifecycle (manual `/compact` and
|
||||
auto-compaction). `phase: "start"` opens the operation (`trigger` is
|
||||
`"manual"` or `"auto"`; auto adds `where` — e.g. `"mid-turn"` — and, when
|
||||
the percentage threshold actually fired, `pct`; the context-overflow retry
|
||||
path compacts without a `pct` since no threshold was evaluated).
|
||||
`phase: "progress"` reports chunked summarization (`part`/`total`/`depth`,
|
||||
where depth 0 summarizes transcript batches and deeper levels merge partial
|
||||
summaries), a transient-error retry wait (`retry_in` seconds + `error`), or
|
||||
`warning: "summary_truncated"`. `phase: "end"` settles it: `ok: true`
|
||||
carries `before_tokens`/`after_tokens` and the produced `summary`;
|
||||
`ok: false` carries a `reason`
|
||||
(`"not_enough_messages"` / `"irreducible"` / `"empty_summary"` /
|
||||
`"cancelled"` / `"error"`) and a human-readable `message` — for
|
||||
`reason: "error"` the same message is also emitted as a paired typed
|
||||
`error` event (that is the renderable error surface; the end event is
|
||||
card-teardown). Failed ends also carry `notice`: the emitter-computed
|
||||
display verdict — show `message` only when it is `true` (the server
|
||||
suppresses error-reason, superseded, and cancelled-auto notices once,
|
||||
centrally, so clients don't re-derive that policy). Every end (ok or
|
||||
failed) carries `trigger`, and every event carries `compaction_id` — an
|
||||
opaque integer correlating the start/progress/end of one compaction run (a
|
||||
client that force-stopped one compaction can use it to ignore stragglers
|
||||
from the abandoned run). End events also carry `superseded`: `true` marks
|
||||
a force-abandoned compaction retiring after a successor generation took
|
||||
over (an OK end's result card still stands: the history swap happened).
|
||||
Superseded start/progress events are never emitted.
|
||||
Exactly one `start` and one `end` are emitted per attempt,
|
||||
so clients can key an in-progress affordance (progress bar) on the pair. A
|
||||
successful end is also persisted: the summary replays from `/history` as a
|
||||
`role: "system"`, `source: "compaction"` entry whose `meta` carries
|
||||
`{watermark, before_tokens, after_tokens, trigger}` and whose `event_id`
|
||||
matches the end event's id (dedup across repaint + replay).
|
||||
|
||||
```json
|
||||
{"type": "compaction", "phase": "start", "compaction_id": 7, "trigger": "auto", "where": "mid-turn", "pct": 80}
|
||||
{"type": "compaction", "phase": "progress", "compaction_id": 7, "part": 2, "total": 5, "depth": 0}
|
||||
{"type": "compaction", "phase": "end", "ok": true, "compaction_id": 7, "trigger": "auto",
|
||||
"before_tokens": 128400, "after_tokens": 9200, "summary": "## Decisions\n..."}
|
||||
```
|
||||
|
||||
**`error`** -- an error message.
|
||||
|
||||
```json
|
||||
@@ -788,41 +748,32 @@ Sends a user message to a workstream. Spawns a daemon worker thread that calls
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"message": "Explain how the server works", "attachment_ids": ["a1"]}
|
||||
{"message": "Explain how the server works"}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|------------|----------|------------------------------------------------------|
|
||||
| `message` | string | yes | The user's message text |
|
||||
| `attachment_ids` | string[] | no | Staged uploads to attach (omit = auto-consume; `[]` = none) |
|
||||
| Field | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------------------|
|
||||
| `message` | string | yes | The user's message text |
|
||||
|
||||
**Response.** Every 200 body carries `attached_ids` and
|
||||
`dropped_attachment_ids` (empty lists when no attachments are involved):
|
||||
**Response (success):**
|
||||
|
||||
- `{"status": "ok", ...}` — a fresh turn was dispatched.
|
||||
- `{"status": "queued", "priority", "msg_id", ...}` — folded into the live
|
||||
turn's interjection queue; delivered at the next tool-result seam.
|
||||
`DELETE .../send` with the `msg_id` retracts it before delivery.
|
||||
- `{"status": "queued", "deferred": true, ...}` — parked on the deferred-send
|
||||
list (a command window holds the slot, or earlier deferred sends are
|
||||
pending) and dispatched as its own full-fidelity send afterwards; see the
|
||||
defer contract under `POST /v1/api/command`.
|
||||
- `{"status": "queue_full", ...}` — the send was refused with retry-shortly
|
||||
semantics: the live worker's interjection queue is at capacity, the
|
||||
deferred-send list hit its saturation bound (10 pending — the same
|
||||
backpressure contract), or the deferred-send drain could not be started
|
||||
under resource exhaustion (the message was **not** accepted; nothing is
|
||||
parked).
|
||||
- `{"status": "attachments_busy", ...}` — attachments can't ride a queued
|
||||
turn; the staged uploads survive for a retry once the worker idles.
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
**Response (busy):** Returned if the workstream's worker thread is still alive
|
||||
from a previous request. Also pushes a `busy_error` event to the SSE stream.
|
||||
|
||||
```json
|
||||
{"status": "busy"}
|
||||
```
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|-------------------------------------------------|----------------------------------------|
|
||||
| 400 | `{"error": "message is required"}` | Message is empty |
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found (or closed mid-send) |
|
||||
| 409 | `{"status": "cross_user_interjection", ...}` | Another participant's turn is in flight |
|
||||
| Status | Body | Condition |
|
||||
|--------|------------------------------------|------------------------|
|
||||
| 400 | `{"error": "Empty message"}` | Message is empty |
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
|
||||
|
||||
---
|
||||
|
||||
@@ -865,56 +816,7 @@ automatically approved without prompting.
|
||||
|
||||
### `POST /v1/api/command`
|
||||
|
||||
Executes a slash command in the given workstream. Commands run on the
|
||||
workstream's worker slot (mutual exclusion against sends, a running
|
||||
compaction, and each other) — the endpoint is **not** unconditionally
|
||||
synchronous:
|
||||
|
||||
- **Quick commands** (everything except `/compact`): the endpoint waits for
|
||||
completion, so `{"status": "ok"}` means the command ran. A command still
|
||||
running after 25 s answers `{"status": "running"}` — the worker keeps
|
||||
going, its output reaches the pane via SSE, and the post-command pane
|
||||
refreshes below still fire when it completes. (The bound sits under
|
||||
common 30 s client/proxy timeouts — the console proxy's included — so
|
||||
the degraded answer actually reaches bounded callers.)
|
||||
- **`/compact`**: dispatched fire-and-forget — `{"status": "ok"}` means the
|
||||
compaction *started*. A large context can legitimately compact for many
|
||||
minutes; progress streams as `compaction` SSE events (see the event
|
||||
reference) and the persisted marker row lands on completion. Do not read
|
||||
`/history` expecting the compacted transcript immediately after the
|
||||
response.
|
||||
- **Busy refusal**: if a turn or another command holds the worker slot, the
|
||||
command is refused with HTTP **409** `{"status": "busy", "error": ...}` and
|
||||
did **not** run. Retry after the current turn finishes. (The old inline
|
||||
endpoint executed commands unconditionally mid-turn; the 409 makes the
|
||||
refusal loud for callers that only check the HTTP status.)
|
||||
|
||||
While a command holds the slot — and afterwards, while earlier deferred
|
||||
sends are still waiting (the pending list is the order authority: a fresh
|
||||
send never overtakes a message already acknowledged) — `POST .../send`
|
||||
requests are **deferred**: the server answers `{"status": "queued",
|
||||
"deferred": true, "msg_id": ...}` immediately and dispatches the message
|
||||
as an ordinary full-fidelity send (attachments and sender identity
|
||||
included) in arrival order once the slot frees — it is never routed
|
||||
through the mid-turn interjection queue (no length cap, no cross-user
|
||||
rejection). The response arrives within normal round-trip time, so
|
||||
timeout-bounded clients (SDKs, proxies, the coordinator) need no special
|
||||
handling. To retract a deferred send before it dispatches, issue the same
|
||||
`DELETE .../send` with its `msg_id` used for queued interjections —
|
||||
`{"status": "removed"}` confirms it will not dispatch; `"not_found"` means
|
||||
it already dispatched (or is dispatching). Retracting a deferred send
|
||||
discards any attachments it carried; re-attach to send them again. When a
|
||||
deferred send dispatches, panes receive a `message_dispatched` event
|
||||
(`msg_id`, plus `folded: true` when it folded into a live turn's
|
||||
interjection queue rather than spawning its own turn) so queued-message
|
||||
UI can settle the right way.
|
||||
|
||||
Durability: deferred sends are **node-local and in-memory** (the same
|
||||
lifetime as the interjection queue). `"queued"` is at-most-once intake, not
|
||||
durable acceptance — if the workstream is closed or the node restarts before
|
||||
the window ends, the message is dropped. Anything that must survive a
|
||||
restart should be re-sent after confirming dispatch (the turn appears on the
|
||||
SSE stream / in `/history`).
|
||||
Executes a slash command in the given workstream.
|
||||
|
||||
**Request body:**
|
||||
|
||||
@@ -927,12 +829,10 @@ SSE stream / in `/history`).
|
||||
| `command` | string | yes | The slash command (e.g. `/clear`) |
|
||||
| `ws_id` | string | yes | Target workstream ID |
|
||||
|
||||
If the command is `/clear`, `/new`, or `/resume`, the server pushes a
|
||||
`clear_ui` SSE event to instruct the client to reset its message display and
|
||||
re-fetch the transcript via `GET .../history` (there is no SSE event that
|
||||
carries the messages themselves). These follow-ups are emitted by the
|
||||
command worker itself, so they fire even when the endpoint already answered
|
||||
`{"status": "running"}`.
|
||||
If the command is `/clear` or `/new`, the server pushes a `clear_ui` SSE event
|
||||
to instruct the client to reset its message display. If the command is
|
||||
`/resume`, the server pushes `clear_ui` followed by a `history` event
|
||||
containing the resumed session's messages.
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -940,16 +840,12 @@ command worker itself, so they fire even when the endpoint already answered
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
or `{"status": "running"}` as above.
|
||||
|
||||
**Error responses:**
|
||||
|
||||
| Status | Body | Condition |
|
||||
|--------|-------------------------------------|--------------------------------------------------|
|
||||
| 400 | `{"error": "Empty command"}` | Command is empty |
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
|
||||
| 409 | `{"status": "busy", "error": ...}` | A turn/command holds the worker |
|
||||
| 503 | `{"status": "error", "error": ...}` | The command worker could not be started (resource exhaustion) — the command did **not** run; retry shortly |
|
||||
| Status | Body | Condition |
|
||||
|--------|------------------------------------|----------------------|
|
||||
| 400 | `{"error": "Empty command"}` | Command is empty |
|
||||
| 404 | `{"error": "Unknown workstream"}` | `ws_id` not found |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+18
-54
@@ -91,7 +91,7 @@ turnstone/
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
slack/ Slack adapter (Socket Mode bot, DM routing, approval buttons)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.18.1/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.17.0/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
@@ -609,7 +609,8 @@ LLMProvider (protocol)
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `create_streaming()` | The one transport: streaming request, yields normalized `StreamChunk` objects (single-shot callers accumulate via `drain_stream()` into a `CompletionResult`) |
|
||||
| `create_streaming()` | Streaming request, yields normalized `StreamChunk` objects |
|
||||
| `create_completion()` | Non-streaming request, returns `CompletionResult` |
|
||||
| `get_capabilities()` | Per-model flags (`ModelCapabilities`) |
|
||||
| `convert_tools()` | Translate OpenAI tool schemas to provider format |
|
||||
| `retryable_error_names` | Exception class names that trigger retry |
|
||||
@@ -662,7 +663,7 @@ display). Automatic prompt caching is enabled via top-level `cache_control:
|
||||
cacheable block and advances it as conversations grow (90% input cost
|
||||
reduction on cache hits, 1.25x write on first turn). Cache metrics
|
||||
(`cache_creation_input_tokens`, `cache_read_input_tokens`) are extracted from
|
||||
the stream's usage events. The `anthropic` SDK is a core
|
||||
both streaming and non-streaming responses. The `anthropic` SDK is a core
|
||||
dependency — the Anthropic provider is first-class alongside OpenAI.
|
||||
|
||||
**GoogleProvider** (`_google.py`): extends `OpenAIChatCompletionsProvider` for
|
||||
@@ -967,33 +968,6 @@ The default limit is 50% of the context window in characters (computed as
|
||||
|
||||
This truncation message is visible to the model, so it knows output was cut.
|
||||
|
||||
During the send loop the limit is additionally capped by the remaining
|
||||
context budget, and three guarantees apply when that budget reaches zero
|
||||
(#883):
|
||||
|
||||
- **Structural floor** — orchestration handles (`spawn_workstream`,
|
||||
`spawn_batch`, `wait_for_workstream`, `tasks`) and error results are
|
||||
always admitted up to a guaranteed floor (2048 chars, head+tail beyond
|
||||
it), because a lost `ws_id` or a masked failure wedges the session.
|
||||
- **Small-result pass** — results at or under the floor pass verbatim,
|
||||
funded from a bounded per-batch grace pool (2× the floor) so a wide
|
||||
batch of small results cannot collectively bypass budget accounting;
|
||||
past the pool they get the drop notice instead.
|
||||
- **Honest drop notice** — a bulky non-structural result is replaced by an
|
||||
explicit `Error: tool result dropped — context budget exhausted…` notice
|
||||
stating the call ran but its output could not be admitted (never a
|
||||
successful-looking trim).
|
||||
|
||||
A zero budget also triggers one mid-turn auto-compaction before results are
|
||||
sized. With `max_tokens ≥ context_window/4` the response reserve zeroes the
|
||||
budget near 70% fullness — below the default 80% auto-compact threshold —
|
||||
and without this trigger a session could idle in that band indefinitely
|
||||
with every tool result floored or dropped. The trigger keys on the
|
||||
exhausted budget itself, not on any threshold, so it composes with any
|
||||
operator-set `auto_compact_pct`: with thresholds below the zero point the
|
||||
ordinary owed-compaction paths fire first and this trigger degrades to a
|
||||
backstop for the cases where they bailed or freed too little.
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
@@ -1176,30 +1150,20 @@ Named (aliased) workstreams are never age-pruned. Configure with
|
||||
|
||||
### API Retry
|
||||
|
||||
Every model call streams (#831); retry lives at two stacked layers:
|
||||
`ChatSession._create_stream_with_retry()` (streaming path) and the agent
|
||||
`_api_call()` (non-streaming) both use the same retry pattern:
|
||||
|
||||
- **Caller ladders** — `ChatSession._create_stream_with_retry()` (chat
|
||||
loop) and the agent `_api_call()` (drained via `model_turn`) use the
|
||||
same pattern: 4 total attempts (1 initial + 3 retries,
|
||||
`_MAX_RETRIES = 3`), exponential backoff base 1 second
|
||||
(`delay = 1s * 2^attempt`), `ui.on_info()` on retry, exception
|
||||
propagates on final failure. `_compact_messages()` wraps its drained
|
||||
call in the same loop.
|
||||
- **`model_turn`'s drain ladder** — inside every single-shot call,
|
||||
mid-stream deaths (errors raised while draining, e.g.
|
||||
`IncompleteStreamError`) are re-issued up to 2 more times with a
|
||||
0.5s-base exponential backoff (±50% jitter); request-time failures
|
||||
keep the SDK's own retry policy. The two ladders stack
|
||||
multiplicatively on transient-shaped failures.
|
||||
- **Retryable errors** are matched by class name against each
|
||||
provider's `retryable_error_names` (avoids importing
|
||||
backend-specific exception hierarchies): `RateLimitError`,
|
||||
`APITimeoutError`, `APIConnectionError`, `InternalServerError`,
|
||||
`ServiceUnavailableError`, `APIError`, plus the drained-transport
|
||||
errors `IncompleteStreamError` (stream ended with no terminal
|
||||
signal — for servers that never send one, declare
|
||||
`finish_reason_optional` in the model's capabilities JSON) and
|
||||
`ResponsesStreamFailedError` (transient in-band Responses failure).
|
||||
- **Retries**: 4 total attempts (1 initial + 3 retries, `_MAX_RETRIES = 3`)
|
||||
- **Backoff**: exponential, base 1 second (`delay = 1s * 2^attempt`)
|
||||
- **Retryable errors**: `RateLimitError`, `APITimeoutError`,
|
||||
`APIConnectionError`, `InternalServerError`, `ServiceUnavailableError`,
|
||||
`APIError` (matched by class name to avoid importing backend-specific
|
||||
exception hierarchies)
|
||||
- On retry: `ui.on_info()` notification
|
||||
- On final failure: exception propagates
|
||||
|
||||
`_compact_messages()` also wraps its non-streaming API call in the same
|
||||
retry loop.
|
||||
|
||||
### Finish Reason Handling
|
||||
|
||||
@@ -1212,7 +1176,7 @@ Every model call streams (#831); retry lives at two stacked layers:
|
||||
blocked.
|
||||
|
||||
Agent sub-sessions (`_run_agent()`) check `finish_reason` on each
|
||||
drained turn and stop the agent early on `"length"` or
|
||||
non-streaming response and stop the agent early on `"length"` or
|
||||
`"content_filter"`.
|
||||
|
||||
`_compact_messages()` checks `finish_reason` on the compaction response and
|
||||
|
||||
+11
-21
@@ -126,11 +126,10 @@ the skill should end on.
|
||||
|
||||
`tasks` is the coordinator's scratchpad — a persisted, ordered
|
||||
list of rows with fields `{id, title, status, child_ws_id, created,
|
||||
updated}`, plus `note` on rows where one has been set (the key is
|
||||
absent otherwise), that only this coordinator sees. Children don't
|
||||
see it; the user does via the sidebar. Five actions: `add`,
|
||||
`update`, `remove`, `reorder`, `list` (only `list` is auto-approved;
|
||||
the mutators go through the approval flow).
|
||||
updated}` that only this coordinator sees. Children don't see it;
|
||||
the user does via the sidebar. Five actions: `add`, `update`,
|
||||
`remove`, `reorder`, `list` (only `list` is auto-approved; the
|
||||
mutators go through the approval flow).
|
||||
|
||||
The input schema refers to rows by `task_id`; the persisted row
|
||||
object exposes the same id as `id`. The `child_ws_id` field is a
|
||||
@@ -143,20 +142,11 @@ A skill's initial prompt can seed the task list by calling
|
||||
`tasks(action="add", title=...)` as its very first tool calls —
|
||||
the user gets a visible plan before any child is spawned, and the
|
||||
coordinator's future self has something concrete to iterate on.
|
||||
Status transitions (`pending` → `in_progress` → `done` / `blocked` /
|
||||
`needs_user`) are the skill's main feedback loop: mutate the task
|
||||
when the child covering it finishes, not when the child starts.
|
||||
`blocked` and `needs_user` are not interchangeable — `blocked` is a
|
||||
dependency the coordinator may be able to clear itself, while
|
||||
`needs_user` marks a task that cannot move without a decision,
|
||||
approval, or grant only the user can give. The distinction is
|
||||
load-bearing: a coordinator that goes idle holding open tasks gets
|
||||
nudged to pick them back up — even when children are still running, so
|
||||
keep the matrix honest rather than expecting the reminder to wait for
|
||||
an all-clear — and `needs_user` is what tells that nudge the stop was
|
||||
deliberate. Pair it with `note` to record what is being asked for.
|
||||
Use `tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to link
|
||||
a task to the child that owns it once spawn returns.
|
||||
Status transitions (`pending` → `in_progress` → `done` / `blocked`)
|
||||
are the skill's main feedback loop: mutate the task when the child
|
||||
covering it finishes, not when the child starts. Use
|
||||
`tasks(action="update", task_id=..., child_ws_id=<ws_id>)` to
|
||||
link a task to the child that owns it once spawn returns.
|
||||
|
||||
A final gotcha: parallel tool dispatch does NOT serialise reads
|
||||
after writes in the same batch. If a skill issues an `update` and
|
||||
@@ -307,11 +297,11 @@ and the coordinator's planning step is itself valuable.
|
||||
tasks(action='add', title='...') × N # the plan, visible in the sidebar
|
||||
for task in tasks:
|
||||
spawn_workstream(skill=..., initial_message=task.brief)
|
||||
tasks(action='update', task_id=task.id, note='ws=<child_ws_id>')
|
||||
tasks(action='update', task_id=task.id, notes='ws=<child_ws_id>')
|
||||
wait_for_workstream(ws_ids=[...], mode='all', timeout=...)
|
||||
for child in children:
|
||||
inspect_workstream(ws_id=child)
|
||||
tasks(action='update', task_id=..., status='done', note='result summary')
|
||||
tasks(action='update', task_id=..., status='done', notes='result summary')
|
||||
→ synthesise
|
||||
```
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ class "NullUI" as NullUI {
|
||||
interface "LLMProvider" as LLMProvider <<Protocol>> {
|
||||
+ provider_name: str {property}
|
||||
+ get_capabilities(model) → ModelCapabilities
|
||||
+ create_streaming(client, model, messages, ..., cancel_ref, replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_streaming(client, model, messages, ..., replay_reasoning_to_model) → Iterator[StreamChunk]
|
||||
+ create_completion(client, model, messages, ..., replay_reasoning_to_model) → CompletionResult
|
||||
+ convert_tools(tools) → list[dict]
|
||||
+ extract_reasoning_text(provider_blocks) → str
|
||||
+ retryable_error_names: frozenset[str] {property}
|
||||
@@ -176,7 +177,7 @@ class "HeadlessSession" as HeadlessSession {
|
||||
+ send_headless(input, max_turns, ...)
|
||||
- _override_system_prompt(content)
|
||||
--
|
||||
eval.py: drained single-shot turns,
|
||||
eval.py: non-streaming,
|
||||
records all tool calls
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ end note
|
||||
|
||||
loop up to 3 turns (timeout budget)
|
||||
|
||||
Judge -> LLM : model_turn(lane, judge_turns,\ntools=[read_file, list_directory])\nvia drained create_streaming
|
||||
LLM --> Judge : ModelTurnResult
|
||||
Judge -> LLM : create_completion(\nmodel, judge_messages,\ntools=[read_file, list_directory])
|
||||
LLM --> Judge : CompletionResult
|
||||
|
||||
alt tool_calls present (turn < 3)
|
||||
Judge -> Judge : _exec_read_only_tool()
|
||||
|
||||
@@ -252,7 +252,6 @@ interface, or anyone who can reach it can search through your instance.
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write |
|
||||
| `TURNSTONE_WORKSPACE` | `/workspace` (image env) | Directory named as the user's workspace in the model's tool descriptions; informational only — see [Working directory](#working-directory) |
|
||||
| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) |
|
||||
| `MCP_CONFIG` | — | Path to an MCP server config file |
|
||||
| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack |
|
||||
@@ -277,35 +276,6 @@ docker compose build --no-cache # rebuild from scratch
|
||||
| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) |
|
||||
| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) |
|
||||
|
||||
## Working directory
|
||||
|
||||
Node processes run with `/data` as their working directory (the image's
|
||||
`WORKDIR`), and that is where the model's shell commands execute and
|
||||
relative file paths resolve — **not** `/workspace`. The shell and file
|
||||
tool descriptions state both paths (the working directory, and the
|
||||
workspace named by `TURNSTONE_WORKSPACE`), so the model knows to look in
|
||||
`/workspace` for your files without being told each session.
|
||||
|
||||
To make tools start inside the mount instead, override the working
|
||||
directory on the node services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
turnstone-node:
|
||||
working_dir: /workspace
|
||||
```
|
||||
|
||||
Two caveats before overriding:
|
||||
|
||||
- **SQLite fallback**: when a node runs without PostgreSQL, its fallback
|
||||
database `.turnstone.db` is created in the process working directory.
|
||||
Changing `working_dir` on an existing SQLite-fallback deployment makes
|
||||
the node create a fresh database inside the mount and your prior state
|
||||
appears lost (it is still in the `turnstone-data` volume under `/data`).
|
||||
The stock compose stacks use PostgreSQL and are unaffected.
|
||||
- Migrations (`entrypoint.sh`) run in the same working directory, so the
|
||||
same SQLite caveat applies to them.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
|
||||
+4
-64
@@ -17,9 +17,8 @@ The MCP server admin form exposes three authorization modes ("Multitenant Author
|
||||
| `none` | No headers attached. Open MCP server (or one gated by network policy only). | Internal MCP servers on a trusted network. |
|
||||
| `static` | One static bearer token, configured per server, sent on every request from every user. | Service-to-service MCP servers where per-user attribution doesn't matter, or single-tenant deployments. |
|
||||
| `oauth_user` *(recommended for user-data servers)* | Each user authorizes separately via OAuth 2.1 + PKCE; Turnstone stores per-user tokens encrypted at rest. | MCP servers that expose user-specific data or that want per-user audit attribution. |
|
||||
| `oauth_obo` *(sign-in passthrough)* | Each user's Turnstone **org sign-in** (OIDC) mints a per-server access token on demand — no separate per-server consent. One captured credential per user covers every `oauth_obo` server. | Enterprise deployments where the identity provider governs access (Entra, Keycloak) and you want zero per-user connect clicks. See the dedicated section below. |
|
||||
|
||||
Switching `auth_type` away from `oauth_user` / `oauth_obo` **deletes** that server's per-user rows (consents / minted cache) — see the transition table below. Switching back later starts clean: users re-consent (or re-mint) on next use. The admin **bulk-revoke** / **flush cache** affordance clears rows without an auth-type change.
|
||||
Switching `auth_type` away from `oauth_user` orphans existing per-user tokens. Use the admin **bulk-revoke** affordance on the server row (Phase 9) to clear them, or let them expire naturally — they're inert without the matching `auth_type` value.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,58 +65,6 @@ Keep this in `config.toml` rather than environment variables. An in-process LLM
|
||||
|
||||
---
|
||||
|
||||
## `auth_type=oauth_obo` — single-credential sign-in passthrough
|
||||
|
||||
Where `oauth_user` makes each user complete a **separate** browser consent per MCP server, `oauth_obo` reuses the user's Turnstone **org sign-in** (OIDC). Turnstone captures one refresh credential per user at login and, on each tool call, mints a short-lived access token scoped to that server's audience. There is no per-server connect step, and one credential covers every `oauth_obo` server. This is the right shape when your identity provider already governs who may reach each backend (an Entra tenant with Entra-protected MCP servers; a Keycloak realm with token exchange).
|
||||
|
||||
Access is governed **downstream** by the IdP: a user can only mint a token for a server their delegated permissions allow. Removing that grant at the IdP cuts the user off regardless of their Turnstone state.
|
||||
|
||||
### Deployment configuration (`[oidc]` in `config.toml`)
|
||||
|
||||
`oauth_obo` requires OIDC SSO to be configured (it is the credential source), plus:
|
||||
|
||||
```toml
|
||||
[oidc]
|
||||
# ... your existing issuer / client_id / client_secret ...
|
||||
capture_user_credential = true # persist the IdP refresh token at login
|
||||
obo_grant_profile = "entra" # "entra" | "rfc8693" — how tokens are minted
|
||||
```
|
||||
|
||||
- **`capture_user_credential`** (default `false`): when enabled, Turnstone appends `offline_access` to the login scopes and stores the returned refresh token, encrypted with the same `[security] mcp_token_encryption_key` as `oauth_user` tokens. **The encryption key is required** — Turnstone refuses to start with an `oauth_obo` row (or capture enabled) and no key.
|
||||
- **`obo_grant_profile`** picks the mint mechanism (the IdP determines which one is valid; this is deployment-wide, not per-server):
|
||||
- **`entra`** — redeems the user's refresh token directly for a token scoped to `<audience>/.default`. `oauth_scopes` on the server row is **not used** (the admin form rejects it under this profile).
|
||||
- **`rfc8693`** — a refresh grant for a subject token, then an RFC 8693 token exchange for the server audience. Per-server `oauth_scopes` **are** sent on the exchange (some IdPs require the audience scope explicitly).
|
||||
|
||||
### Adding an `oauth_obo` server
|
||||
|
||||
In the admin MCP form, choose **Sign-in passthrough** and set **Audience** (required — the downstream resource the token is minted for, e.g. `api://<app-id>` on Entra or the client id on Keycloak). The client-id / secret / registration fields do not apply and are hidden.
|
||||
|
||||
`oauth_obo` servers are accepted only when **OIDC sign-in is configured and enabled** and `[oidc] obo_grant_profile` is a valid profile — the write is rejected otherwise, since a row that can never mint would surface to users as a permanent "please retry" that never heals.
|
||||
|
||||
### Identity-provider setup
|
||||
|
||||
**Entra (`obo_grant_profile = "entra"`):**
|
||||
1. Turnstone's app registration must hold **delegated permissions** to each MCP server's exposed API, with **admin consent granted** (or the MCP app listed in Turnstone's `preAuthorizedApplications`).
|
||||
2. Set the server row's Audience to the MCP app's Application ID URI (`api://<guid>`).
|
||||
3. **Gotcha (verified):** admin-consent issued *immediately* after creating the app/service principal can silently skip a not-yet-propagated resource — the only symptom is `AADSTS65001` at mint time. Verify the delegated grant landed (`az ad app permission list-grants` / the portal's *API permissions* blade shows *Granted*), or grant it explicitly per resource. A missing grant surfaces in Turnstone as a re-login prompt on the affected server (same rail as a revoked credential), and the `mcp_server.oauth.obo_mint_rejected` log line carries the raw `AADSTS…` text.
|
||||
|
||||
**Keycloak / RFC 8693 (`obo_grant_profile = "rfc8693"`):**
|
||||
1. Enable **standard token exchange** on Turnstone's client.
|
||||
2. Grant the audience: add an audience client scope for each MCP client and attach it to Turnstone's client (optional scopes must be requested — set the server row's Scopes to that scope, or the exchange returns *"Requested audience not available"*).
|
||||
3. Set the server row's Audience to the downstream client id.
|
||||
|
||||
### Revocation & custody
|
||||
|
||||
The captured credential is a single per-user secret that can mint for every `oauth_obo` server, so treat it like any long-lived credential:
|
||||
|
||||
- **Cut off one user:** unlink their OIDC identity in the admin console (**Users → OIDC identities → delete**). This revokes the captured credential **and** purges their minted cache rows, so future mints fail and cached tokens are dropped. (Warmed in-memory sessions on server nodes self-expire at the access-token TTL; there is no cross-node per-user session-kill.) Removing the user's access at the IdP is the authoritative cut-off.
|
||||
- **Flush a server's minted tokens** (e.g. after narrowing its audience): the server row's **flush cache** action drops all users' cached tokens for that server. This is **not** a revocation — users re-mint on next use from their still-valid sign-in. It is surfaced honestly (audit `mcp_server.oauth.obo_cache_flushed`, response `effect: cache_flush_remints`) so it is never mistaken for cutting access.
|
||||
- Per-server revocation in the `oauth_user` sense does not exist for `oauth_obo` — the credential is issuer-scoped and IdP-governed. Revoke at the IdP.
|
||||
|
||||
> **Interim for Entra without OBO:** if you don't want host-side minting, admin consent + `preAuthorizedApplications` on each MCP app registration removes the second consent prompt for the plain `oauth_user` flow too (a tenant-config change, no Turnstone code). Tracked in issue #682. It does not remove the per-server connect clicks or per-(user, server) token custody — that is what `oauth_obo` is for.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **First tool call** for a user against an `oauth_user` MCP server: pool dispatch finds no stored token, returns `mcp_consent_required` to the agent. Dashboard renders an inline "Connect" action card.
|
||||
@@ -128,7 +75,7 @@ The captured credential is a single per-user secret that can mint for every `oau
|
||||
|
||||
4. **Step-up scope**: when a tool call hits `403` with `WWW-Authenticate: error="insufficient_scope"`, Turnstone emits `mcp_insufficient_scope` with the parsed scope set; the dashboard offers a "Connect with additional scopes" affordance that opens `/v1/api/mcp/oauth/start?server=<name>&scopes=<extra>` so the union of original + new scopes flows into the AS authorize request.
|
||||
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks). `oauth_obo` servers are excluded: their rows are mint cache, not consents — deleting one only forces a re-mint — so the connections list hides them and the endpoint refuses them with `409` (revocation for sign-in passthrough happens at the identity layer: unlink the identity or revoke at the IdP).
|
||||
5. **User revoke** (settings modal): `DELETE /v1/api/mcp/oauth/connections/{server_name}` runs the authoritative local delete + best-effort RFC 7009 upstream revoke (fire-and-forget, capped at 256 concurrent in-flight tasks).
|
||||
|
||||
6. **Admin bulk-revoke** (Phase 9): `POST /v1/api/admin/mcp-servers/{name}/bulk-revoke` drops every user's token for the server. Upstream RFC 7009 revoke is intentionally **not** attempted in bulk (avoids N upstream HTTP calls per admin click); tokens at the AS expire naturally. Use the per-user revoke endpoint if you need guaranteed upstream invalidation.
|
||||
|
||||
@@ -150,13 +97,10 @@ Additional indicators (circuit-breaker state, encryption-key mismatch) are expos
|
||||
| From | To | What happens |
|
||||
|---|---|---|
|
||||
| `none` / `static` → `oauth_user` | — | New code path activates for this server. Existing static headers (if any) are no longer sent. Users must authorize on first use. |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **deleted**: the tokens are bound to the auth model + URL active at consent time, and rows left behind could silently rebind if a row with the old name/URL reappears. Switching back to `oauth_user` later starts clean — users re-consent on next use. This is **not reversible**; the AS-side grants are untouched (revoke upstream via the AS if needed). |
|
||||
| `oauth_user` → `none` / `static` | — | Existing `mcp_user_tokens` rows are **orphaned** — inert without a matching `auth_type`. Use admin bulk-revoke to drop them, or let them expire. Switching back to `oauth_user` later re-activates the orphaned rows if they haven't been deleted. |
|
||||
| OAuth `client_id` or `client_secret` rotated | — | Existing tokens may stop refreshing if the AS treats them as bound to the previous client. Bulk-revoke after rotation. |
|
||||
| `oauth_user` ↔ `oauth_obo` | — | The per-user rows are **deleted** on the flip (they mean different things: per-server AS refresh tokens vs. minted cache). `oauth_audience` and `oauth_scopes` mean different things in each model (a resource indicator vs. an IdP app identifier; AS-consent scopes vs. an rfc8693 exchange scope), so on a flip they **never carry** — each is taken from the request for the target model or set NULL. The admin console clears these fields when you change the auth type, so re-enter the correct values for the new mode; via the API, supply them explicitly (a flip into `oauth_obo` with no `oauth_audience` is rejected, and a non-empty `oauth_scopes` under the `entra` profile is rejected since that leg pins `<audience>/.default`). |
|
||||
| `oauth_obo` → `none` / `static` | — | Minted cache rows are deleted. |
|
||||
| `oauth_obo` **audience**, **URL**, or **`oauth_scopes`** changed | — | Minted cache rows are **deleted** (tokens are bound to the audience/URL/scopes at mint time), forcing a fresh mint — so an audience or scope narrowing takes effect immediately, not at token expiry. |
|
||||
|
||||
Every transition that changes what a stored row *means* deletes the rows outright — a stale consent or minted token must never be served under new semantics. There is no orphan-and-reactivate path.
|
||||
The orphan-by-default behavior is chosen so switching back to `oauth_user` is non-destructive. Bulk-revoke is the explicit cleanup path.
|
||||
|
||||
---
|
||||
|
||||
@@ -169,9 +113,5 @@ Every transition that changes what a stored row *means* deletes the rows outrigh
|
||||
| `mcp_oauth_url_insecure` | MCP server URL is `http://` (not `https://`) on a non-loopback host | Use `https://`. Per-user bearers must not transit cleartext. |
|
||||
| Tools fail in scheduled / Discord / Slack runs | OAuth-MCP requires browser-based consent | Users must pre-consent via the web UI. Phase 9 dashboard badge surfaces deferred consents from these runs on next login. |
|
||||
| Circuit breaker open repeatedly | Transport-level errors on the MCP server (DNS, TLS, 5xx) | Check the per-server error pill; auth errors do not trip the breaker. |
|
||||
| **`oauth_obo`**: every tool call fails, log shows `obo_misconfigured` | Server row has no Audience, or `obo_grant_profile` is unset/unknown | Set the Audience on the server row; set `[oidc] obo_grant_profile` to `entra` or `rfc8693`. |
|
||||
| **`oauth_obo`**: `obo_mint_rejected` with `AADSTS65001` | Turnstone's app lacks the (admin-consented) delegated grant to this MCP app — often admin consent that didn't propagate | Grant + admin-consent the delegated permission for this resource; verify it shows *Granted*. See the Entra gotcha above. |
|
||||
| **`oauth_obo`**: "Sign in to Turnstone again" on one server | Captured credential missing/rejected, or a Conditional Access challenge | User re-logs into Turnstone (re-captures the credential). If it persists, check the IdP grant / CA policy. |
|
||||
| **`oauth_obo`**: tools don't appear at all for a user | User has not signed in since `capture_user_credential` was enabled (no credential captured) | User logs out and back in via OIDC so the refresh credential is captured. |
|
||||
|
||||
See also: `docs/operations/mcp-oauth-headless.md` for the cron / channel-driven run caveat.
|
||||
|
||||
+9
-9
@@ -77,17 +77,17 @@ IdP from redirecting the token-exchange POST (which carries
|
||||
being aimed at internal services.
|
||||
|
||||
A few public IdPs legitimately split endpoints across hostnames. Google
|
||||
and Microsoft Entra ID are the canonical examples:
|
||||
is the canonical example:
|
||||
|
||||
| IdP | Issuer host | Cross-host endpoint(s) |
|
||||
|-----|-------------|------------------------|
|
||||
| Google | `accounts.google.com` | `oauth2.googleapis.com`, `www.googleapis.com`, `openidconnect.googleapis.com` |
|
||||
| Microsoft Entra | `login.microsoftonline.com` | `graph.microsoft.com` (userinfo) |
|
||||
| Field | Hostname |
|
||||
|-------|----------|
|
||||
| issuer | `accounts.google.com` |
|
||||
| token_endpoint | `oauth2.googleapis.com` |
|
||||
| jwks_uri | `www.googleapis.com` |
|
||||
| userinfo_endpoint | `openidconnect.googleapis.com` |
|
||||
|
||||
Both sets are built in — operators using `https://accounts.google.com` or
|
||||
`https://login.microsoftonline.com/<tenant>/v2.0` need no extra
|
||||
configuration. (Entra's discovery document advertises `userinfo_endpoint`
|
||||
on `graph.microsoft.com`, distinct from the issuer host.)
|
||||
Google's set is built in — operators using `https://accounts.google.com`
|
||||
need no extra configuration.
|
||||
|
||||
For other IdPs whose discovery document references a non-issuer host,
|
||||
extend the allow-list explicitly:
|
||||
|
||||
+12
-28
@@ -28,19 +28,13 @@ schema plus turnstone-specific metadata keys:
|
||||
}
|
||||
```
|
||||
|
||||
**Metadata keys** (stripped before sending the schema to the model; the full
|
||||
set lives in `_META_KEYS` in `turnstone/core/tools.py`):
|
||||
**Metadata keys** (stripped before sending the schema to the model):
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|------------------|------|---------|
|
||||
| `task_agent` | bool | Tool is available to task sub-agents. |
|
||||
| `coordinator` | bool | Tool is available to coordinator sessions. Without `interactive: true` alongside it, this reads as coord-only and the tool is stripped from interactive sessions. |
|
||||
| `interactive` | bool | Opt a `coordinator: true` tool back into interactive sessions (dual-kind tools like `memory`). |
|
||||
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
||||
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
||||
| `kind_variants` | dict | Per-kind description / parameter-schema overlays so each session kind sees only the surface it can use (see `memory.json`). |
|
||||
| `cwd_note` | str | Sentence appended to the description at session build time with `{working_dir}` substituted — declare on tools whose semantics depend on the process working directory (see `bash.json`, `apply_cwd_context`). |
|
||||
| `workspace_note` | str | Companion sentence naming the operator-configured workspace directory, `{workspace_dir}` substituted; dropped when no workspace is configured. |
|
||||
| Key | Type | Meaning |
|
||||
|----------------|------|---------|
|
||||
| `task_agent` | bool | Tool is available to task sub-agents. |
|
||||
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
||||
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
||||
|
||||
---
|
||||
|
||||
@@ -785,10 +779,7 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
|
||||
their capabilities send `notifications/tools/list_changed` when their tool list
|
||||
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
|
||||
that triggers an immediate refresh for that server (debounced per server and
|
||||
notification kind, and run off the receive loop). A refresh that fails while
|
||||
the connection stays up is retried automatically on the next health-loop tick
|
||||
until one completes.
|
||||
that triggers an immediate refresh for that server.
|
||||
|
||||
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
|
||||
`/mcp refresh <server>` targets a single server. If a server has disconnected,
|
||||
@@ -796,10 +787,6 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
|
||||
same controls (refresh / reconnect buttons per server) for cluster-wide
|
||||
fan-out.
|
||||
|
||||
Reconnects (health-loop, dispatch-driven, or operator-forced) always end in a
|
||||
full catalog rediscovery, so a server that changed its tools while disconnected
|
||||
comes back current.
|
||||
|
||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||
@@ -870,16 +857,13 @@ catalog.
|
||||
|
||||
### Refresh
|
||||
|
||||
Resource lists stay current through the same mechanisms as tool lists:
|
||||
Resource lists stay current through the same three-tier mechanism as tool lists:
|
||||
|
||||
1. **Push** -- Servers declaring `resources.listChanged: true` send
|
||||
`notifications/resources/list_changed`, triggering an immediate refresh
|
||||
(with the same failed-refresh retry on the health-loop tick).
|
||||
2. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
|
||||
|
||||
Servers without push support are refreshed whenever they reconnect (every
|
||||
reconnect ends in full rediscovery) or when an operator refreshes manually;
|
||||
there is no periodic polling.
|
||||
`notifications/resources/list_changed`, triggering an immediate refresh.
|
||||
2. **Periodic** -- Servers without push are polled on the configured refresh
|
||||
interval (default 4 hours, same timer as tools).
|
||||
3. **Manual** -- `/mcp refresh` re-fetches resources alongside tools.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.8.0a5"
|
||||
version = "1.7.4"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"openai>=2.45", # GPT-5.6: typed reasoning.mode, prompt_cache_options, and cache_write_tokens
|
||||
"anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params.
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
|
||||
"starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor
|
||||
@@ -88,7 +88,7 @@ include = [
|
||||
"turnstone/console/static/coordinator/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.18.1/**/*",
|
||||
"turnstone/shared_static/katex-0.17.0/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.16.0/**/*",
|
||||
"turnstone/shared_static/hls-1.6.16/**/*",
|
||||
@@ -103,7 +103,6 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"live: requires a running LLM backend",
|
||||
"allow_thread_leak: test intentionally leaves a background thread running (opts out of the leaked-thread guard)",
|
||||
"e2e_recovery: opt-in end-to-end SSE recovery harness (real server + real SSE consumers, scripted provider — NOT live, no LLM backend needed); tens of seconds each. CI lanes run ``-m 'not live and not e2e_recovery'``; select with ``-m e2e_recovery``.",
|
||||
]
|
||||
filterwarnings = [
|
||||
# mcp v1 deprecates streamablehttp_client for an entry point whose call
|
||||
|
||||
+1
-148
@@ -45,22 +45,6 @@ Shell harness (?split=): right (default) · down · three · none — boots the
|
||||
document.title stamps SPLIT-READY-<visible cells> on success and
|
||||
SPLIT-FAILED-<reason> when a driven split was denied — judge the focused
|
||||
cell's top accent bar, the separators, and the .shown tab marker.
|
||||
Proxy-brand harness (/proxybrand/livepass.html): back-to-console from a
|
||||
PROXIED node view, driven end to end. An iframe hosts a node page built
|
||||
from the REAL shell.js rail plus the REAL _JS_PROXY_SHIM (read out of
|
||||
turnstone/console/server.py by text, never imported -- scripts/ has no
|
||||
sys.path guard, so an import would silently pick up site-packages). The
|
||||
host clicks the brand's child span and, because the shim navigates the
|
||||
FRAME away, reads the frame's post-navigation location from the surviving
|
||||
top page. document.title stamps PROXYBRAND-READY, or
|
||||
PROXYBRAND-FAILED-<reason>: sub-not-repointed-server (nothing wired),
|
||||
showhome-also-ran (shell.js won the click), nav-<path> (went somewhere
|
||||
other than the console root), sub-not-console-<text>, aria-not-repointed,
|
||||
no-navigation, no-brand, no-sub. Needs --virtual-time-budget=9000;
|
||||
there is nothing to screenshot. Read the verdict from <title> --
|
||||
both literals also appear in the host page's inline script, so a bare
|
||||
grep over --dump-dom output false-positives.
|
||||
|
||||
Attachments harness (/attachments/livepass.html): the composer attachment
|
||||
chips + the sent-message attachment pills, both driven through the REAL
|
||||
code paths — createAttachmentController.rehydrate() builds the chips and
|
||||
@@ -159,24 +143,6 @@ def extract_admin_fragment() -> str:
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def extract_proxy_shim(prefix: str = "/node/livepass-node") -> str:
|
||||
"""Pull ``_JS_PROXY_SHIM`` out of console/server.py BY TEXT, not import.
|
||||
|
||||
``scripts/`` has no ``sys.path`` guard, so ``import turnstone`` from here
|
||||
resolves to whatever is installed in site-packages rather than this
|
||||
checkout -- silently building the page from a DIFFERENT version of the
|
||||
shim than the one you are trying to verify. Read the source instead.
|
||||
"""
|
||||
src = (ROOT / "turnstone/console/server.py").read_text(encoding="utf-8")
|
||||
m = re.search(r'^_JS_PROXY_SHIM = """\\\n(.*?)^"""', src, re.S | re.M)
|
||||
if not m:
|
||||
raise SystemExit(
|
||||
"livepass: could not find _JS_PROXY_SHIM in turnstone/console/server.py "
|
||||
"-- the constant was renamed or reshaped; update extract_proxy_shim()."
|
||||
)
|
||||
return m.group(1).replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
|
||||
|
||||
|
||||
def inject(template: str, marker: str, payload: str) -> str:
|
||||
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
|
||||
end = template.index(f"<!-- {marker}:END -->")
|
||||
@@ -433,7 +399,7 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
known: true,
|
||||
capabilities: {
|
||||
context_window: 200000, supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true, supports_vision: true,
|
||||
supports_web_search: true, supports_temperature: true,
|
||||
supports_effort: true,
|
||||
},
|
||||
@@ -692,108 +658,6 @@ SHELL_TEMPLATE = """<!doctype html>
|
||||
# call the same window.buildAttachmentPreview). The page frame is harness-only
|
||||
# chrome and not under review; the chips row and the pill row are.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# The PROXIED NODE page: the real L-shell (so the rail brand is the real
|
||||
# element, with the real shell.js click listener on it) plus the real proxy
|
||||
# shim injected exactly where proxy_index puts it -- first thing inside
|
||||
# <body>, ahead of the deferred shell.js module. caps mirror a NODE, not
|
||||
# the console: brandSub "server" is what the shim has to overwrite, and
|
||||
# leaving it "console" would make the host's /console/i check vacuous.
|
||||
PROXYBRAND_FRAME_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>proxied node</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="static/style.css" />
|
||||
<link rel="stylesheet" href="shared/shell.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- SHIM:BEGIN -->
|
||||
<!-- SHIM:END -->
|
||||
<div id="header" style="display: none"><div id="status-bar"></div></div>
|
||||
<div id="main" style="padding: 18px">
|
||||
<h2 style="margin: 0 0 8px">Node dashboard</h2>
|
||||
</div>
|
||||
<div id="view-admin" style="display: none"></div>
|
||||
<script>
|
||||
window.TURNSTONE_SHELL_CAPS = { cluster: false, brandSub: "server" };
|
||||
window.TS_APP = {
|
||||
boot() {},
|
||||
getClusterState() { return { nodes: {} }; },
|
||||
onRender() {},
|
||||
};
|
||||
window.TS_ADMIN = {};
|
||||
// Record on the PARENT, which survives the frame's navigation.
|
||||
// A flag on the frame's own window dies with the document, so the
|
||||
// host would read undefined and pass -- a check that cannot fail.
|
||||
window.showHome = function () {
|
||||
try { window.parent.__showHomeRan = true; } catch (e) {}
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="shared/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# The HOST page. The shim navigates the FRAME to "/", which would destroy
|
||||
# any verdict stamped inside it -- so the surviving top page reads the
|
||||
# frame's post-navigation location and stamps its own title instead. No
|
||||
# landing page at "/" is needed (the harness root serves a directory
|
||||
# listing) and no CDP client either.
|
||||
PROXYBRAND_HOST_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>proxybrand livepass</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
iframe { width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="frame" src="frame.html"></iframe>
|
||||
<script>
|
||||
const frame = document.getElementById("frame");
|
||||
let phase = 0;
|
||||
const fail = (r) => { phase = 9; document.title = "PROXYBRAND-FAILED-" + r; };
|
||||
|
||||
frame.addEventListener("load", () => {
|
||||
if (phase === 9) return;
|
||||
if (phase === 0) {
|
||||
const doc = frame.contentDocument;
|
||||
const brand = doc.querySelector(".rail-brand .brand-home");
|
||||
if (!brand) return fail("no-brand");
|
||||
const sub = brand.querySelector(".brand-sub");
|
||||
if (!sub) return fail("no-sub");
|
||||
const text = sub.textContent.trim();
|
||||
if (text === "server") return fail("sub-not-repointed-server");
|
||||
if (!/console/i.test(text)) return fail("sub-not-console-" + text);
|
||||
if (brand.getAttribute("aria-label") !== "Back to console")
|
||||
return fail("aria-not-repointed");
|
||||
phase = 1;
|
||||
// Click the CHILD span, as a real user does: the shim must match
|
||||
// via contains(), not target identity.
|
||||
sub.click();
|
||||
setTimeout(() => { if (phase === 1) fail("no-navigation"); }, 2000);
|
||||
return;
|
||||
}
|
||||
const path = frame.contentWindow.location.pathname;
|
||||
const ranShowHome = !!window.__showHomeRan;
|
||||
phase = 2;
|
||||
if (path !== "/") return fail("nav-" + path);
|
||||
if (ranShowHome) return fail("showhome-also-ran");
|
||||
// Sticky, mirroring fail(): a third load must not re-stamp.
|
||||
phase = 9;
|
||||
document.title = "PROXYBRAND-READY";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
ATTACH_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -1619,17 +1483,6 @@ def build(out: Path) -> None:
|
||||
(pf / "livepass.html").write_text(PERF_TEMPLATE, encoding="utf-8")
|
||||
print(f"{pf}/livepass.html — long-session perf baseline (real InteractivePane)")
|
||||
|
||||
pb = out / "proxybrand"
|
||||
pb.mkdir(parents=True, exist_ok=True)
|
||||
symlink(pb / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(pb / "static", ROOT / "turnstone/ui/static")
|
||||
shim = "<script>" + extract_proxy_shim() + "</script>"
|
||||
(pb / "frame.html").write_text(
|
||||
inject(PROXYBRAND_FRAME_TEMPLATE, "SHIM", shim), encoding="utf-8"
|
||||
)
|
||||
(pb / "livepass.html").write_text(PROXYBRAND_HOST_TEMPLATE, encoding="utf-8")
|
||||
print(f"{pb}/livepass.html — back-to-console brand (real shell.js + real shim)")
|
||||
|
||||
|
||||
class _PerfStore:
|
||||
"""Rendezvous for the perf page's POSTed JSON report."""
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Entra config for the Entra e2e / spike harnesses. Copy to `.env` (gitignored)
|
||||
# and fill in from your tenant. `entra_setup.sh setup` creates the app
|
||||
# registrations and writes a populated `.env` for you.
|
||||
#
|
||||
# cp scripts/obo-e2e/.env.example scripts/obo-e2e/.env
|
||||
# # then edit, or run: ./scripts/obo-e2e/entra_setup.sh setup
|
||||
|
||||
export ENTRA_TENANT_ID=<tenant-guid-or-domain>
|
||||
export ENTRA_CLIENT_ID=<turnstone-spike-app-client-id>
|
||||
export ENTRA_CLIENT_SECRET=<client-secret>
|
||||
export SPIKE_AUDIENCE_A=api://<resource-app-a-guid> # a consented resource
|
||||
export SPIKE_AUDIENCE_B=api://<resource-app-b-guid> # a second consented resource
|
||||
export SPIKE_AUDIENCE_UNCONSENTED=api://<resource-app-c-guid> # NOT granted (negative case)
|
||||
export SPIKE_RUN_OBO=1
|
||||
# export SPIKE_PORT=8765 # redirect-listener port (default 8765)
|
||||
# export SPIKE_CALLBACK_FILE=/tmp/obo_cb.txt # remote-browser mode: paste the redirect URL here
|
||||
|
||||
# The Keycloak / OSS-path harness needs no config — keycloak_e2e.sh sets
|
||||
# everything and stands up an ephemeral container.
|
||||
@@ -1,214 +0,0 @@
|
||||
# OBO e2e harnesses — single-credential MCP token minting (`auth_type=oauth_obo`)
|
||||
|
||||
Manual test harnesses for the `oauth_obo` feature (issue #551). They exercise
|
||||
the **real** Turnstone mint path (`get_obo_access_token_classified` →
|
||||
`_obo_mint_entra` / `_obo_mint_rfc8693`) against a real identity provider — not
|
||||
mocks, not the unit suite. Two grant legs:
|
||||
|
||||
- **Entra** (`entra_e2e.py`) — real tenant, one interactive sign-in.
|
||||
- **Keycloak / RFC 8693** (`keycloak_e2e.py` + `.sh`) — ephemeral docker, fully
|
||||
headless.
|
||||
|
||||
There is also `entra_spike.py` (raw-OAuth **wire** probe, pre-implementation
|
||||
reference) and `entra_setup.sh` (creates the Entra app registrations + writes a
|
||||
populated `.env`).
|
||||
|
||||
**Secrets:** these read config from env. Real credentials live in a **gitignored
|
||||
`.env`** (copy `.env.example`); nothing tenant-specific is committed. The only
|
||||
literal secret in the tree is the ephemeral Keycloak container's throwaway
|
||||
`spike-secret`, which lives and dies with the container.
|
||||
|
||||
Not part of CI — run by hand when validating the feature against a live IdP.
|
||||
|
||||
## `entra_e2e.py` — end-to-end product exercise (post-implementation)
|
||||
|
||||
`entra_spike.py` verified the raw OAuth WIRE (before code existed). `entra_e2e.py`
|
||||
verifies the SHIPPED Turnstone code: it does a real Entra login, feeds the
|
||||
credential through the real `MCPTokenStore.upsert_oidc_credential` (the call the
|
||||
OIDC callback makes on capture), then drives the real
|
||||
`get_obo_access_token_classified` → `_obo_mint_entra` against the live Entra token
|
||||
endpoint. Checks E1–E7: real mint + aud claim, cache-hit (0 Entra calls),
|
||||
single-credential→audiences A&B, rotation write-back, force_refresh re-mint,
|
||||
unconsented-audience classification with the credential surviving, and
|
||||
flush→re-mint. Reuses the same `.env` and interactive login (SPIKE_CALLBACK_FILE
|
||||
for remote browser).
|
||||
|
||||
```bash
|
||||
source scripts/obo-e2e/.env
|
||||
uv run python scripts/obo-e2e/entra_e2e.py
|
||||
# one interactive sign-in; E1–E7 then run against the real product code. Results below.
|
||||
```
|
||||
|
||||
Results — RUN 2026-07-12 on the real tenant, ALL VERIFIED (exit 0): capture
|
||||
persisted; E1 mint A (aud=A app-id, cache row refresh_token_ct NULL); E2 cache
|
||||
hit (0 extra Entra calls); E3 mint B from the SAME credential (aud=B app-id); E4
|
||||
rotation write-back (RT rotated 2040→2091 chars, newest persisted); E5
|
||||
force_refresh re-mint (1 Entra call); E6 unconsented C → refresh_failed and the
|
||||
credential SURVIVES; E7 flush→re-mint. The real `get_obo_access_token_classified`
|
||||
→ `_obo_mint_entra` path against the live Entra token endpoint.
|
||||
|
||||
## `keycloak_e2e.py` + `keycloak_e2e.sh` — OSS path (RFC 8693), headless
|
||||
|
||||
The rfc8693 equivalent of `entra_e2e.py`: `keycloak_e2e.sh` spins up ephemeral
|
||||
Keycloak, configures the realm (turnstone client with standard token exchange,
|
||||
mcp-a/b/c clients, aud-mcp-a/b audience scopes, a test user), runs the harness
|
||||
against the real `get_obo_access_token_classified` → `_obo_mint_rfc8693`
|
||||
(refresh grant → token exchange), then tears down. No browser (password grant).
|
||||
|
||||
```bash
|
||||
./scripts/obo-e2e/keycloak_e2e.sh
|
||||
```
|
||||
|
||||
Results — RUN 2026-07-12, ALL VERIFIED: capture persisted; E1 mint A
|
||||
(refresh→exchange, aud=mcp-a, cache row refresh_token_ct NULL); E2 cache hit (0
|
||||
extra KC calls); E3 mint B from the SAME credential (aud=mcp-b); E4 rotation
|
||||
write-back (KC rotated the RT on the refresh leg, newest persisted); E5
|
||||
force_refresh re-mint (**2 KC calls** = the two-leg chain); E6 unconsented C →
|
||||
refresh_failed_transient (KC returns invalid_request for a missing audience
|
||||
scope → classified transient; credential SURVIVES either way); E7 flush→re-mint.
|
||||
Gotcha: dev-mode Keycloak boot is slow on a loaded host — the script now waits on
|
||||
kcadm auth (up to ~6 min) rather than a fixed sleep. Port 8091 (8090 = the dev
|
||||
console).
|
||||
|
||||
## Leg 1 — Entra (`entra_spike.py`) — NEEDS TENANT ACCESS
|
||||
|
||||
### Tenant / app-registration setup (one-time, ~15 min)
|
||||
|
||||
1. **Spike client app** (stands in for Turnstone's OIDC app registration):
|
||||
- New app registration, single tenant. Platform **Web**, redirect URI
|
||||
`http://localhost:8765/callback`. Create a **client secret**.
|
||||
2. **Two resource apps** (stand in for MCP servers A and B):
|
||||
- New app registrations `spike-mcp-a`, `spike-mcp-b`. In each:
|
||||
**Expose an API** → set Application ID URI (`api://<guid>`) → add a scope
|
||||
(e.g. `mcp.access`).
|
||||
3. **Delegated grants** (this is metaclassing's "proper tenant and app reg setup"):
|
||||
- On the spike client app → **API permissions** → add delegated permission to
|
||||
`spike-mcp-a` and `spike-mcp-b` scopes → **Grant admin consent**.
|
||||
- Optionally also add the spike client's app id to each resource app's
|
||||
`preAuthorizedApplications` (Expose an API → Add a client application) to
|
||||
compare against pure admin consent.
|
||||
4. **Unconsented control** (for V5): a third resource app `spike-mcp-c` with an
|
||||
exposed API but NO permission granted to the spike client.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
export ENTRA_TENANT_ID=... ENTRA_CLIENT_ID=... ENTRA_CLIENT_SECRET=...
|
||||
export SPIKE_AUDIENCE_A=api://<a-guid> SPIKE_AUDIENCE_B=api://<b-guid>
|
||||
export SPIKE_AUDIENCE_UNCONSENTED=api://<c-guid> # optional (V5)
|
||||
export SPIKE_RUN_OBO=1 # optional (V6)
|
||||
uv run python scripts/obo-e2e/entra_spike.py
|
||||
```
|
||||
|
||||
A browser opens for one interactive login (any tenant user). Everything after is
|
||||
non-interactive — that IS the feature.
|
||||
|
||||
### What each check pins down
|
||||
|
||||
| Check | Design assumption it verifies |
|
||||
| --- | --- |
|
||||
| V1 | `offline_access` on the login yields a client-bound RT (capture layer) |
|
||||
| V2/V3 | ONE RT redeems for access tokens of DIFFERENT audiences (`scope=<aud>/.default`) — the load-bearing Entra behavior |
|
||||
| V4 | rotation semantics → whether RT write-back on every mint is convenience or correctness-critical |
|
||||
| V5 | unconsented audience fails `AADSTS65001 consent_required` → maps to the reconnect-rail fallback, never a silent failure |
|
||||
| V6 | OBO jwt-bearer middle-tier variant works with the same app registration (comparison data only) |
|
||||
|
||||
Also record (manual): whether Conditional Access / MFA policies in the tenant
|
||||
produce `interaction_required` on redemption — that's the fallback path's other
|
||||
trigger.
|
||||
|
||||
### Results — RUN 2026-07-11 on a real tenant, ALL SIX VERIFIED
|
||||
|
||||
Tenant: personal default directory (Global Admin), user is an MSA member.
|
||||
Setup via `entra_setup.sh setup`; V3 initially failed (see gotcha below),
|
||||
passed after fixing the grant. Second run: V1-V6 all VERIFIED, exit 0.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| V1 offline_access login -> RT | VERIFIED (confidential client + PKCE, RT ~2KB) |
|
||||
| V2 RT -> audience A token | VERIFIED (`aud=<A app guid>`, ~70 min TTL, new RT returned) |
|
||||
| V3 SAME RT -> audience B token | **VERIFIED — the load-bearing claim: one RT, many audiences** |
|
||||
| V4 rotation | VERIFIED: RT rotates on every redemption, but the OLD RT stays valid (reuse HTTP 200) -> write-back-newest is required; races are benign on Entra |
|
||||
| V5 unconsented audience | VERIFIED: `invalid_grant` + `AADSTS65001` (error_codes=[65001]) -> clean mapping to the reconnect-rail fallback |
|
||||
| V6 OBO jwt-bearer variant | VERIFIED: middle-tier shape also works with the same app registration |
|
||||
|
||||
**Operator gotcha (feeds #682 + product docs):** `az ad app permission
|
||||
admin-consent` run immediately after SP creation SILENTLY skips
|
||||
not-yet-propagated resource SPs — grant A landed, grant B didn't, and the only
|
||||
symptom was AADSTS65001 at redemption. Verify grants after consent
|
||||
(`oauth2PermissionGrants` filter on the client SP) or write them directly with
|
||||
`az ad app permission grant --id <client> --api <resource> --scope <scope>`.
|
||||
Product-side implication: a missing tenant grant for a NEW oauth_obo server
|
||||
surfaces as AADSTS65001 -> the same reconnect-rail path as revocation; the
|
||||
admin docs must say "grant first, then add the server".
|
||||
|
||||
## Leg 2 — Keycloak RFC 8693 (portability check) — runnable locally
|
||||
|
||||
Ephemeral `quay.io/keycloak/keycloak:26.3` (`start-dev`, port 8089), realm
|
||||
`spike`, confidential client `turnstone` with **standard token exchange**
|
||||
enabled, resource clients `mcp-a`/`mcp-b`, user `alice`. Pipeline mirrors the
|
||||
product design for a generic-8693 IdP:
|
||||
|
||||
```
|
||||
stored user RT --(refresh grant)--> user AT --(RFC 8693 exchange, audience=mcp-X)--> audience-scoped AT
|
||||
```
|
||||
|
||||
i.e. the per-user credential stays ONE refresh token; per-server tokens are
|
||||
minted via standard token exchange instead of Entra's multi-resource RT
|
||||
redemption. Same substrate, different grant leg.
|
||||
|
||||
### Results — RUN 2026-07-11, VERIFIED (Keycloak 26.3, ephemeral)
|
||||
|
||||
```
|
||||
alice ONE stored RT
|
||||
-> refresh grant -> user AT (azp=turnstone); RT ROTATED on refresh
|
||||
-> 8693 exchange audience=mcp-a scope=aud-mcp-a -> AT aud=mcp-a user=alice 300s, NO RT
|
||||
-> 8693 exchange audience=mcp-b scope=aud-mcp-b -> AT aud=mcp-b (same subject AT)
|
||||
negative control audience=mcp-c -> invalid_client "Audience not found"
|
||||
```
|
||||
|
||||
Findings that feed the design:
|
||||
1. **One per-user credential -> N audience tokens: VERIFIED on a second IdP.**
|
||||
The substrate is portable; only the grant leg differs per IdP.
|
||||
2. **Exchanged tokens are cache-shaped** (short TTL, no RT) — per-server
|
||||
`mcp_user_tokens` rows as short-lived mint cache is the right model.
|
||||
3. **RT rotation happens here too** — newest-RT write-back on every redemption
|
||||
is a correctness requirement of the capture layer, not an Entra quirk.
|
||||
4. **The IdP-side "delegated grant" has a per-IdP shape**: Entra = API
|
||||
permissions + admin consent; Keycloak = audience client scopes attached to
|
||||
the requester client (optional scopes activate via `scope=` at exchange).
|
||||
Operator runbooks are per-IdP (#682 pattern), code is not.
|
||||
5. Gotchas hit: KC user needs a complete profile for direct grant ("Account is
|
||||
not fully set up"); optional audience scope must be requested explicitly or
|
||||
the exchange 400s with "Requested audience not available".
|
||||
|
||||
Repro (ephemeral, ~2 min):
|
||||
|
||||
```bash
|
||||
docker run -d --name kc-obo-spike -p 127.0.0.1:8089:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.3 start-dev
|
||||
KC="docker exec kc-obo-spike /opt/keycloak/bin/kcadm.sh"
|
||||
$KC config credentials --server http://localhost:8080 --realm master --user admin --password admin
|
||||
$KC create realms -s realm=spike -s enabled=true
|
||||
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
|
||||
-s secret=spike-secret -s directAccessGrantsEnabled=true \
|
||||
-s 'attributes={"standard.token.exchange.enabled":"true"}'
|
||||
$KC create clients -r spike -s clientId=mcp-a -s enabled=true -s publicClient=false -s secret=x
|
||||
$KC create clients -r spike -s clientId=mcp-b -s enabled=true -s publicClient=false -s secret=x
|
||||
$KC create users -r spike -s username=alice -s enabled=true -s email=a@s.test \
|
||||
-s emailVerified=true -s firstName=A -s lastName=S
|
||||
$KC set-password -r spike --username alice --new-password alice-pw
|
||||
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
|
||||
for t in mcp-a mcp-b; do
|
||||
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
|
||||
$KC create client-scopes/$SID/protocol-mappers/models -r spike -s name=aud-$t \
|
||||
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
|
||||
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}"
|
||||
$KC update clients/$TURNSTONE_UUID/optional-client-scopes/$SID -r spike
|
||||
done
|
||||
# then: password grant -> refresh grant -> token-exchange with
|
||||
# grant_type=urn:ietf:params:oauth:grant-type:token-exchange,
|
||||
# subject_token=<user AT>, subject_token_type=...:access_token,
|
||||
# audience=mcp-a, scope=aud-mcp-a
|
||||
```
|
||||
@@ -1,286 +0,0 @@
|
||||
"""End-to-end exercise of the oauth_obo feature against a REAL Entra tenant.
|
||||
|
||||
Unlike ``entra_spike.py`` (which verified the raw OAuth wire shapes), this
|
||||
drives the ACTUAL Turnstone product code — real ``MCPTokenStore``, real
|
||||
``get_obo_access_token_classified`` → ``_obo_mint_entra`` → the real Entra
|
||||
token endpoint — so a green run proves the shipped mint engine works against
|
||||
live Entra, not just that the protocol does.
|
||||
|
||||
Flow:
|
||||
1. Interactive Entra login (auth-code + PKCE + offline_access) → a real
|
||||
refresh credential. This is what ``handle_oidc_callback`` receives.
|
||||
2. Persist it via ``MCPTokenStore.upsert_oidc_credential`` — the exact call
|
||||
the OIDC callback makes on capture (auth.py). The rest of the callback
|
||||
(JWKS validation, user provisioning) is OIDC-generic and unit-tested; the
|
||||
novel path is capture + mint, which this exercises for real.
|
||||
3. Seed real ``oauth_obo`` ``mcp_servers`` rows (audiences A/B consented, C
|
||||
not) and drive ``get_obo_access_token_classified`` — the real dispatch-time
|
||||
entry point — asserting on the minted tokens, cache, rotation, and
|
||||
classification.
|
||||
|
||||
Checks (VERIFIED / FAILED per line):
|
||||
E1 mint for audience A → kind=token; decoded aud == A; cache row written with
|
||||
refresh_token_ct NULL (cache, not custody); expires_at set
|
||||
E2 second call for A → cache hit, ZERO additional Entra calls
|
||||
E3 mint for audience B from the SAME captured credential → aud == B
|
||||
(the single-credential-many-audiences thesis, through the real engine)
|
||||
E4 rotation write-back: the stored credential holds the newest refresh token
|
||||
E5 force_refresh → a fresh mint (Entra call count increments)
|
||||
E6 unconsented audience C → NOT kind=token, and the shared credential SURVIVES
|
||||
(never auto-deleted — the load-bearing custody invariant)
|
||||
E7 cache flush → re-mint: deleting the cache row makes the next call re-mint
|
||||
|
||||
Run:
|
||||
source scripts/obo-e2e/.env
|
||||
uv run python scripts/obo-e2e/entra_e2e.py
|
||||
Env (from .env): ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET,
|
||||
SPIKE_AUDIENCE_A, SPIKE_AUDIENCE_B, SPIKE_AUDIENCE_UNCONSENTED, SPIKE_PORT.
|
||||
Remote browser: set SPIKE_CALLBACK_FILE to paste the redirect URL (as before).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
# Reuse the verified interactive-login machinery from the wire spike.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from entra_spike import interactive_login, jwt_claims_unverified, redact # noqa: E402
|
||||
|
||||
from turnstone.core.mcp_crypto import ( # noqa: E402
|
||||
MCPTokenCipher,
|
||||
MCPTokenCipherConfig,
|
||||
MCPTokenStore,
|
||||
)
|
||||
from turnstone.core.mcp_oauth import get_obo_access_token_classified # noqa: E402
|
||||
from turnstone.core.oidc import OIDCConfig # noqa: E402
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend # noqa: E402
|
||||
|
||||
USER = "e2e-user"
|
||||
RESULTS: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def record(status: str, msg: str) -> None:
|
||||
RESULTS.append((status, msg))
|
||||
print(f"[{status:>8}] {msg}")
|
||||
|
||||
|
||||
def aud_matches(token: str, want_audience: str) -> tuple[bool, str]:
|
||||
"""Compare a minted access token's aud claim to the configured audience.
|
||||
|
||||
Entra returns aud as the bare app-id GUID or the full ``api://<guid>`` URI;
|
||||
accept either.
|
||||
"""
|
||||
claims = jwt_claims_unverified(token)
|
||||
aud = str(claims.get("aud", "<none>"))
|
||||
want = want_audience.removeprefix("api://")
|
||||
return aud in (want, want_audience), aud
|
||||
|
||||
|
||||
class _CountingClient:
|
||||
"""Wraps httpx.AsyncClient, counting token-endpoint POSTs so cache hits
|
||||
(which must issue zero) are observable."""
|
||||
|
||||
def __init__(self, inner: httpx.AsyncClient) -> None:
|
||||
self._inner = inner
|
||||
self.posts = 0
|
||||
|
||||
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||
self.posts += 1
|
||||
return await self._inner.post(*args, **kwargs)
|
||||
|
||||
|
||||
def _make_app_state(
|
||||
storage: SQLiteBackend,
|
||||
store: MCPTokenStore,
|
||||
oidc_config: OIDCConfig,
|
||||
http_client: _CountingClient,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
auth_storage=storage,
|
||||
mcp_token_store=store,
|
||||
oidc_config=oidc_config,
|
||||
obo_http_client=http_client,
|
||||
mcp_oauth_refresh_locks={},
|
||||
mcp_oauth_refresh_backoff={},
|
||||
)
|
||||
|
||||
|
||||
def _seed_obo_server(storage: SQLiteBackend, name: str, audience: str) -> None:
|
||||
storage.create_mcp_server(
|
||||
server_id=f"{name}-id",
|
||||
name=name,
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.invalid/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience=audience,
|
||||
)
|
||||
|
||||
|
||||
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
|
||||
tenant = cfg["ENTRA_TENANT_ID"]
|
||||
issuer = f"https://login.microsoftonline.com/{tenant}/v2.0"
|
||||
token_endpoint = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
aud_a = cfg["SPIKE_AUDIENCE_A"]
|
||||
aud_b = cfg["SPIKE_AUDIENCE_B"]
|
||||
aud_c = cfg.get("SPIKE_AUDIENCE_UNCONSENTED", "")
|
||||
|
||||
# Real Turnstone objects.
|
||||
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-e2e-"), "e2e.db")
|
||||
storage = SQLiteBackend(db_path)
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||||
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
|
||||
oidc_config = OIDCConfig(
|
||||
enabled=True,
|
||||
issuer=issuer,
|
||||
client_id=cfg["ENTRA_CLIENT_ID"],
|
||||
client_secret=cfg["ENTRA_CLIENT_SECRET"],
|
||||
token_endpoint=token_endpoint,
|
||||
obo_grant_profile="entra",
|
||||
capture_user_credential=True,
|
||||
)
|
||||
|
||||
# Step 2 — CAPTURE: the exact storage call handle_oidc_callback makes.
|
||||
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
|
||||
cap = store.get_oidc_credential(USER, issuer)
|
||||
if cap and cap["refresh_token"] == refresh_token:
|
||||
record("VERIFIED", f"capture: credential persisted for {USER} ({redact(refresh_token)})")
|
||||
else:
|
||||
record("FAILED", "capture: credential did not round-trip")
|
||||
return
|
||||
|
||||
_seed_obo_server(storage, "e2e-a", aud_a)
|
||||
_seed_obo_server(storage, "e2e-b", aud_b)
|
||||
if aud_c:
|
||||
_seed_obo_server(storage, "e2e-c", aud_c)
|
||||
|
||||
inner = httpx.AsyncClient(timeout=20.0)
|
||||
client = _CountingClient(inner)
|
||||
app_state = _make_app_state(storage, store, oidc_config, client)
|
||||
try:
|
||||
# E1 — real mint for audience A.
|
||||
r = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
if r.kind == "token" and r.token:
|
||||
ok, aud = aud_matches(r.token, aud_a)
|
||||
row = storage.get_mcp_user_token(USER, "e2e-a")
|
||||
cache_ok = (
|
||||
row is not None and row["refresh_token_ct"] is None and bool(row["expires_at"])
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if ok and cache_ok else "FAILED",
|
||||
f"E1 mint A: kind=token aud={aud} want={aud_a} cache_row_refreshless={cache_ok}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
|
||||
return
|
||||
|
||||
# E2 — cache hit issues zero Entra calls.
|
||||
posts_before = client.posts
|
||||
r2 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
|
||||
f"E2 cache hit: kind={r2.kind} extra_entra_calls={client.posts - posts_before} (want 0)",
|
||||
)
|
||||
|
||||
# E3 — same credential, audience B.
|
||||
rb = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-b"
|
||||
)
|
||||
if rb.kind == "token" and rb.token:
|
||||
ok_b, aud_bclaim = aud_matches(rb.token, aud_b)
|
||||
record(
|
||||
"VERIFIED" if ok_b else "FAILED",
|
||||
f"E3 mint B from SAME credential: aud={aud_bclaim} want={aud_b}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E3 mint B: kind={rb.kind}")
|
||||
|
||||
# E4 — rotation write-back: the stored credential is still redeemable
|
||||
# (holds the newest RT — Entra rotates on redemption).
|
||||
cred_now = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if cred_now is not None else "FAILED",
|
||||
f"E4 rotation write-back: credential persisted {redact(cred_now['refresh_token']) if cred_now else '<gone>'}",
|
||||
)
|
||||
|
||||
# E5 — force_refresh re-mints (a real Entra call).
|
||||
posts_before = client.posts
|
||||
rf = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a", force_refresh=True
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E5 force_refresh re-mint: kind={rf.kind} entra_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
|
||||
# E6 — unconsented audience: not a token, and the credential SURVIVES.
|
||||
if aud_c:
|
||||
rc = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-c"
|
||||
)
|
||||
cred_after = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
|
||||
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
|
||||
)
|
||||
else:
|
||||
record("SKIPPED", "E6 unconsented C: SPIKE_AUDIENCE_UNCONSENTED not set")
|
||||
|
||||
# E7 — cache flush → re-mint.
|
||||
store.delete_user_token(USER, "e2e-a")
|
||||
posts_before = client.posts
|
||||
r7 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="e2e-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E7 flush→re-mint: kind={r7.kind} entra_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
finally:
|
||||
await inner.aclose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"ENTRA_TENANT_ID",
|
||||
"ENTRA_CLIENT_ID",
|
||||
"ENTRA_CLIENT_SECRET",
|
||||
"SPIKE_AUDIENCE_A",
|
||||
"SPIKE_AUDIENCE_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("ENTRA_", "SPIKE_"))}
|
||||
missing = [k for k in required if not cfg.get(k)]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)} — did you `source scripts/obo-e2e/.env`?")
|
||||
return 2
|
||||
|
||||
print("Signing in to Entra (this is the login the feature captures)...")
|
||||
tokens = interactive_login(cfg)
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
print(f"No refresh_token from login (keys={sorted(tokens)}) — offline_access missing?")
|
||||
return 1
|
||||
|
||||
asyncio.run(_run(cfg, refresh_token))
|
||||
|
||||
print("\n=== summary ===")
|
||||
for status, msg in RESULTS:
|
||||
print(f" {status:>8} {msg}")
|
||||
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entra spike setup for entra_spike.py (#551 re-scope boundary spike).
|
||||
# Manual test tooling — not run in CI. Creates throwaway Entra app registrations.
|
||||
#
|
||||
# ./entra_setup.sh setup create app registrations + consent + .env
|
||||
# ./entra_setup.sh cleanup delete everything it created (incl. .env)
|
||||
#
|
||||
# Creates in the logged-in tenant (az login first):
|
||||
# spike-turnstone confidential client (stands in for Turnstone's OIDC app)
|
||||
# spike-mcp-a/b resource apps exposing scope mcp.access, admin-consented
|
||||
# spike-mcp-c resource app with NO grant to the client (V5 control)
|
||||
# Requires: the logged-in user can create apps + grant admin consent
|
||||
# (Global Admin on a personal tenant qualifies).
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ENV_FILE=".env"
|
||||
NAMES=(spike-turnstone spike-mcp-a spike-mcp-b spike-mcp-c)
|
||||
|
||||
log() { printf '>> %s\n' "$*"; }
|
||||
|
||||
graph_patch_api() { # $1=appId $2=scope-uuid $3=display-name
|
||||
local obj_id
|
||||
obj_id=$(az ad app show --id "$1" --query id -o tsv)
|
||||
az rest --method PATCH \
|
||||
--url "https://graph.microsoft.com/v1.0/applications/${obj_id}" \
|
||||
--headers 'Content-Type=application/json' \
|
||||
--body "{
|
||||
\"identifierUris\": [\"api://$1\"],
|
||||
\"api\": {
|
||||
\"requestedAccessTokenVersion\": 2,
|
||||
\"oauth2PermissionScopes\": [{
|
||||
\"id\": \"$2\",
|
||||
\"value\": \"mcp.access\",
|
||||
\"type\": \"Admin\",
|
||||
\"isEnabled\": true,
|
||||
\"adminConsentDisplayName\": \"Access $3\",
|
||||
\"adminConsentDescription\": \"Spike scope for $3\"
|
||||
}]
|
||||
}
|
||||
}"
|
||||
}
|
||||
|
||||
make_resource_app() { # $1=display-name ; echoes "appId scopeId"
|
||||
local app_id scope_id
|
||||
app_id=$(az ad app create --display-name "$1" \
|
||||
--sign-in-audience AzureADMyOrg --query appId -o tsv)
|
||||
scope_id=$(python3 -c 'import uuid; print(uuid.uuid4())')
|
||||
graph_patch_api "$app_id" "$scope_id" "$1" >/dev/null
|
||||
az ad sp create --id "$app_id" >/dev/null 2>&1 || true
|
||||
echo "$app_id $scope_id"
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
local tenant_id
|
||||
tenant_id=$(az account show --query tenantId -o tsv)
|
||||
log "tenant: ${tenant_id}"
|
||||
|
||||
log "creating resource apps (a, b, c)..."
|
||||
read -r APP_A SCOPE_A <<<"$(make_resource_app spike-mcp-a)"
|
||||
read -r APP_B SCOPE_B <<<"$(make_resource_app spike-mcp-b)"
|
||||
read -r APP_C _ <<<"$(make_resource_app spike-mcp-c)"
|
||||
log " a=${APP_A} b=${APP_B} c=${APP_C} (c stays unconsented)"
|
||||
|
||||
log "creating confidential client spike-turnstone..."
|
||||
CLIENT_ID=$(az ad app create --display-name spike-turnstone \
|
||||
--sign-in-audience AzureADMyOrg \
|
||||
--web-redirect-uris "http://localhost:8765/callback" \
|
||||
--query appId -o tsv)
|
||||
az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true
|
||||
# No stderr suppression here: the secret is load-bearing (it lands in .env),
|
||||
# so under `set -e` a reset failure must abort LOUDLY, not silently.
|
||||
SECRET=$(az ad app credential reset --id "$CLIENT_ID" \
|
||||
--display-name spike --years 1 --query password -o tsv)
|
||||
|
||||
log "adding delegated permissions (a, b — NOT c)..."
|
||||
# Tolerated failures (|| log): a re-run hits "permission already exists" and
|
||||
# SP-propagation delays are common right after app creation — the
|
||||
# admin-consent retry loop below is the real gate. `set -e` would otherwise
|
||||
# turn a suppressed non-zero here into a silent mid-script abort.
|
||||
az ad app permission add --id "$CLIENT_ID" \
|
||||
--api "$APP_A" --api-permissions "${SCOPE_A}=Scope" \
|
||||
|| log " warn: permission add for a failed (may already exist); admin-consent below will confirm"
|
||||
az ad app permission add --id "$CLIENT_ID" \
|
||||
--api "$APP_B" --api-permissions "${SCOPE_B}=Scope" \
|
||||
|| log " warn: permission add for b failed (may already exist); admin-consent below will confirm"
|
||||
|
||||
log "granting admin consent (retries while SPs propagate)..."
|
||||
local ok=""
|
||||
for i in 1 2 3 4 5; do
|
||||
if az ad app permission admin-consent --id "$CLIENT_ID" 2>/dev/null; then
|
||||
ok=1; break
|
||||
fi
|
||||
log " not yet (attempt $i) — waiting 15s"
|
||||
sleep 15
|
||||
done
|
||||
[ -n "$ok" ] || { log "admin-consent failed after retries — grant manually in the portal (API permissions blade) and re-run the spike"; }
|
||||
|
||||
# Single-quote the values in the generated .env: the AS-issued client secret
|
||||
# can contain $ / backtick, and an unquoted RHS would be re-expanded (or
|
||||
# partially executed) when the operator `source`s the file. The heredoc still
|
||||
# interpolates ${...} into the single-quoted output; sourcing then treats the
|
||||
# result literally. (Azure secrets are base64-ish — no single quotes to escape.)
|
||||
umask 177
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
export ENTRA_TENANT_ID='${tenant_id}'
|
||||
export ENTRA_CLIENT_ID='${CLIENT_ID}'
|
||||
export ENTRA_CLIENT_SECRET='${SECRET}'
|
||||
export SPIKE_AUDIENCE_A='api://${APP_A}'
|
||||
export SPIKE_AUDIENCE_B='api://${APP_B}'
|
||||
export SPIKE_AUDIENCE_UNCONSENTED='api://${APP_C}'
|
||||
export SPIKE_RUN_OBO=1
|
||||
EOF
|
||||
log "wrote ${ENV_FILE} (chmod 600). Next:"
|
||||
log " source scripts/obo-e2e/.env && uv run python scripts/obo-e2e/entra_spike.py"
|
||||
log "cleanup later with: ./entra_setup.sh cleanup"
|
||||
}
|
||||
|
||||
cmd_cleanup() {
|
||||
for name in "${NAMES[@]}"; do
|
||||
for app_id in $(az ad app list --display-name "$name" --query '[].appId' -o tsv); do
|
||||
log "deleting ${name} (${app_id})"
|
||||
az ad app delete --id "$app_id"
|
||||
done
|
||||
done
|
||||
rm -f "$ENV_FILE"
|
||||
log "cleanup done (app registrations + .env removed)"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
setup) cmd_setup ;;
|
||||
cleanup) cmd_cleanup ;;
|
||||
*) echo "usage: $0 setup|cleanup"; exit 2 ;;
|
||||
esac
|
||||
@@ -1,333 +0,0 @@
|
||||
"""Entra boundary spike for single-credential MCP token minting (#551 re-scope).
|
||||
|
||||
Verifies, against a REAL Entra tenant, the assumptions behind the oauth_obo
|
||||
design (one IdP refresh token per user; per-MCP access tokens minted on
|
||||
demand). Each check prints VERIFIED / FAILED / SKIPPED plus redacted evidence.
|
||||
|
||||
V1 interactive confidential-client login (auth-code + PKCE + offline_access)
|
||||
-> refresh token captured [capture layer works]
|
||||
V2 RT redeemed with scope=<AUDIENCE_A>/.default -> aud claim == A
|
||||
V3 SAME credential redeemed for <AUDIENCE_B> -> aud claim == B
|
||||
KEY CHECK: Entra RTs are client-bound, not resource-bound.
|
||||
V4 rotation semantics: does each redemption return a new RT, and does the
|
||||
PREVIOUS RT keep working? [write-back design]
|
||||
V5 redemption for an unconsented audience -> AADSTS65001 consent_required
|
||||
[maps to the reconnect-rail fallback]
|
||||
V6 optional: OBO jwt-bearer leg (requested_token_use=on_behalf_of) using a
|
||||
Turnstone-audience access token as assertion [middle-tier variant]
|
||||
|
||||
Run: uv run python scripts/obo-e2e/entra_spike.py
|
||||
Env: ENTRA_TENANT_ID tenant GUID or domain
|
||||
ENTRA_CLIENT_ID Turnstone spike app registration (confidential)
|
||||
ENTRA_CLIENT_SECRET client secret for the above
|
||||
SPIKE_AUDIENCE_A e.g. api://<guid-a> (exposes a scope, consented)
|
||||
SPIKE_AUDIENCE_B e.g. api://<guid-b> (exposes a scope, consented)
|
||||
SPIKE_AUDIENCE_UNCONSENTED optional, for V5
|
||||
SPIKE_RUN_OBO optional "1" to run V6
|
||||
SPIKE_PORT redirect listener port (default 8765; register
|
||||
http://localhost:<port>/callback as a Web
|
||||
redirect URI on the spike app registration)
|
||||
|
||||
App-registration setup checklist: see README.md next to this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
RESULTS: list[tuple[str, str, str]] = [] # (check, status, evidence)
|
||||
|
||||
|
||||
def record(check: str, status: str, evidence: str) -> None:
|
||||
RESULTS.append((check, status, evidence))
|
||||
print(f"[{status:>8}] {check}: {evidence}")
|
||||
|
||||
|
||||
def b64url_json(segment: str) -> dict[str, Any]:
|
||||
pad = "=" * (-len(segment) % 4)
|
||||
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(segment + pad))
|
||||
return out
|
||||
|
||||
|
||||
def jwt_claims_unverified(token: str) -> dict[str, Any]:
|
||||
"""Spike-only unverified decode. NEVER do this in product code."""
|
||||
try:
|
||||
return b64url_json(token.split(".")[1])
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def redact(token: str | None) -> str:
|
||||
if not token:
|
||||
return "<absent>"
|
||||
return f"{token[:8]}...({len(token)} chars)"
|
||||
|
||||
|
||||
class _CodeCatcher(BaseHTTPRequestHandler):
|
||||
code: str | None = None
|
||||
state: str | None = None
|
||||
event = threading.Event()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib API name
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
_CodeCatcher.code = (q.get("code") or [None])[0]
|
||||
_CodeCatcher.state = (q.get("state") or [None])[0]
|
||||
body = b"Spike login captured - return to the terminal."
|
||||
if q.get("error"):
|
||||
body = f"IdP error: {q}".encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
_CodeCatcher.event.set()
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def interactive_login(cfg: dict[str, str]) -> dict[str, Any]:
|
||||
"""V1: authorization-code + PKCE + offline_access as a confidential client.
|
||||
|
||||
Mirrors production shape: same grant Turnstone's OIDC login uses
|
||||
(core/oidc.py exchange_code), plus offline_access.
|
||||
"""
|
||||
port = int(cfg.get("SPIKE_PORT", "8765"))
|
||||
redirect_uri = f"http://localhost:{port}/callback"
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
)
|
||||
state = secrets.token_urlsafe(16)
|
||||
authorize = (
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/authorize?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_mode": "query",
|
||||
# offline_access is THE capture-layer delta vs today's login.
|
||||
# No resource scope here: the RT is minted client-bound.
|
||||
"scope": "openid profile offline_access",
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
)
|
||||
server = HTTPServer(("127.0.0.1", port), _CodeCatcher)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
print(f"\nOpen (or auto-opened) in a browser with a tenant user:\n {authorize}\n")
|
||||
cb_file = cfg.get("SPIKE_CALLBACK_FILE", "")
|
||||
if cb_file:
|
||||
print(
|
||||
"Remote-browser mode: after sign-in the browser lands on a broken\n"
|
||||
f"http://localhost:{port}/callback?... page. Copy that FULL URL and run:\n"
|
||||
f" echo '<url>' > {cb_file}\n"
|
||||
)
|
||||
|
||||
def _watch_callback_file() -> None:
|
||||
# Driver-friendly fallback: the sign-in can happen on any device;
|
||||
# whoever signed in drops the redirected URL into SPIKE_CALLBACK_FILE.
|
||||
import time as _time
|
||||
|
||||
while not _CodeCatcher.event.is_set():
|
||||
try:
|
||||
with open(cb_file) as _f:
|
||||
pasted = _f.read().strip()
|
||||
except OSError:
|
||||
pasted = ""
|
||||
if "?" in pasted:
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(pasted).query)
|
||||
_CodeCatcher.code = (q.get("code") or [None])[0]
|
||||
_CodeCatcher.state = (q.get("state") or [None])[0]
|
||||
_CodeCatcher.event.set()
|
||||
return
|
||||
_time.sleep(1.0)
|
||||
|
||||
if cb_file:
|
||||
threading.Thread(target=_watch_callback_file, daemon=True).start()
|
||||
webbrowser.open(authorize)
|
||||
if not _CodeCatcher.event.wait(timeout=600):
|
||||
server.shutdown()
|
||||
raise SystemExit("Timed out waiting for the redirect (10 min).")
|
||||
server.shutdown()
|
||||
if _CodeCatcher.state != state:
|
||||
raise SystemExit("state mismatch on redirect - aborting.")
|
||||
if not _CodeCatcher.code:
|
||||
raise SystemExit("No code on redirect (IdP error page shown in browser).")
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": _CodeCatcher.code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
tokens: dict[str, Any] = resp.json()
|
||||
if resp.status_code != 200:
|
||||
raise SystemExit(f"code exchange failed: {json.dumps(tokens, indent=2)[:800]}")
|
||||
return tokens
|
||||
|
||||
|
||||
def redeem(cfg: dict[str, str], refresh_token: str, scope: str) -> tuple[int, dict[str, Any]]:
|
||||
"""Redeem a refresh token for an access token with the given scope."""
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"scope": scope,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
body: dict[str, Any] = resp.json()
|
||||
return resp.status_code, body
|
||||
|
||||
|
||||
def obo_exchange(cfg: dict[str, str], assertion: str, scope: str) -> tuple[int, dict[str, Any]]:
|
||||
"""V6: middle-tier OBO variant (jwt-bearer + requested_token_use)."""
|
||||
resp = httpx.post(
|
||||
f"https://login.microsoftonline.com/{cfg['ENTRA_TENANT_ID']}/oauth2/v2.0/token",
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": assertion,
|
||||
"client_id": cfg["ENTRA_CLIENT_ID"],
|
||||
"client_secret": cfg["ENTRA_CLIENT_SECRET"],
|
||||
"scope": scope,
|
||||
"requested_token_use": "on_behalf_of",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
body: dict[str, Any] = resp.json()
|
||||
return resp.status_code, body
|
||||
|
||||
|
||||
def check_aud(label: str, status: int, body: dict[str, Any], want_aud: str) -> str | None:
|
||||
"""Common V2/V3 assertion: 200 + aud matches. Returns the new RT if any."""
|
||||
if status != 200:
|
||||
record(label, "FAILED", f"HTTP {status}: {json.dumps(body)[:300]}")
|
||||
return None
|
||||
claims = jwt_claims_unverified(body.get("access_token", ""))
|
||||
aud = str(claims.get("aud", "<none>"))
|
||||
ok = aud == want_aud or aud == want_aud.removeprefix("api://")
|
||||
record(
|
||||
label,
|
||||
"VERIFIED" if ok else "FAILED",
|
||||
f"aud={aud} want={want_aud} expires_in={body.get('expires_in')} "
|
||||
f"new_rt={redact(body.get('refresh_token'))}",
|
||||
)
|
||||
new_rt = body.get("refresh_token")
|
||||
return str(new_rt) if isinstance(new_rt, str) else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"ENTRA_TENANT_ID",
|
||||
"ENTRA_CLIENT_ID",
|
||||
"ENTRA_CLIENT_SECRET",
|
||||
"SPIKE_AUDIENCE_A",
|
||||
"SPIKE_AUDIENCE_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in required if k in os.environ}
|
||||
missing = [k for k in required if k not in cfg]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)}\nSee module docstring.")
|
||||
return 2
|
||||
for opt in ("SPIKE_AUDIENCE_UNCONSENTED", "SPIKE_PORT", "SPIKE_RUN_OBO"):
|
||||
if opt in os.environ:
|
||||
cfg[opt] = os.environ[opt]
|
||||
|
||||
# V1 - capture
|
||||
tokens = interactive_login(cfg)
|
||||
rt0 = tokens.get("refresh_token")
|
||||
if isinstance(rt0, str) and rt0:
|
||||
record("V1 capture (offline_access -> RT)", "VERIFIED", redact(rt0))
|
||||
else:
|
||||
record("V1 capture (offline_access -> RT)", "FAILED", f"keys={sorted(tokens.keys())}")
|
||||
return 1
|
||||
|
||||
# V2 - mint for audience A
|
||||
a = cfg["SPIKE_AUDIENCE_A"]
|
||||
s2, b2 = redeem(cfg, rt0, f"{a}/.default")
|
||||
rt_after_a = check_aud("V2 mint audience A from RT", s2, b2, a)
|
||||
|
||||
# V3 - SAME credential, audience B (the design-critical check)
|
||||
b = cfg["SPIKE_AUDIENCE_B"]
|
||||
s3, b3 = redeem(cfg, rt0, f"{b}/.default")
|
||||
check_aud("V3 mint audience B from SAME RT", s3, b3, b)
|
||||
|
||||
# V4 - rotation semantics
|
||||
if rt_after_a and rt_after_a != rt0:
|
||||
s4, _ = redeem(cfg, rt0, f"{a}/.default")
|
||||
record(
|
||||
"V4 rotation (new RT returned; old still valid?)",
|
||||
"VERIFIED" if s4 == 200 else "VERIFIED",
|
||||
f"rotated=yes old_rt_reuse_http={s4} "
|
||||
"(design: persist newest RT on every mint; "
|
||||
f"{'old stays valid - benign race window' if s4 == 200 else 'old INVALIDATED - write-back is correctness-critical'})",
|
||||
)
|
||||
else:
|
||||
record(
|
||||
"V4 rotation",
|
||||
"VERIFIED",
|
||||
"no rotation observed on redemption (same/absent RT) - "
|
||||
"write-back still required for the rotating case",
|
||||
)
|
||||
|
||||
# V5 - unconsented audience -> consent_required
|
||||
unc = cfg.get("SPIKE_AUDIENCE_UNCONSENTED")
|
||||
if unc:
|
||||
s5, b5 = redeem(cfg, rt0, f"{unc}/.default")
|
||||
codes = b5.get("error_codes", [])
|
||||
hit = s5 == 400 and (65001 in codes or b5.get("suberror") == "consent_required")
|
||||
record(
|
||||
"V5 unconsented audience -> AADSTS65001",
|
||||
"VERIFIED" if hit else "FAILED",
|
||||
f"http={s5} error={b5.get('error')} codes={codes}",
|
||||
)
|
||||
else:
|
||||
record("V5 unconsented audience", "SKIPPED", "SPIKE_AUDIENCE_UNCONSENTED not set")
|
||||
|
||||
# V6 - optional OBO middle-tier variant
|
||||
if cfg.get("SPIKE_RUN_OBO") == "1":
|
||||
s6a, b6a = redeem(cfg, rt0, f"{cfg['ENTRA_CLIENT_ID']}/.default")
|
||||
at_self = b6a.get("access_token", "") if s6a == 200 else ""
|
||||
if at_self:
|
||||
s6, b6 = obo_exchange(cfg, at_self, f"{a}/.default")
|
||||
check_aud("V6 OBO jwt-bearer variant", s6, b6, a)
|
||||
else:
|
||||
record(
|
||||
"V6 OBO jwt-bearer variant",
|
||||
"FAILED",
|
||||
f"could not mint self-audience assertion: HTTP {s6a}",
|
||||
)
|
||||
else:
|
||||
record("V6 OBO jwt-bearer variant", "SKIPPED", "SPIKE_RUN_OBO != 1")
|
||||
|
||||
print("\n=== summary ===")
|
||||
for check, status, _ in RESULTS:
|
||||
print(f" {status:>8} {check}")
|
||||
return 0 if all(s != "FAILED" for _, s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,271 +0,0 @@
|
||||
"""End-to-end exercise of the oauth_obo feature on the OSS path (RFC 8693).
|
||||
|
||||
Parallel to ``entra_e2e.py`` but for ``obo_grant_profile="rfc8693"`` against an
|
||||
ephemeral Keycloak — the open-source / non-Entra deployment shape. Fully
|
||||
headless (password grant, no browser), so it runs unattended.
|
||||
|
||||
Drives the REAL Turnstone code: ``MCPTokenStore.upsert_oidc_credential`` (capture)
|
||||
then ``get_obo_access_token_classified`` → ``_obo_mint_rfc8693`` (refresh grant →
|
||||
RFC 8693 token exchange) against the live Keycloak token endpoint.
|
||||
|
||||
Checks E1–E7 mirror the Entra harness:
|
||||
E1 mint audience A → token, aud claim carries A, cache row refresh_token_ct NULL
|
||||
E2 second call → cache hit, ZERO extra Keycloak calls
|
||||
E3 audience B from the SAME captured credential → aud carries B
|
||||
E4 rotation write-back (KC rotates the RT on the refresh leg)
|
||||
E5 force_refresh → re-mint (Keycloak call count increments)
|
||||
E6 unconsented audience C → NOT token, credential SURVIVES
|
||||
E7 cache flush → re-mint
|
||||
|
||||
Env (set by keycloak_e2e.sh):
|
||||
KC_TOKEN_ENDPOINT, KC_ISSUER, KC_CLIENT_ID, KC_CLIENT_SECRET,
|
||||
KC_USER, KC_PASSWORD, AUD_A, SCOPE_A, AUD_B, SCOPE_B, AUD_C
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from turnstone.core.mcp_crypto import (
|
||||
MCPTokenCipher,
|
||||
MCPTokenCipherConfig,
|
||||
MCPTokenStore,
|
||||
)
|
||||
from turnstone.core.mcp_oauth import get_obo_access_token_classified
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
USER = "e2e-user"
|
||||
RESULTS: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def record(status: str, msg: str) -> None:
|
||||
RESULTS.append((status, msg))
|
||||
print(f"[{status:>8}] {msg}")
|
||||
|
||||
|
||||
def redact(token: str | None) -> str:
|
||||
return f"{token[:8]}...({len(token)} chars)" if token else "<absent>"
|
||||
|
||||
|
||||
def jwt_claims(token: str) -> dict[str, Any]:
|
||||
seg = token.split(".")[1]
|
||||
pad = "=" * (-len(seg) % 4)
|
||||
out: dict[str, Any] = json.loads(base64.urlsafe_b64decode(seg + pad))
|
||||
return out
|
||||
|
||||
|
||||
def aud_carries(token: str, want: str) -> tuple[bool, str]:
|
||||
"""KC puts the exchanged audience in the aud claim (str or list)."""
|
||||
aud = jwt_claims(token).get("aud", [])
|
||||
auds = aud if isinstance(aud, list) else [aud]
|
||||
return want in auds, str(aud)
|
||||
|
||||
|
||||
class _CountingClient:
|
||||
def __init__(self, inner: httpx.AsyncClient) -> None:
|
||||
self._inner = inner
|
||||
self.posts = 0
|
||||
|
||||
async def post(self, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||
self.posts += 1
|
||||
return await self._inner.post(*args, **kwargs)
|
||||
|
||||
|
||||
def _password_login(cfg: dict[str, str]) -> str:
|
||||
"""Headless direct-access grant → a real refresh token for the user."""
|
||||
resp = httpx.post(
|
||||
cfg["KC_TOKEN_ENDPOINT"],
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": cfg["KC_CLIENT_ID"],
|
||||
"client_secret": cfg["KC_CLIENT_SECRET"],
|
||||
"username": cfg["KC_USER"],
|
||||
"password": cfg["KC_PASSWORD"],
|
||||
"scope": "openid",
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return str(resp.json()["refresh_token"])
|
||||
|
||||
|
||||
def _seed(storage: SQLiteBackend, name: str, audience: str, scopes: str | None) -> None:
|
||||
storage.create_mcp_server(
|
||||
server_id=f"{name}-id",
|
||||
name=name,
|
||||
transport="streamable-http",
|
||||
url="https://mcp.example.invalid/sse",
|
||||
auth_type="oauth_obo",
|
||||
oauth_audience=audience,
|
||||
oauth_scopes=scopes,
|
||||
)
|
||||
|
||||
|
||||
async def _run(cfg: dict[str, str], refresh_token: str) -> None:
|
||||
issuer = cfg["KC_ISSUER"]
|
||||
db_path = os.path.join(tempfile.mkdtemp(prefix="obo-kc-e2e-"), "e2e.db")
|
||||
storage = SQLiteBackend(db_path)
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
||||
store = MCPTokenStore(storage, MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,))), node_id="e2e")
|
||||
oidc_config = OIDCConfig(
|
||||
enabled=True,
|
||||
issuer=issuer,
|
||||
client_id=cfg["KC_CLIENT_ID"],
|
||||
client_secret=cfg["KC_CLIENT_SECRET"],
|
||||
token_endpoint=cfg["KC_TOKEN_ENDPOINT"],
|
||||
obo_grant_profile="rfc8693",
|
||||
capture_user_credential=True,
|
||||
)
|
||||
|
||||
store.upsert_oidc_credential(USER, issuer, refresh_token=refresh_token)
|
||||
cap = store.get_oidc_credential(USER, issuer)
|
||||
if cap and cap["refresh_token"] == refresh_token:
|
||||
record("VERIFIED", f"capture: credential persisted ({redact(refresh_token)})")
|
||||
else:
|
||||
record("FAILED", "capture: credential did not round-trip")
|
||||
return
|
||||
|
||||
_seed(storage, "kc-a", cfg["AUD_A"], cfg.get("SCOPE_A"))
|
||||
_seed(storage, "kc-b", cfg["AUD_B"], cfg.get("SCOPE_B"))
|
||||
if cfg.get("AUD_C"):
|
||||
_seed(storage, "kc-c", cfg["AUD_C"], None) # no audience scope → unconsented
|
||||
|
||||
inner = httpx.AsyncClient(timeout=20.0)
|
||||
client = _CountingClient(inner)
|
||||
app_state = SimpleNamespace(
|
||||
auth_storage=storage,
|
||||
mcp_token_store=store,
|
||||
oidc_config=oidc_config,
|
||||
obo_http_client=client,
|
||||
mcp_oauth_refresh_locks={},
|
||||
mcp_oauth_refresh_backoff={},
|
||||
)
|
||||
try:
|
||||
# E1 — rfc8693 mint (refresh grant → token exchange) for audience A.
|
||||
r = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
if r.kind == "token" and r.token:
|
||||
ok, aud = aud_carries(r.token, cfg["AUD_A"])
|
||||
row = storage.get_mcp_user_token(USER, "kc-a")
|
||||
cache_ok = row is not None and row["refresh_token_ct"] is None
|
||||
record(
|
||||
"VERIFIED" if ok and cache_ok else "FAILED",
|
||||
f"E1 mint A (refresh→exchange): kind=token aud={aud} want={cfg['AUD_A']} "
|
||||
f"cache_row_refreshless={cache_ok}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E1 mint A: kind={r.kind} (expected token)")
|
||||
return
|
||||
|
||||
# E2 — cache hit.
|
||||
posts_before = client.posts
|
||||
r2 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r2.kind == "token" and client.posts == posts_before else "FAILED",
|
||||
f"E2 cache hit: kind={r2.kind} extra_kc_calls={client.posts - posts_before} (want 0)",
|
||||
)
|
||||
|
||||
# E3 — audience B from the SAME credential.
|
||||
rb = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-b"
|
||||
)
|
||||
if rb.kind == "token" and rb.token:
|
||||
ok_b, aud_b = aud_carries(rb.token, cfg["AUD_B"])
|
||||
record(
|
||||
"VERIFIED" if ok_b else "FAILED",
|
||||
f"E3 mint B from SAME credential: aud={aud_b} want={cfg['AUD_B']}",
|
||||
)
|
||||
else:
|
||||
record("FAILED", f"E3 mint B: kind={rb.kind}")
|
||||
|
||||
# E4 — rotation write-back (KC rotates the RT on the refresh leg).
|
||||
cred_now = store.get_oidc_credential(USER, issuer)
|
||||
rotated = cred_now is not None and cred_now["refresh_token"] != refresh_token
|
||||
record(
|
||||
"VERIFIED" if cred_now is not None else "FAILED",
|
||||
f"E4 rotation write-back: persisted={redact(cred_now['refresh_token']) if cred_now else '<gone>'} "
|
||||
f"rotated_from_initial={rotated}",
|
||||
)
|
||||
|
||||
# E5 — force_refresh re-mints.
|
||||
posts_before = client.posts
|
||||
rf = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a", force_refresh=True
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if rf.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E5 force_refresh re-mint: kind={rf.kind} kc_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
|
||||
# E6 — unconsented audience: not a token, credential survives.
|
||||
if cfg.get("AUD_C"):
|
||||
rc = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-c"
|
||||
)
|
||||
cred_after = store.get_oidc_credential(USER, issuer)
|
||||
record(
|
||||
"VERIFIED" if rc.kind != "token" and cred_after is not None else "FAILED",
|
||||
f"E6 unconsented C: kind={rc.kind} (not token) credential_survives={cred_after is not None}",
|
||||
)
|
||||
else:
|
||||
record("SKIPPED", "E6 unconsented C: AUD_C not set")
|
||||
|
||||
# E7 — cache flush → re-mint.
|
||||
store.delete_user_token(USER, "kc-a")
|
||||
posts_before = client.posts
|
||||
r7 = await get_obo_access_token_classified(
|
||||
app_state=app_state, user_id=USER, server_name="kc-a"
|
||||
)
|
||||
record(
|
||||
"VERIFIED" if r7.kind == "token" and client.posts > posts_before else "FAILED",
|
||||
f"E7 flush→re-mint: kind={r7.kind} kc_calls={client.posts - posts_before} (want >=1)",
|
||||
)
|
||||
finally:
|
||||
await inner.aclose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
required = [
|
||||
"KC_TOKEN_ENDPOINT",
|
||||
"KC_ISSUER",
|
||||
"KC_CLIENT_ID",
|
||||
"KC_CLIENT_SECRET",
|
||||
"KC_USER",
|
||||
"KC_PASSWORD",
|
||||
"AUD_A",
|
||||
"AUD_B",
|
||||
]
|
||||
cfg = {k: os.environ[k] for k in os.environ if k.startswith(("KC_", "AUD_", "SCOPE_"))}
|
||||
missing = [k for k in required if not cfg.get(k)]
|
||||
if missing:
|
||||
print(f"Missing env: {', '.join(missing)} — run via keycloak_e2e.sh")
|
||||
return 2
|
||||
|
||||
print("Headless password login to Keycloak (the credential the feature captures)...")
|
||||
refresh_token = _password_login(cfg)
|
||||
|
||||
asyncio.run(_run(cfg, refresh_token))
|
||||
|
||||
print("\n=== summary ===")
|
||||
for status, msg in RESULTS:
|
||||
print(f" {status:>8} {msg}")
|
||||
return 0 if all(s in ("VERIFIED", "SKIPPED") for s, _ in RESULTS) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# OSS-path (RFC 8693) end-to-end: spin up ephemeral Keycloak, configure the
|
||||
# realm, run keycloak_e2e.py against the REAL Turnstone mint engine, tear down.
|
||||
# Fully headless — no browser. Manual test tooling, not run in CI.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.." # repo root (uv run needs it)
|
||||
|
||||
CONTAINER=kc-obo-e2e
|
||||
PORT=8091
|
||||
KC="docker exec $CONTAINER /opt/keycloak/bin/kcadm.sh"
|
||||
|
||||
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
echo ">> starting Keycloak 26.3 (ephemeral)..."
|
||||
docker run -d --name "$CONTAINER" -p "127.0.0.1:${PORT}:8080" \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.3 start-dev >/dev/null
|
||||
|
||||
echo ">> waiting for Keycloak (dev-mode boot can take a few minutes on a loaded host)..."
|
||||
# Wait on kcadm auth succeeding directly — more reliable than the host HTTP port,
|
||||
# and generous enough for a resource-starved boot (up to ~6 min).
|
||||
ready=""
|
||||
for _ in $(seq 1 90); do
|
||||
if $KC config credentials --server http://localhost:8080 --realm master \
|
||||
--user admin --password admin >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
[ -n "$ready" ] || { echo "Keycloak did not become ready in time"; docker logs "$CONTAINER" 2>&1 | tail -15; exit 1; }
|
||||
|
||||
echo ">> configuring realm 'spike'..."
|
||||
$KC create realms -s realm=spike -s enabled=true >/dev/null
|
||||
# Confidential client with standard token exchange (the RFC 8693 leg) + direct
|
||||
# access grant (headless password login to fetch the user's refresh token).
|
||||
$KC create clients -r spike -s clientId=turnstone -s enabled=true -s publicClient=false \
|
||||
-s secret=spike-secret -s directAccessGrantsEnabled=true \
|
||||
-s 'attributes={"standard.token.exchange.enabled":"true"}' >/dev/null
|
||||
for t in mcp-a mcp-b mcp-c; do
|
||||
$KC create clients -r spike -s clientId=$t -s enabled=true -s publicClient=false -s secret=x >/dev/null
|
||||
done
|
||||
$KC create users -r spike -s username=e2e-user -s enabled=true -s email=e2e@spike.test \
|
||||
-s emailVerified=true -s firstName=E2E -s lastName=User >/dev/null
|
||||
$KC set-password -r spike --username e2e-user --new-password e2e-pw >/dev/null
|
||||
|
||||
TURNSTONE_UUID=$($KC get clients -r spike -q clientId=turnstone --fields id --format csv --noquotes)
|
||||
# Audience client scopes for mcp-a and mcp-b ONLY (mcp-c stays unconsented → E6).
|
||||
for t in mcp-a mcp-b; do
|
||||
SID=$($KC create client-scopes -r spike -s name=aud-$t -s protocol=openid-connect -i)
|
||||
$KC create "client-scopes/$SID/protocol-mappers/models" -r spike -s name=aud-$t \
|
||||
-s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \
|
||||
-s "config={\"included.client.audience\":\"$t\",\"access.token.claim\":\"true\"}" >/dev/null
|
||||
$KC update "clients/$TURNSTONE_UUID/optional-client-scopes/$SID" -r spike >/dev/null
|
||||
done
|
||||
|
||||
echo ">> running the product e2e harness..."
|
||||
export KC_TOKEN_ENDPOINT="http://127.0.0.1:${PORT}/realms/spike/protocol/openid-connect/token"
|
||||
export KC_ISSUER="http://127.0.0.1:${PORT}/realms/spike"
|
||||
export KC_CLIENT_ID=turnstone KC_CLIENT_SECRET=spike-secret
|
||||
export KC_USER=e2e-user KC_PASSWORD=e2e-pw
|
||||
export AUD_A=mcp-a SCOPE_A=aud-mcp-a AUD_B=mcp-b SCOPE_B=aud-mcp-b AUD_C=mcp-c
|
||||
uv run python scripts/obo-e2e/keycloak_e2e.py
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "1.8.0a4",
|
||||
"version": "1.7.0rc1",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -7791,12 +7791,6 @@
|
||||
"title": "Persona",
|
||||
"type": "string"
|
||||
},
|
||||
"project_id": {
|
||||
"default": "",
|
||||
"description": "Project to attach the workstream to (validated against membership, empty = none)",
|
||||
"title": "Project Id",
|
||||
"type": "string"
|
||||
},
|
||||
"resume_ws": {
|
||||
"default": "",
|
||||
"description": "Workstream ID to resume (loads previous conversation)",
|
||||
@@ -8354,7 +8348,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "One of: pending / in_progress / done / blocked / needs_user. ``blocked`` is waiting on a dependency the coordinator may clear itself; ``needs_user`` is waiting on a decision only the operator can make.",
|
||||
"description": "One of: pending / in_progress / done / blocked.",
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -8363,12 +8357,6 @@
|
||||
"title": "Child Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"default": "",
|
||||
"description": "Optional one-sentence note, typically what the coordinator needs from the operator on a ``needs_user`` task. Absent from the stored record when unset (there is no backfill for rows written before the field existed), so it defaults to the empty string here.",
|
||||
"title": "Note",
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
@@ -8508,18 +8496,6 @@
|
||||
"title": "Skill",
|
||||
"type": "string"
|
||||
},
|
||||
"persona": {
|
||||
"default": "",
|
||||
"description": "Persona slug (empty = kind default)",
|
||||
"title": "Persona",
|
||||
"type": "string"
|
||||
},
|
||||
"project_id": {
|
||||
"default": "",
|
||||
"description": "Project to attach the workstream to",
|
||||
"title": "Project Id",
|
||||
"type": "string"
|
||||
},
|
||||
"notify_targets": {
|
||||
"description": "Notification targets on completion (channel_type + channel_id/user_id)",
|
||||
"items": {
|
||||
@@ -8683,30 +8659,6 @@
|
||||
"default": null,
|
||||
"title": "Skill"
|
||||
},
|
||||
"persona": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Persona"
|
||||
},
|
||||
"project_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Project Id"
|
||||
},
|
||||
"notify_targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8802,16 +8754,6 @@
|
||||
"title": "Skill",
|
||||
"type": "string"
|
||||
},
|
||||
"persona": {
|
||||
"default": "",
|
||||
"title": "Persona",
|
||||
"type": "string"
|
||||
},
|
||||
"project_id": {
|
||||
"default": "",
|
||||
"title": "Project Id",
|
||||
"type": "string"
|
||||
},
|
||||
"notify_targets": {
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "1.8.0a2",
|
||||
"version": "1.7.0rc1",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -228,16 +228,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -400,26 +390,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Error 503",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2290,23 +2260,16 @@
|
||||
"SendResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"description": "'ok' (fresh turn dispatched), 'queued' (folded into the live turn's interjection queue, or \u2014 when `deferred` is true \u2014 parked for dispatch after the current command window), 'queue_full', 'attachments_busy' (attachments can't ride a queued turn; retry when idle), or 'cross_user_interjection' (another participant's turn is in flight; carried on the 409 body).",
|
||||
"description": "'ok', 'busy', 'queued', or 'queue_full'",
|
||||
"examples": [
|
||||
"ok",
|
||||
"busy",
|
||||
"queued",
|
||||
"queue_full",
|
||||
"attachments_busy",
|
||||
"cross_user_interjection"
|
||||
"queue_full"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"deferred": {
|
||||
"default": false,
|
||||
"description": "Set on `queued` responses: the message is parked on the workstream's deferred-send list (a slash-command window holds the worker slot, or earlier deferred sends are still pending) and dispatches as an ordinary full-fidelity send afterwards \u2014 it is NOT in a live turn's interjection queue. `DELETE .../send` retracts it until dispatch. Node-local and in-memory: a node restart before dispatch drops it (at-most-once intake).",
|
||||
"title": "Deferred",
|
||||
"type": "boolean"
|
||||
},
|
||||
"attached_ids": {
|
||||
"description": "Attachment ids actually attached to this turn. Subset of the request's `attachment_ids` (or the auto-consumed pending set). Empty when the send carries no attachments.",
|
||||
"items": {
|
||||
|
||||
Generated
+143
-504
@@ -9,7 +9,7 @@
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
},
|
||||
@@ -74,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -84,9 +84,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -101,9 +101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -118,9 +118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -135,9 +135,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -152,9 +152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -189,9 +189,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -209,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -229,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -269,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -289,9 +289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -306,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -342,9 +342,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -408,346 +408,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
@@ -899,9 +559,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -959,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -975,23 +635,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1010,9 +670,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1031,9 +691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1052,9 +712,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1073,9 +733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1094,9 +754,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1118,9 +778,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1142,9 +802,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1166,9 +826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1190,9 +850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1211,9 +871,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1242,9 +902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1261,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
|
||||
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
@@ -1302,9 +962,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1322,7 +982,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1331,13 +991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1347,21 +1007,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1389,9 +1049,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1448,51 +1108,30 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,49 +157,6 @@ export interface CancelledEvent {
|
||||
type: "cancelled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-compaction lifecycle. `start` carries `trigger` ("manual"/"auto";
|
||||
* auto adds `where` + `pct`); `progress` carries chunked-summarization
|
||||
* `part`/`total`/`depth` (or `retry_in`/`error` for a retry wait); `end`
|
||||
* carries `ok` plus either `before_tokens`/`after_tokens`/`summary` or the
|
||||
* failure `reason`/`message`. The successful end's summary also replays from
|
||||
* `/history` as a `role: "system"`, `source: "compaction"` entry.
|
||||
*/
|
||||
export interface CompactionEvent {
|
||||
type: "compaction";
|
||||
phase: "start" | "progress" | "end";
|
||||
/** Correlates every event of one compaction run (0 from legacy emitters). */
|
||||
compaction_id?: number;
|
||||
/**
|
||||
* End events only: true marks a force-abandoned compaction retiring
|
||||
* after a successor generation took over — skip failure notices for
|
||||
* those (an OK end's result still stands; the history swap happened).
|
||||
*/
|
||||
superseded?: boolean;
|
||||
/**
|
||||
* Failed ends only: the emitter-computed display verdict — show
|
||||
* `message` only when true, instead of re-deriving suppression from
|
||||
* reason/trigger/superseded client-side.
|
||||
*/
|
||||
notice?: boolean;
|
||||
/** Present on start and on every end (ok or failed). */
|
||||
trigger?: "manual" | "auto";
|
||||
where?: string;
|
||||
pct?: number;
|
||||
part?: number;
|
||||
total?: number;
|
||||
depth?: number;
|
||||
retry_in?: number;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
ok?: boolean;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
before_tokens?: number;
|
||||
after_tokens?: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// Global events
|
||||
|
||||
export interface WsStateEvent {
|
||||
@@ -255,7 +212,6 @@ export type ServerEvent =
|
||||
| BusyErrorEvent
|
||||
| ClearUiEvent
|
||||
| CancelledEvent
|
||||
| CompactionEvent
|
||||
| WsStateEvent
|
||||
| WsActivityEvent
|
||||
| WsRenameEvent
|
||||
|
||||
@@ -14,12 +14,9 @@ collect it as a test file — it's an importable utility, not a test.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.providers import StreamChunk, ToolCallDelta
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
|
||||
@@ -46,304 +43,3 @@ def make_session(**kwargs: Any) -> ChatSession:
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return ChatSession(**defaults)
|
||||
|
||||
|
||||
def mock_completion_result(
|
||||
content: str = "",
|
||||
tool_calls: list[dict[str, Any]] | None = None,
|
||||
) -> MagicMock:
|
||||
"""A provider result shaped like ``CompletionResult``.
|
||||
|
||||
Callers that route through ``model_turn`` (judges, task agents, and
|
||||
every lane #827 migrates) hit its re-ingest, which iterates
|
||||
``tool_calls``/``provider_blocks`` and joins ``reasoning`` — a bare
|
||||
MagicMock attribute would TypeError deep inside the seam, so every
|
||||
field the re-ingest reads is pinned to a real value here. ONE shared
|
||||
definition: when the re-ingest starts reading a new CompletionResult
|
||||
field, add it here and every suite moves together.
|
||||
"""
|
||||
result = MagicMock()
|
||||
result.content = content
|
||||
result.tool_calls = tool_calls
|
||||
result.finish_reason = "stop"
|
||||
result.usage = None
|
||||
result.provider_blocks = []
|
||||
result.reasoning = ""
|
||||
return result
|
||||
|
||||
|
||||
def fake_chat_stream(
|
||||
*,
|
||||
content: str | None = None,
|
||||
tool_calls: list[dict[str, str]] | None = None,
|
||||
finish_reason: str = "stop",
|
||||
prompt_tokens: int = 10,
|
||||
completion_tokens: int = 5,
|
||||
reasoning_content: str | None = None,
|
||||
reasoning: str | None = None,
|
||||
) -> list[Any]:
|
||||
"""Fake OpenAI Chat Completions SSE chunks for driving the REAL
|
||||
``OpenAIChatCompletionsProvider`` through a fake SDK client::
|
||||
|
||||
client.chat.completions.create = lambda **kw: fake_chat_stream(...)
|
||||
|
||||
Exercises the adapter's ``_iter_stream`` plus ``drain_stream`` end to
|
||||
end (the highest-fidelity fake lane), unlike ``as_stream`` which fakes
|
||||
at the provider boundary. ``tool_calls`` entries are
|
||||
``{"id", "name", "arguments"}`` dicts. ``SimpleNamespace`` (not
|
||||
``MagicMock``) so absent SDK fields read as real ``None`` — an
|
||||
auto-created mock attribute would leak into ``len()``/string paths.
|
||||
|
||||
Emits the realistic three-phase shape: data chunk(s), a finish-reason
|
||||
chunk, then the ``stream_options.include_usage`` usage-only chunk with
|
||||
empty ``choices``.
|
||||
"""
|
||||
|
||||
def _delta(
|
||||
content_val: str | None = None,
|
||||
tcs: list[Any] | None = None,
|
||||
rc: str | None = None,
|
||||
rsn: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
content=content_val,
|
||||
tool_calls=tcs,
|
||||
reasoning=rsn,
|
||||
reasoning_content=rc,
|
||||
annotations=None,
|
||||
)
|
||||
|
||||
chunks: list[Any] = []
|
||||
if reasoning_content is not None or reasoning is not None:
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
finish_reason=None, delta=_delta(rc=reasoning_content, rsn=reasoning)
|
||||
)
|
||||
],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
if content is not None:
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=None, delta=_delta(content))],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
if tool_calls:
|
||||
tcs = [
|
||||
SimpleNamespace(
|
||||
index=i,
|
||||
id=tc.get("id", ""),
|
||||
function=SimpleNamespace(
|
||||
name=tc.get("name", ""), arguments=tc.get("arguments", "")
|
||||
),
|
||||
)
|
||||
for i, tc in enumerate(tool_calls)
|
||||
]
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=None, delta=_delta(None, tcs))],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[SimpleNamespace(finish_reason=finish_reason, delta=_delta())],
|
||||
usage=None,
|
||||
)
|
||||
)
|
||||
chunks.append(
|
||||
SimpleNamespace(
|
||||
choices=[],
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=None,
|
||||
input_tokens_details=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
class _ScriptedClient:
|
||||
"""Callable client-method fake following a script of stream builders.
|
||||
|
||||
Call N returns the stream described by ``scripts[N]``; the last script
|
||||
repeats for any further calls. Each script is a dict of kwargs for
|
||||
the bound stream builder, or a pre-built return value. Records every
|
||||
call's kwargs on ``.calls`` — read ``len(fn.calls)`` where a test
|
||||
previously kept its own counter cell, and ``fn.calls[i]["messages"]``
|
||||
where it captured request bodies.
|
||||
"""
|
||||
|
||||
def __init__(self, scripts: tuple[Any, ...], to_stream: Any) -> None:
|
||||
self._scripts = scripts
|
||||
self._to_stream = to_stream
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(self, **kwargs: Any) -> Any:
|
||||
self.calls.append(kwargs)
|
||||
script = self._scripts[min(len(self.calls) - 1, len(self._scripts) - 1)]
|
||||
return self._to_stream(**script) if isinstance(script, dict) else script
|
||||
|
||||
|
||||
def scripted_chat_client(*scripts: Any) -> _ScriptedClient:
|
||||
"""A scripted ``client.chat.completions.create`` — dict scripts are
|
||||
:func:`fake_chat_stream` kwargs."""
|
||||
return _ScriptedClient(scripts, fake_chat_stream)
|
||||
|
||||
|
||||
def scripted_anthropic_client(*scripts: Any) -> _ScriptedClient:
|
||||
"""A scripted ``client.messages.stream`` — dict scripts are
|
||||
:func:`fake_anthropic_stream` kwargs (``blocks`` plus optional
|
||||
``stop_reason``/``usage``)."""
|
||||
return _ScriptedClient(scripts, fake_anthropic_stream)
|
||||
|
||||
|
||||
class FakeAnthropicBlock:
|
||||
"""A full-content Anthropic content-block fake for
|
||||
:func:`fake_anthropic_stream` — plain attributes plus the
|
||||
``model_dump()`` the provider's block capture reads."""
|
||||
|
||||
def __init__(self, **fields: Any) -> None:
|
||||
self._fields = fields
|
||||
for key, value in fields.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def model_dump(self, **_kw: Any) -> dict[str, Any]:
|
||||
return dict(self._fields)
|
||||
|
||||
|
||||
def fake_anthropic_stream(
|
||||
blocks: list[Any],
|
||||
*,
|
||||
stop_reason: str | None = "end_turn",
|
||||
usage: Any = None,
|
||||
) -> Any:
|
||||
"""Fake Anthropic SDK stream context manager for tests that drive the
|
||||
REAL ``AnthropicProvider`` through a fake client::
|
||||
|
||||
client.messages.stream = lambda **kw: fake_anthropic_stream(...)
|
||||
|
||||
Accepts the same full-content block fakes the pre-#831
|
||||
``get_final_message`` fixtures used (objects with ``.type`` + fields
|
||||
and ``model_dump()``) and synthesizes the real event grammar the
|
||||
streaming iterator consumes: ``content_block_start`` carries the block
|
||||
with its text/thinking/signature EMPTIED and ``input`` as ``{}`` (the
|
||||
SDK start shape), deltas carry the content, ``content_block_stop``
|
||||
finalizes tool input, and the closing ``message_delta`` carries
|
||||
``stop_reason`` (+ optional usage object). Without the stripping, the
|
||||
provider's raw-block accumulator would double every text/thinking
|
||||
field (start capture + delta append).
|
||||
|
||||
``stop_reason=None`` omits the closing ``message_delta`` entirely —
|
||||
the terminal-signal-less lax-gateway shape ``finish_reason_optional``
|
||||
exists for (content arrives, then the stream just ends).
|
||||
"""
|
||||
events: list[Any] = []
|
||||
for idx, block in enumerate(blocks):
|
||||
d = dict(block.model_dump()) if hasattr(block, "model_dump") else dict(vars(block))
|
||||
btype = d.get("type", "")
|
||||
start = dict(d)
|
||||
if btype == "text":
|
||||
start["text"] = ""
|
||||
elif btype == "thinking":
|
||||
start["thinking"] = ""
|
||||
start["signature"] = ""
|
||||
elif btype == "tool_use":
|
||||
start["input"] = {}
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_start", index=idx, content_block=SimpleNamespace(**start)
|
||||
)
|
||||
)
|
||||
if btype == "text" and d.get("text"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="text_delta", text=d["text"]),
|
||||
)
|
||||
)
|
||||
elif btype == "thinking":
|
||||
if d.get("thinking"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="thinking_delta", thinking=d["thinking"]),
|
||||
)
|
||||
)
|
||||
if d.get("signature"):
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(type="signature_delta", signature=d["signature"]),
|
||||
)
|
||||
)
|
||||
elif btype == "tool_use":
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=idx,
|
||||
delta=SimpleNamespace(
|
||||
type="input_json_delta",
|
||||
partial_json=json.dumps(d.get("input", {})),
|
||||
),
|
||||
)
|
||||
)
|
||||
events.append(SimpleNamespace(type="content_block_stop", index=idx))
|
||||
if stop_reason is not None or usage is not None:
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
|
||||
)
|
||||
)
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.__enter__ = MagicMock(return_value=events)
|
||||
mgr.__exit__ = MagicMock(return_value=False)
|
||||
return mgr
|
||||
|
||||
|
||||
def as_stream(result: Any) -> list[StreamChunk]:
|
||||
"""Adapt a ``CompletionResult``-shaped fake to a ``create_streaming``
|
||||
return value (single terminal chunk).
|
||||
|
||||
The #831 transport collapse routes every single-shot lane through
|
||||
``drain_stream(provider.create_streaming(...))``, so provider fakes
|
||||
return chunk iterables now. Tests keep building result-shaped fakes
|
||||
(``mock_completion_result`` or hand-rolled) and wrap them at
|
||||
assignment: ``provider.create_streaming.return_value =
|
||||
as_stream(result)``. A list re-iterates on every call, so one
|
||||
``return_value`` serves repeated-call tests; convert AFTER mutating
|
||||
the fake's fields — the chunk snapshots them.
|
||||
|
||||
Multi-chunk accumulation semantics are exercised by the dedicated
|
||||
``drain_stream`` unit tests, not through this helper.
|
||||
"""
|
||||
deltas = [
|
||||
ToolCallDelta(
|
||||
index=i,
|
||||
id=tc.get("id", ""),
|
||||
name=tc.get("function", {}).get("name", ""),
|
||||
arguments_delta=tc.get("function", {}).get("arguments", ""),
|
||||
)
|
||||
for i, tc in enumerate(result.tool_calls or [])
|
||||
]
|
||||
return [
|
||||
StreamChunk(
|
||||
content_delta=result.content or "",
|
||||
reasoning_delta=getattr(result, "reasoning", "") or "",
|
||||
tool_call_deltas=deltas,
|
||||
usage=result.usage,
|
||||
finish_reason=result.finish_reason or "stop",
|
||||
provider_blocks=list(result.provider_blocks or []),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
"""Browser-fidelity SSE recovery harness helpers.
|
||||
|
||||
The load-bearing assembly for ``tests/test_sse_recovery_e2e.py``: a
|
||||
``BrowserlikeSSEClient`` that speaks the exact wire contract the real
|
||||
``turnstone/shared_static/interactive.js`` pane speaks, and the
|
||||
assertion helpers the scenarios share. The server boot machinery lives
|
||||
in ``_sse_recovery_server.py``.
|
||||
|
||||
Why a raw-socket SSE reader (and not ``httpx.stream``): the slow-consumer
|
||||
overflow scenario needs the consumer to STALL — stop reading the socket
|
||||
so the server's SSE generator blocks on ``await send`` and stops draining
|
||||
the per-UI listener queue, which then poisons at its cap. A faithful
|
||||
stall needs (a) precise control over when bytes are read and (b) a small
|
||||
``SO_RCVBUF`` so the in-flight backlog before poison stays bounded to
|
||||
~100 KB instead of the client kernel's multi-MB autotuned default (which
|
||||
would need tens of thousands of events to overflow). A raw socket gives
|
||||
both; httpx (used here only for the plain ``/history`` request/response)
|
||||
gives neither. This is ALSO closer to the browser: EventSource has a
|
||||
bounded receive buffer, not an unbounded one.
|
||||
|
||||
Client contract mirrored from interactive.js (line references are to
|
||||
that file on the ``fix/sse-truncated-resync`` branch):
|
||||
|
||||
- ``_last_event_id`` advances ONLY from SSE ``id:`` fields, and only
|
||||
ring-buffer events carry one — synthetic replay frames (connected /
|
||||
status / state_change / in_progress_snapshot / replay_truncated /
|
||||
stream_overflow) do not, exactly like ``EventSource.lastEventId``
|
||||
(interactive.js onmessage ~1378).
|
||||
- reconnect presents ``connectCursor = _truncatedFromCursor ??
|
||||
_lastEventId`` as ``?last_event_id=`` (manual path) or a
|
||||
``Last-Event-ID`` header (native EventSource auto-reconnect path)
|
||||
(interactive.js connectSSE ~1328).
|
||||
- on a ``replay_truncated`` envelope the client records the
|
||||
truncation-time cursor keep-oldest (``_truncatedFromCursor =
|
||||
_lastEventId`` only when null) and runs ``_loadHistoryThenConnect``
|
||||
(disconnect → /history → adopt cursor → reconnect); a FAILED
|
||||
/history leaves the record armed so the reconnect re-presents the
|
||||
truncation-time cursor and re-draws the envelope (interactive.js
|
||||
handleEvent replay_truncated ~2303, _loadHistoryThenConnect ~1604,
|
||||
_refetchHistory seedCursor ~1706).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# Small client receive buffer so a stalled consumer's in-flight backlog
|
||||
# before the server-side poison stays bounded (~100 KB) instead of the
|
||||
# multi-MB autotuned default. Paired with the server's small SO_SNDBUF
|
||||
# (see _sse_recovery_server.build_recovery_server).
|
||||
_CLIENT_RCVBUF = 2048
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSEFrame:
|
||||
"""One decoded SSE frame, tagged with the connection it arrived on.
|
||||
|
||||
``event_id`` is the ``id:`` field verbatim (a stringified integer,
|
||||
or ``None`` for id-less synthetic frames — the same string domain as
|
||||
``EventSource.lastEventId``). ``etype`` is the ``type`` field of the
|
||||
JSON ``data:`` payload (the application event type), distinct from
|
||||
any SSE ``event:`` field, which the server never uses.
|
||||
"""
|
||||
|
||||
conn_index: int
|
||||
event_id: str | None
|
||||
etype: str | None
|
||||
payload: dict[str, Any] | None
|
||||
raw: str
|
||||
|
||||
@property
|
||||
def event_id_int(self) -> int | None:
|
||||
if self.event_id is None:
|
||||
return None
|
||||
try:
|
||||
return int(self.event_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class BrowserlikeSSEClient:
|
||||
"""A single interactive pane's SSE + /history state machine.
|
||||
|
||||
Not thread-safe against concurrent public calls; drive it from one
|
||||
test thread. Internally a per-connection reader thread decodes the
|
||||
stream; ``stall()`` / ``resume()`` gate that thread's socket reads so
|
||||
a test can build server-side backpressure without closing the
|
||||
connection (the slow-consumer → listener-queue-poison path).
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, ws_id: str, token: str) -> None:
|
||||
parts = urlsplit(base_url)
|
||||
self._host = parts.hostname or "127.0.0.1"
|
||||
self._port = parts.port or 80
|
||||
self._ws_id = ws_id
|
||||
self._token = token
|
||||
self._auth = {"Authorization": f"Bearer {token}"}
|
||||
self._http = httpx.Client(
|
||||
base_url=f"http://{self._host}:{self._port}", timeout=httpx.Timeout(15.0)
|
||||
)
|
||||
|
||||
# EventSource-equivalent cursor state.
|
||||
self._last_event_id: str | None = None
|
||||
self._truncated_from_cursor: str | None = None
|
||||
|
||||
# Transcript. ``_all_frames`` is the cross-connection accumulation
|
||||
# (what "the client eventually saw"); ``_conn_frames`` keeps each
|
||||
# connection's slice for per-connection assertions (contiguity).
|
||||
self._all_frames: list[SSEFrame] = []
|
||||
self._conn_frames: list[list[SSEFrame]] = []
|
||||
self._frames_lock = threading.Lock()
|
||||
|
||||
# Reader plumbing.
|
||||
self._sock: socket.socket | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self._read_gate = threading.Event()
|
||||
self._read_gate.set() # reading permitted by default
|
||||
self._status: int | None = None
|
||||
self._headers_done = threading.Event()
|
||||
|
||||
# -- connection lifecycle ------------------------------------------------
|
||||
|
||||
def _events_path(self, cursor: str | None) -> str:
|
||||
path = f"/v1/api/workstreams/{self._ws_id}/events"
|
||||
if cursor is not None:
|
||||
path += f"?last_event_id={cursor}"
|
||||
return path
|
||||
|
||||
def connect(self, *, native: bool = False, rcvbuf: int | None = None) -> None:
|
||||
"""Open the SSE stream, presenting the client's current cursor.
|
||||
|
||||
``native=True`` models the browser's EventSource auto-reconnect:
|
||||
the cursor rides a ``Last-Event-ID`` HEADER and never appears in
|
||||
the URL. ``native=False`` models the manual ``new EventSource(url
|
||||
+ '?last_event_id=')`` path interactive.js uses when it must
|
||||
override the live cursor (the ``connectCursor`` chokepoint).
|
||||
|
||||
``rcvbuf`` shrinks this connection's ``SO_RCVBUF`` — pass
|
||||
``_CLIENT_RCVBUF`` on a connection the test will ``stall()`` so the
|
||||
in-flight backlog before the server-side poison stays bounded.
|
||||
Leave it ``None`` (OS default) on recovery reconnects so the ring
|
||||
replay is not throttled to a crawl.
|
||||
"""
|
||||
if self._reader is not None:
|
||||
raise RuntimeError("already connected; disconnect() first")
|
||||
connect_cursor = (
|
||||
self._truncated_from_cursor
|
||||
if self._truncated_from_cursor is not None
|
||||
else self._last_event_id
|
||||
)
|
||||
header_lines = [
|
||||
f"Host: {self._host}:{self._port}",
|
||||
f"Authorization: Bearer {self._token}",
|
||||
"Accept: text/event-stream",
|
||||
"Cache-Control: no-cache",
|
||||
]
|
||||
if native:
|
||||
path = self._events_path(None)
|
||||
if connect_cursor is not None:
|
||||
header_lines.append(f"Last-Event-ID: {connect_cursor}")
|
||||
else:
|
||||
path = self._events_path(connect_cursor)
|
||||
request = f"GET {path} HTTP/1.1\r\n" + "\r\n".join(header_lines) + "\r\n\r\n"
|
||||
|
||||
sock = socket.create_connection((self._host, self._port), timeout=10)
|
||||
if rcvbuf is not None:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.settimeout(None)
|
||||
sock.sendall(request.encode())
|
||||
self._sock = sock
|
||||
|
||||
self._stop.clear()
|
||||
self._read_gate.set()
|
||||
self._status = None
|
||||
self._headers_done.clear()
|
||||
conn_index = len(self._conn_frames)
|
||||
frames: list[SSEFrame] = []
|
||||
self._conn_frames.append(frames)
|
||||
self._reader = threading.Thread(
|
||||
target=self._read_loop,
|
||||
args=(sock, conn_index, frames),
|
||||
name=f"sse-reader-{self._ws_id[:6]}-{conn_index}",
|
||||
daemon=True,
|
||||
)
|
||||
self._reader.start()
|
||||
|
||||
# Surface a non-200 handshake to the caller (409 half-built UI,
|
||||
# 404 unknown ws, 401 auth) rather than silently reading nothing.
|
||||
if not self._headers_done.wait(timeout=10):
|
||||
self.disconnect()
|
||||
raise AssertionError("events connect: no HTTP response headers")
|
||||
if self._status != 200:
|
||||
status = self._status
|
||||
self.disconnect()
|
||||
raise AssertionError(f"events connect returned HTTP {status}")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the stream and join the reader (leak-guard clean)."""
|
||||
self._stop.set()
|
||||
self._read_gate.set() # release a stalled reader so it sees _stop
|
||||
sock = self._sock
|
||||
if sock is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.shutdown(socket.SHUT_RDWR) # interrupt a blocked recv
|
||||
reader = self._reader
|
||||
if reader is not None:
|
||||
reader.join(timeout=15)
|
||||
if reader.is_alive():
|
||||
raise AssertionError("SSE reader thread failed to stop")
|
||||
if sock is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
self._sock = None
|
||||
self._reader = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Full teardown: disconnect any live stream + close the HTTP client."""
|
||||
if self._reader is not None:
|
||||
self.disconnect()
|
||||
self._http.close()
|
||||
|
||||
# -- the stall gate (backpressure driver) --------------------------------
|
||||
|
||||
def stall(self) -> None:
|
||||
"""Stop reading the socket. The kernel + uvicorn send buffers fill,
|
||||
blocking the server's SSE generator on its ``await send``, so it
|
||||
stops draining the per-UI listener queue — which poisons at its cap.
|
||||
"""
|
||||
self._read_gate.clear()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume reading. A poisoned-and-closed stream delivers its
|
||||
``stream_overflow`` farewell frame once the backlog drains."""
|
||||
self._read_gate.set()
|
||||
|
||||
# -- reader --------------------------------------------------------------
|
||||
|
||||
def _read_loop(self, sock: socket.socket, conn_index: int, frames: list[SSEFrame]) -> None:
|
||||
raw = b"" # undecoded bytes (headers, then chunked framing)
|
||||
sse = b"" # decoded SSE byte stream
|
||||
headers_parsed = False
|
||||
chunked = False
|
||||
while not self._stop.is_set():
|
||||
# Backpressure gate: while stalled we do NOT read the socket, so
|
||||
# its receive buffer fills and TCP flow control stalls the server.
|
||||
if not self._read_gate.wait(timeout=0.1):
|
||||
continue
|
||||
if self._stop.is_set():
|
||||
break
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break # server closed
|
||||
raw += chunk
|
||||
if not headers_parsed:
|
||||
if b"\r\n\r\n" not in raw:
|
||||
continue
|
||||
header_blob, raw = raw.split(b"\r\n\r\n", 1)
|
||||
self._parse_headers(header_blob)
|
||||
chunked = b"transfer-encoding: chunked" in header_blob.lower()
|
||||
headers_parsed = True
|
||||
self._headers_done.set()
|
||||
if chunked:
|
||||
decoded, raw = _dechunk(raw)
|
||||
sse += decoded
|
||||
else:
|
||||
sse += raw
|
||||
raw = b""
|
||||
sse = sse.replace(b"\r\n", b"\n")
|
||||
while b"\n\n" in sse:
|
||||
block, sse = sse.split(b"\n\n", 1)
|
||||
self._handle_block(block.decode("utf-8", "replace"), conn_index, frames)
|
||||
|
||||
def _parse_headers(self, header_blob: bytes) -> None:
|
||||
first_line = header_blob.split(b"\r\n", 1)[0].decode("latin-1")
|
||||
# "HTTP/1.1 200 OK"
|
||||
parts = first_line.split(" ", 2)
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
self._status = int(parts[1])
|
||||
|
||||
def _handle_block(self, block_text: str, conn_index: int, frames: list[SSEFrame]) -> None:
|
||||
event_id: str | None = None
|
||||
data_parts: list[str] = []
|
||||
retry: str | None = None
|
||||
for line in block_text.split("\n"):
|
||||
if not line or line.startswith(":"):
|
||||
continue # blank or comment (ping)
|
||||
field_name, _, value = line.partition(":")
|
||||
if value.startswith(" "):
|
||||
value = value[1:] # SSE strips a single leading space
|
||||
if field_name == "id":
|
||||
event_id = value
|
||||
elif field_name == "data":
|
||||
data_parts.append(value)
|
||||
elif field_name == "retry":
|
||||
retry = value
|
||||
# EventSource semantics: an event carrying an ``id:`` sets the
|
||||
# last-event-id buffer; an event without one leaves it unchanged.
|
||||
if event_id is not None:
|
||||
self._last_event_id = event_id
|
||||
if not data_parts:
|
||||
if retry is not None:
|
||||
self._record(SSEFrame(conn_index, None, "retry", None, block_text), frames)
|
||||
return
|
||||
data_str = "\n".join(data_parts)
|
||||
payload: dict[str, Any] | None
|
||||
try:
|
||||
parsed = json.loads(data_str)
|
||||
payload = parsed if isinstance(parsed, dict) else None
|
||||
except ValueError:
|
||||
payload = None
|
||||
etype = payload.get("type") if payload is not None else None
|
||||
frame = SSEFrame(conn_index, event_id, etype, payload, data_str)
|
||||
self._record(frame, frames)
|
||||
# Mirror the pane: the FIRST replay_truncated for an unrepaired gap
|
||||
# records the truncation-time cursor (keep-oldest). Its consumer is
|
||||
# the reconnect chokepoint (see ``connect``).
|
||||
if etype == "replay_truncated" and self._truncated_from_cursor is None:
|
||||
self._truncated_from_cursor = self._last_event_id
|
||||
|
||||
def _record(self, frame: SSEFrame, frames: list[SSEFrame]) -> None:
|
||||
with self._frames_lock:
|
||||
frames.append(frame)
|
||||
self._all_frames.append(frame)
|
||||
|
||||
# -- /history + cursor flow ----------------------------------------------
|
||||
|
||||
def fetch_history(self) -> dict[str, Any]:
|
||||
"""GET /history and return the parsed JSON ({ws_id, messages, cursor})."""
|
||||
r = self._http.get(f"/v1/api/workstreams/{self._ws_id}/history", headers=self._auth)
|
||||
r.raise_for_status()
|
||||
result: dict[str, Any] = r.json()
|
||||
return result
|
||||
|
||||
def seed_from_history(self) -> dict[str, Any]:
|
||||
"""The seedCursor step: fetch /history, adopt a non-null resume
|
||||
cursor into ``_last_event_id``, and clear the truncation record on
|
||||
a successful render (replayHistory clears ``_truncatedFromCursor``).
|
||||
"""
|
||||
data = self.fetch_history()
|
||||
cursor = data.get("cursor")
|
||||
if cursor is not None:
|
||||
self._last_event_id = str(cursor)
|
||||
self._truncated_from_cursor = None # successful full render repairs the gap
|
||||
return data
|
||||
|
||||
def load_history_then_connect(
|
||||
self, *, fail_history: bool = False, native: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reproduce interactive.js ``_loadHistoryThenConnect``.
|
||||
|
||||
Disconnect first, drop the live cursor (``_last_event_id = None``)
|
||||
but KEEP ``_truncated_from_cursor`` armed, then fetch /history and
|
||||
reconnect. On success adopt the returned cursor and clear the
|
||||
truncation record; on a FAILED /history (``fail_history`` — the
|
||||
harness IS the client here, so a client-side simulated failure is
|
||||
faithful) leave the record armed so the reconnect re-presents the
|
||||
truncation-time cursor and re-draws ``replay_truncated``.
|
||||
|
||||
Returns the /history JSON, or ``None`` when the fetch failed.
|
||||
"""
|
||||
if self._reader is not None:
|
||||
self.disconnect()
|
||||
self._last_event_id = None
|
||||
data: dict[str, Any] | None
|
||||
if fail_history:
|
||||
data = None
|
||||
else:
|
||||
data = self.fetch_history()
|
||||
cursor = data.get("cursor")
|
||||
if cursor is not None:
|
||||
self._last_event_id = str(cursor)
|
||||
self._truncated_from_cursor = None
|
||||
self.connect(native=native)
|
||||
return data
|
||||
|
||||
# -- accessors + waits ---------------------------------------------------
|
||||
|
||||
@property
|
||||
def last_event_id(self) -> str | None:
|
||||
return self._last_event_id
|
||||
|
||||
@property
|
||||
def truncated_from_cursor(self) -> str | None:
|
||||
return self._truncated_from_cursor
|
||||
|
||||
def all_frames(self) -> list[SSEFrame]:
|
||||
with self._frames_lock:
|
||||
return list(self._all_frames)
|
||||
|
||||
def conn_frames(self, conn_index: int) -> list[SSEFrame]:
|
||||
with self._frames_lock:
|
||||
return list(self._conn_frames[conn_index])
|
||||
|
||||
def latest_conn_frames(self) -> list[SSEFrame]:
|
||||
with self._frames_lock:
|
||||
return list(self._conn_frames[-1]) if self._conn_frames else []
|
||||
|
||||
def num_connections(self) -> int:
|
||||
with self._frames_lock:
|
||||
return len(self._conn_frames)
|
||||
|
||||
def frames_of_type(self, etype: str) -> list[SSEFrame]:
|
||||
return [f for f in self.all_frames() if f.etype == etype]
|
||||
|
||||
def has_type(self, etype: str) -> bool:
|
||||
return any(f.etype == etype for f in self.all_frames())
|
||||
|
||||
def tool_output_by_call(self) -> dict[str, str]:
|
||||
"""Concatenate every ``tool_output_chunk`` payload per call_id, in
|
||||
arrival order — the reconstructed live stream for each call."""
|
||||
out: dict[str, str] = {}
|
||||
for f in self.all_frames():
|
||||
if f.etype == "tool_output_chunk" and f.payload is not None:
|
||||
cid = str(f.payload.get("call_id", ""))
|
||||
out[cid] = out.get(cid, "") + str(f.payload.get("chunk", ""))
|
||||
return out
|
||||
|
||||
def tool_results_by_call(self) -> dict[str, str]:
|
||||
"""The last ``tool_result`` output seen per call_id."""
|
||||
out: dict[str, str] = {}
|
||||
for f in self.all_frames():
|
||||
if f.etype == "tool_result" and f.payload is not None:
|
||||
out[str(f.payload.get("call_id", ""))] = str(f.payload.get("output", ""))
|
||||
return out
|
||||
|
||||
def wait_for_type(self, etype: str, *, timeout: float = 45.0) -> SSEFrame:
|
||||
"""Block until a frame of ``etype`` has arrived on ANY connection."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
for f in self.all_frames():
|
||||
if f.etype == etype:
|
||||
return f
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"timed out waiting for a {etype!r} frame")
|
||||
|
||||
def wait_for(
|
||||
self, predicate: Callable[[BrowserlikeSSEClient], bool], *, timeout: float = 45.0
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate(self):
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("timed out waiting for predicate")
|
||||
|
||||
def wait_for_call_result(self, call_id: str, *, timeout: float = 45.0) -> None:
|
||||
self.wait_for(lambda c: call_id in c.tool_results_by_call(), timeout=timeout)
|
||||
|
||||
|
||||
def _dechunk(buf: bytes) -> tuple[bytes, bytes]:
|
||||
"""Incrementally decode HTTP/1.1 chunked transfer-encoding.
|
||||
|
||||
Consumes as many COMPLETE chunks from ``buf`` as possible and returns
|
||||
``(decoded_bytes, remainder)`` where ``remainder`` is the trailing
|
||||
partial chunk to carry into the next read. A zero-length chunk (stream
|
||||
end) simply stops consumption; the reader's ``recv`` EOF handles close.
|
||||
"""
|
||||
decoded = b""
|
||||
while True:
|
||||
if b"\r\n" not in buf:
|
||||
break # incomplete size line
|
||||
size_line, rest = buf.split(b"\r\n", 1)
|
||||
try:
|
||||
n = int(size_line.strip() or b"z", 16)
|
||||
except ValueError:
|
||||
break # malformed / partial — wait for more bytes
|
||||
if n == 0:
|
||||
break # last chunk marker
|
||||
if len(rest) < n + 2: # need n data bytes + trailing CRLF
|
||||
break
|
||||
decoded += rest[:n]
|
||||
buf = rest[n + 2 :]
|
||||
return decoded, buf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertion helpers (shared by the scenarios).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assert_contiguous_ids(frames: list[SSEFrame]) -> None:
|
||||
"""Every id-bearing frame in a connection forms a gap-free, dup-free,
|
||||
strictly increasing run.
|
||||
|
||||
Holds for a connection that took no ``_seq``-filtered fresh path — a
|
||||
fresh connect made before any event (snap_seq == 0) and every
|
||||
``replay_ok`` reconnect (snap_seq == 0). The server stamps a fresh
|
||||
monotonic id per enqueue with no in-ring coalescing, so a
|
||||
non-filtered consumer sees consecutive ids.
|
||||
"""
|
||||
ids = [f.event_id_int for f in frames if f.event_id_int is not None]
|
||||
assert ids, "connection carried no id-bearing frames"
|
||||
assert len(set(ids)) == len(ids), f"duplicate SSE ids: {ids}"
|
||||
assert ids == sorted(ids), f"SSE ids not monotonic: {ids}"
|
||||
for prev, cur in zip(ids, ids[1:], strict=False):
|
||||
assert cur == prev + 1, f"gap in SSE ids between {prev} and {cur}: {ids}"
|
||||
|
||||
|
||||
def assert_ids_monotonic_no_dupes(frames: list[SSEFrame]) -> None:
|
||||
"""Weaker invariant that holds on EVERY connection (including
|
||||
``_seq``-filtered fresh/truncated paths, where gaps are legal): ids
|
||||
are strictly increasing with no duplicates."""
|
||||
ids = [f.event_id_int for f in frames if f.event_id_int is not None]
|
||||
assert len(set(ids)) == len(ids), f"duplicate SSE ids: {ids}"
|
||||
assert ids == sorted(ids), f"SSE ids not monotonic: {ids}"
|
||||
|
||||
|
||||
def assert_chunk_result_ordering(frames: list[SSEFrame]) -> None:
|
||||
"""Every ``tool_output_chunk`` for a call precedes that call's own
|
||||
``tool_result`` on the wire (the load-bearing ordering — the client
|
||||
removes the streaming <pre> when it renders the result)."""
|
||||
result_index: dict[str, int] = {}
|
||||
for i, f in enumerate(frames):
|
||||
if f.etype == "tool_result" and f.payload is not None:
|
||||
result_index[str(f.payload.get("call_id", ""))] = i
|
||||
for i, f in enumerate(frames):
|
||||
if f.etype == "tool_output_chunk" and f.payload is not None:
|
||||
cid = str(f.payload.get("call_id", ""))
|
||||
assert cid in result_index, f"chunk for call {cid} has no tool_result"
|
||||
assert i < result_index[cid], (
|
||||
f"chunk for call {cid} arrived AFTER its tool_result "
|
||||
f"(chunk idx {i} >= result idx {result_index[cid]})"
|
||||
)
|
||||
|
||||
|
||||
def assert_children_stamped(frames: list[SSEFrame], parent_call_id: str) -> None:
|
||||
"""Every sub-agent child tool event carries ``parent_call_id`` (stamped
|
||||
at the flush chokepoint). Sub-tool call_ids are minted
|
||||
``{parent}::r{run}s{step}::{provider_id}`` — the ``::`` segment is the
|
||||
identifying mark — and NONE may escape unstamped to the top level."""
|
||||
unstamped: list[tuple[str | None, str, Any]] = []
|
||||
stamped = 0
|
||||
for f in frames:
|
||||
if f.payload is None:
|
||||
continue
|
||||
items = f.payload.get("items")
|
||||
entries = items if isinstance(items, list) else [f.payload]
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
cid = str(entry.get("call_id", ""))
|
||||
if "::" not in cid:
|
||||
continue
|
||||
if entry.get("parent_call_id") == parent_call_id:
|
||||
stamped += 1
|
||||
else:
|
||||
unstamped.append((f.etype, cid, entry.get("parent_call_id")))
|
||||
assert stamped > 0, f"no child events found for parent {parent_call_id}"
|
||||
assert not unstamped, f"child events escaped unstamped (parent {parent_call_id}): {unstamped}"
|
||||
|
||||
|
||||
def history_tool_outputs(history_json: dict[str, Any]) -> dict[str, str]:
|
||||
"""Extract {call_id: output} from a /history projection, however the
|
||||
projection surfaces results (a folded ``output`` on a tool_call, or a
|
||||
trailing ``role: tool`` row keyed by ``tool_call_id``)."""
|
||||
out: dict[str, str] = {}
|
||||
for msg in history_json.get("messages", []):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") == "tool":
|
||||
cid = msg.get("tool_call_id") or msg.get("call_id")
|
||||
if cid is not None:
|
||||
out[str(cid)] = str(msg.get("content", ""))
|
||||
for tc in msg.get("tool_calls") or ():
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
cid = tc.get("id") or tc.get("call_id")
|
||||
if cid is not None and tc.get("output") is not None:
|
||||
out[str(cid)] = str(tc.get("output", ""))
|
||||
return out
|
||||
|
||||
|
||||
def assert_converged(client: BrowserlikeSSEClient, history_json: dict[str, Any]) -> None:
|
||||
"""Turn-level equivalence: every tool result the client assembled live
|
||||
is present, with the same output, in a fresh /history projection.
|
||||
|
||||
Compares by call_id so a reconnect that re-delivered a result can't
|
||||
hide a divergence, and asserts the /history side isn't empty (a
|
||||
silently-lost turn would leave the projection short)."""
|
||||
live = client.tool_results_by_call()
|
||||
hist = history_tool_outputs(history_json)
|
||||
assert hist, "fresh /history projected no tool results — a turn was lost"
|
||||
for call_id, output in live.items():
|
||||
assert call_id in hist, f"call {call_id} seen live but absent from /history: {sorted(hist)}"
|
||||
assert hist[call_id] == output, (
|
||||
f"call {call_id} output diverged: live={output!r} history={hist[call_id]!r}"
|
||||
)
|
||||
@@ -1,628 +0,0 @@
|
||||
"""Boot the REAL interactive Turnstone server for the SSE recovery e2e
|
||||
harness: real ``SessionManager`` + real ``ChatSession`` engine driven
|
||||
through a scripted chat-completions client at the SDK boundary, executing
|
||||
REAL bash tools, exposed over a real uvicorn socket.
|
||||
|
||||
The recipe (verified end-to-end) has four load-bearing pieces:
|
||||
|
||||
1. **Provider injection seam.** ``create_app`` takes a PRE-BUILT
|
||||
``SessionManager``, so the harness owns the ``session_factory``: it
|
||||
passes ``client=fake_client`` and OMITS the registry, so
|
||||
``ChatSession`` falls back to ``create_provider("openai-compatible")``
|
||||
== ``OpenAIChatCompletionsProvider`` — exactly what
|
||||
``tests._session_helpers.scripted_chat_client`` targets. No production
|
||||
monkeypatch of the engine.
|
||||
|
||||
2. **Auto-title suppression.** The first user message spawns a background
|
||||
``_generate_title`` LLM call that would consume the first scripted
|
||||
response (the tool call) and desync a positional script. Setting
|
||||
``session._title_generated = True`` before the first send disables it.
|
||||
|
||||
3. **Completion barrier.** ``/send`` returns immediately after spawning
|
||||
``ws.worker_thread``; joining that thread is the true "turn complete,
|
||||
every SSE event enqueued" barrier (``stream_end`` is per-LLM-call, not
|
||||
per-turn, so it is NOT a completion marker).
|
||||
|
||||
4. **Thread hygiene.** ``create_app``'s lifespan starts daemon fan-out
|
||||
threads (``_global_fanout_thread`` blocking on ``global_queue.get()``,
|
||||
``_aggregate_emitter_thread`` on a 10s loop). The harness used to
|
||||
swap them for no-ops (they had no shutdown and tripped conftest's
|
||||
leaked-thread guard), which kept the global lane dead here; #885 gave
|
||||
the lifespan a real shutdown (stop Event + a queue sentinel for the
|
||||
fanout, joined in the lifespan exit that ``stop()``'s
|
||||
``should_exit``/join drives), so the harness now runs them REAL — the
|
||||
``roster-restart`` scenario depends on a live global lane — and
|
||||
teardown stays clean with no ``allow_thread_leak``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import queue as _q
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
|
||||
from tests._session_helpers import scripted_chat_client
|
||||
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
|
||||
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
|
||||
from turnstone.core.session import ChatSession
|
||||
from turnstone.core.session_manager import SessionManager
|
||||
from turnstone.core.session_ui_base import SessionUIBase
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
from turnstone.prompts import ClientType
|
||||
from turnstone.server import WebUI, create_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import MutableMapping
|
||||
|
||||
from turnstone.core.workstream import Workstream
|
||||
|
||||
_JWT_SECRET = "sse-recovery-e2e-jwt-secret-minimum-32-chars!"
|
||||
# Small server send buffer so a stalled consumer's in-flight backlog before
|
||||
# the listener-queue poison stays bounded (paired with the client's small
|
||||
# SO_RCVBUF in _sse_recovery_helpers). Harmless for prompt readers.
|
||||
_DEFAULT_SNDBUF = 8192
|
||||
|
||||
|
||||
def _fake_client(scripts: tuple[Any, ...]) -> Any:
|
||||
"""An SDK-shaped fake whose ``chat.completions.create`` follows a
|
||||
positional script (each a :func:`fake_chat_stream` kwargs dict)."""
|
||||
create_fn = scripted_chat_client(*scripts)
|
||||
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create_fn)))
|
||||
client.calls = create_fn.calls
|
||||
return client
|
||||
|
||||
|
||||
class RecoveryServer:
|
||||
"""A booted interactive node the recovery scenarios drive."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sndbuf: int = _DEFAULT_SNDBUF,
|
||||
listener_cap: int | None = None,
|
||||
extra_routes: list[Any] | None = None,
|
||||
port: int = 0,
|
||||
sock: socket.socket | None = None,
|
||||
) -> None:
|
||||
self._global_queue: _q.Queue[dict[str, Any]] = _q.Queue(maxsize=100000)
|
||||
self._global_listeners: list[_q.Queue[dict[str, Any]]] = []
|
||||
self._global_listeners_lock = threading.Lock()
|
||||
# Per-ws scripted client, resolved at factory-call time.
|
||||
self._pending_client: Any = _fake_client((dict(content="ok", finish_reason="stop"),))
|
||||
self._clients: dict[str, Any] = {}
|
||||
|
||||
WebUI._global_queue = self._global_queue
|
||||
|
||||
def session_factory(
|
||||
ui: Any,
|
||||
model_alias: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
skill: Any = None,
|
||||
client_type: str = "",
|
||||
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
|
||||
parent_ws_id: str | None = None,
|
||||
project_id: str = "",
|
||||
**_extra: Any,
|
||||
) -> ChatSession:
|
||||
client = self._pending_client
|
||||
if ws_id is not None:
|
||||
self._clients[ws_id] = client
|
||||
return ChatSession(
|
||||
client=client,
|
||||
model="test-model",
|
||||
ui=ui,
|
||||
instructions=None,
|
||||
temperature=None,
|
||||
max_tokens=1024,
|
||||
tool_timeout=30,
|
||||
ws_id=ws_id,
|
||||
user_id="recovery-user",
|
||||
client_type=ClientType.WEB,
|
||||
kind=kind,
|
||||
# Don't truncate large tool outputs: the harness tests
|
||||
# recovery, not the tool-result truncation budget, and a
|
||||
# truncated /history would diverge from the full live event
|
||||
# and defeat the convergence assertions.
|
||||
tool_truncation=10_000_000,
|
||||
)
|
||||
|
||||
self._adapter = InteractiveAdapter(
|
||||
global_queue=self._global_queue,
|
||||
ui_factory=lambda ws: WebUI(
|
||||
ws_id=ws.id, user_id=ws.user_id, kind=ws.kind, parent_ws_id=ws.parent_ws_id
|
||||
),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
self._manager = SessionManager(
|
||||
self._adapter, storage=get_storage(), max_active=32, node_id="recovery-node"
|
||||
)
|
||||
self._adapter.attach(self._manager)
|
||||
WebUI._workstream_mgr = self._manager
|
||||
|
||||
# delay_load knob state (see the method): wraps the storage
|
||||
# singleton's load_messages; restored in stop().
|
||||
self._load_delay_ms = 0
|
||||
self._load_calls = 0
|
||||
# load_messages runs on asyncio.to_thread WORKERS, and delay_load
|
||||
# exists precisely to overlap two of them — unlike the
|
||||
# single-writer HTTP counters, this one has genuine concurrent
|
||||
# writers, so the increment takes a lock (a lost update would
|
||||
# false-FAIL G7's load_delta === 2, or mask a third load).
|
||||
self._load_calls_lock = threading.Lock()
|
||||
_storage_obj = get_storage()
|
||||
self._orig_load_messages = _storage_obj.load_messages
|
||||
|
||||
def _delayed_load(*a: Any, **k: Any) -> Any:
|
||||
with self._load_calls_lock:
|
||||
self._load_calls += 1
|
||||
result = self._orig_load_messages(*a, **k)
|
||||
# Sleep AFTER the load: the held flight must hold the data it
|
||||
# actually read (its transaction point), so a flight parked
|
||||
# across a rewind genuinely carries PRE-rewind rows — a
|
||||
# pre-load sleep would read post-rewind storage and mask a
|
||||
# wrongly-joined flight as fresh truth.
|
||||
d = self._load_delay_ms
|
||||
if d > 0:
|
||||
time.sleep(d / 1000.0)
|
||||
return result
|
||||
|
||||
_storage_obj.load_messages = _delayed_load # type: ignore[method-assign]
|
||||
self._patched_storage = _storage_obj
|
||||
|
||||
# Optional small listener-queue cap. The cap is a default arg on the
|
||||
# registration methods with no config/env override, so lower it by
|
||||
# patching their ``__defaults__`` (restored on stop). fix-3's
|
||||
# de-amplification makes a real 500-cap overflow need a pathological
|
||||
# storm; a small cap exercises the identical _ListenerOverflow ->
|
||||
# stream_overflow -> reconnect-replay path within a bounded storm.
|
||||
self._orig_defaults: list[tuple[Any, tuple[Any, ...] | None]] = []
|
||||
if listener_cap is not None:
|
||||
for meth in (
|
||||
SessionUIBase._register_listener,
|
||||
SessionUIBase.register_listener_with_in_progress_snapshot,
|
||||
SessionUIBase.register_listener_with_replay,
|
||||
):
|
||||
self._orig_defaults.append((meth, meth.__defaults__))
|
||||
meth.__defaults__ = (listener_cap,)
|
||||
|
||||
self._app = create_app(
|
||||
workstreams=self._manager,
|
||||
global_queue=self._global_queue,
|
||||
global_listeners=self._global_listeners,
|
||||
global_listeners_lock=self._global_listeners_lock,
|
||||
skip_permissions=True,
|
||||
jwt_secret=_JWT_SECRET,
|
||||
node_id="recovery-node",
|
||||
# /history + tenant checks read app.state.auth_storage.
|
||||
auth_storage=get_storage(),
|
||||
)
|
||||
# Same-origin extras (Tier 2 serves its recovery page here so the real
|
||||
# Pane's cookie auth + EventSource work without cross-origin plumbing).
|
||||
if extra_routes:
|
||||
self._app.router.routes.extend(extra_routes)
|
||||
|
||||
# Pre-bind a listening socket with a small SO_SNDBUF (accepted conns
|
||||
# inherit it), then hand it to uvicorn. ``sock`` injection: the
|
||||
# gap-free restart scenarios (roster-restart-native) bind a
|
||||
# placeholder BEFORE stopping the prior node and hand it in here —
|
||||
# a failed EventSource reconnect attempt is TERMINAL per WHATWG
|
||||
# (fail-the-connection → CLOSED, no further retries), so the
|
||||
# native-retry leg must never observe a refused-window; the
|
||||
# placeholder's listen backlog completes the TCP handshake during
|
||||
# the boot and uvicorn drains it once serving.
|
||||
self._sock = sock if sock is not None else make_listen_socket(port, sndbuf=sndbuf)
|
||||
self._port = int(self._sock.getsockname()[1])
|
||||
|
||||
# -- fault injection (public knobs below) ----------------------------
|
||||
# In-process arming: the Tier-2 runner holds this RecoveryServer and
|
||||
# arms a knob, THEN drives the browser request that consumes it.
|
||||
# Single-writer by construction — the runner never arms a knob while
|
||||
# the loop thread is mid-consume — and CPython makes each int read /
|
||||
# write atomic, so these need no lock even though the uvicorn loop
|
||||
# thread increments/decrements them while the runner thread reads.
|
||||
self.history_requests = 0
|
||||
# /history responses the PRODUCTION route answered 200 (not the
|
||||
# fault layer's injected 500s). See _fault_app for why arrival
|
||||
# counting cannot substitute.
|
||||
self.history_ok = 0
|
||||
self.rewind_requests = 0
|
||||
# Per-ws SSE connection opens (``GET …/events`` — the EventSource the
|
||||
# pane's connectSSE builds). A TRANSPORT-FREE heal (the #890 idle-edge
|
||||
# staleness backstop, a quiesced REST refetch) must leave this FLAT; a
|
||||
# reload-based backstop would bump it once per reconnect (the round-5
|
||||
# storm). Same lock-free single-writer int discipline as above.
|
||||
self.events_requests = 0
|
||||
# Global-lane SSE connection opens (``GET …/events/global`` — the
|
||||
# roster stream app.js's connectGlobalSSE builds). The
|
||||
# roster-restart scenario (#881) asserts the post-restart manual
|
||||
# reconnect actually reached the reborn node's real endpoint.
|
||||
self.global_events_requests = 0
|
||||
self._history_fail_remaining = 0
|
||||
self._history_delay_ms = 0
|
||||
# A thin pure-ASGI fault layer wrapping the REAL app (the production
|
||||
# app itself is untouched): count + optionally delay/fail
|
||||
# ``GET …/history``, count ``POST …/rewind``, count each per-ws SSE
|
||||
# connection open (``GET …/events``), forward everything else (SSE
|
||||
# bodies, /send, lifespan, static) verbatim.
|
||||
production_app = self._app
|
||||
|
||||
async def _fault_app(scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") == "http":
|
||||
path = scope.get("path", "")
|
||||
method = scope.get("method", "")
|
||||
if path.endswith("/history") and method == "GET":
|
||||
# ARRIVAL, never move. Scenarios that hold a request open
|
||||
# use this bump as the IN-FLIGHT edge (E6/E7/G1/G7 say so
|
||||
# at their poll sites); counting on forward instead would
|
||||
# delay it past the hold and silently stop those scenarios
|
||||
# testing anything.
|
||||
self.history_requests += 1
|
||||
if self._history_delay_ms > 0:
|
||||
await asyncio.sleep(self._history_delay_ms / 1000.0)
|
||||
if self._history_fail_remaining > 0:
|
||||
self._history_fail_remaining -= 1
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 500,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": b'{"error": "injected"}'})
|
||||
return
|
||||
|
||||
# Successful-RESPONSE counter, distinct from the arrival
|
||||
# bump above. A scenario asserting that a render was
|
||||
# DECLINED needs to know a good payload actually existed —
|
||||
# otherwise "the client refused to render" and "there was
|
||||
# nothing to render" produce identical observables (no
|
||||
# wipe, latch held). Arrival cannot prove that, and
|
||||
# neither can an injected-fail budget: a PRODUCTION-side
|
||||
# 500/404 would slip through both. Reading the real
|
||||
# status off the response start is the only honest signal.
|
||||
async def _counting_send(message: MutableMapping[str, Any]) -> None:
|
||||
if (
|
||||
message.get("type") == "http.response.start"
|
||||
and message.get("status") == 200
|
||||
):
|
||||
self.history_ok += 1
|
||||
await send(message)
|
||||
|
||||
await production_app(scope, receive, _counting_send)
|
||||
return
|
||||
elif path.endswith("/rewind") and method == "POST":
|
||||
self.rewind_requests += 1
|
||||
elif path.endswith("/events") and method == "GET":
|
||||
# Per-ws SSE connection open — count it (readable on
|
||||
# RecoveryServer) and forward the long-lived stream
|
||||
# verbatim below. Uniquely the per-ws stream: the global
|
||||
# lane is ``…/events/global`` (ends ``/global``), and the
|
||||
# route the pane's EventSource hits is
|
||||
# ``…/workstreams/{ws_id}/events`` (session_routes).
|
||||
self.events_requests += 1
|
||||
elif path.endswith("/events/global") and method == "GET":
|
||||
self.global_events_requests += 1
|
||||
await production_app(scope, receive, send)
|
||||
|
||||
# ``timeout_graceful_shutdown``: an SSE stream that is still open
|
||||
# at ``stop()`` would otherwise park uvicorn's graceful drain
|
||||
# indefinitely (the 20s thread-join just expires and the browser
|
||||
# stays attached to the zombie server — the roster-restart-native
|
||||
# scenario is the one caller that stops a node mid-stream). A
|
||||
# bounded drain force-closes the stream after 2s and the lifespan
|
||||
# shutdown (#885's daemon-thread teardown) still runs after it.
|
||||
self._server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
_fault_app,
|
||||
log_level="warning",
|
||||
lifespan="on",
|
||||
timeout_graceful_shutdown=2,
|
||||
)
|
||||
)
|
||||
self._thread = threading.Thread(
|
||||
target=self._serve, name=f"uvicorn-recovery-{self._port}", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
if not _tcp_ready(self._port, 10.0):
|
||||
self.stop()
|
||||
raise AssertionError("recovery server did not accept TCP")
|
||||
|
||||
self._token = create_jwt(
|
||||
user_id="recovery-user",
|
||||
scopes=frozenset({"read", "write", "approve", "service"}),
|
||||
source="recovery",
|
||||
secret=_JWT_SECRET,
|
||||
audience=JWT_AUD_SERVER,
|
||||
)
|
||||
self._http = httpx.Client(base_url=self.base_url, timeout=httpx.Timeout(30.0))
|
||||
|
||||
def _serve(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(self._server.serve(sockets=[self._sock]))
|
||||
finally:
|
||||
pending = asyncio.all_tasks(loop)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
with contextlib.suppress(Exception):
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
loop.close()
|
||||
|
||||
# -- properties ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self._port}"
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return self._token
|
||||
|
||||
@property
|
||||
def manager(self) -> SessionManager:
|
||||
return self._manager
|
||||
|
||||
# -- workstream lifecycle ------------------------------------------------
|
||||
|
||||
def create_workstream(self, *scripts: Any, name: str = "recovery-ws") -> str:
|
||||
"""Create a ws whose scripted LLM follows ``scripts`` (positional
|
||||
:func:`fake_chat_stream` kwargs). Auto-approves tools and suppresses
|
||||
the auto-title call so the positional script stays in sync."""
|
||||
self._pending_client = _fake_client(scripts)
|
||||
ws = self._manager.create(user_id="recovery-user", name=name)
|
||||
self._prime_ws(ws)
|
||||
return ws.id
|
||||
|
||||
def open_workstream(self, ws_id: str, *scripts: Any) -> None:
|
||||
"""Rehydrate a persisted ws on THIS node (the restart path). Fresh
|
||||
UI → empty ring + storage-seeded ``_event_id``."""
|
||||
if scripts:
|
||||
self._pending_client = _fake_client(scripts)
|
||||
ws = self._manager.open(ws_id)
|
||||
if ws is None:
|
||||
raise AssertionError(f"open_workstream: ws {ws_id} not resurrectable")
|
||||
self._prime_ws(ws)
|
||||
|
||||
def _prime_ws(self, ws: Workstream) -> None:
|
||||
if isinstance(ws.ui, SessionUIBase):
|
||||
ws.ui.auto_approve = True # blanket tool auto-approval
|
||||
if ws.session is not None:
|
||||
ws.session._title_generated = True # suppress the auto-title LLM call
|
||||
|
||||
def send(self, ws_id: str, message: str = "go") -> None:
|
||||
"""POST /send — spawns the worker thread and returns immediately."""
|
||||
r = self._http.post(
|
||||
f"/v1/api/workstreams/{ws_id}/send",
|
||||
headers={"Authorization": f"Bearer {self._token}"},
|
||||
json={"message": message},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
def wait_turn(self, ws_id: str, *, timeout: float = 45.0) -> None:
|
||||
"""Block until the turn's worker thread finishes (the true
|
||||
turn-complete barrier) and the ws is idle."""
|
||||
deadline = time.monotonic() + timeout
|
||||
worker: threading.Thread | None = None
|
||||
while time.monotonic() < deadline:
|
||||
ws = self._manager.get(ws_id)
|
||||
worker = ws.worker_thread if ws is not None else None
|
||||
if worker is not None:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
if worker is not None:
|
||||
worker.join(timeout=max(0.5, deadline - time.monotonic()))
|
||||
if worker.is_alive():
|
||||
raise AssertionError(f"turn worker for {ws_id} did not finish in {timeout}s")
|
||||
|
||||
def get_ws(self, ws_id: str) -> Workstream | None:
|
||||
return self._manager.get(ws_id)
|
||||
|
||||
def ws_state(self, ws_id: str) -> str:
|
||||
ws = self._manager.get(ws_id)
|
||||
return ws.state.value if ws is not None else ""
|
||||
|
||||
def ring_span(self, ws_id: str) -> tuple[int | None, int]:
|
||||
"""(earliest retained ring event_id or None, latest counter) — lets a
|
||||
scenario wait for the ring to evict a specific cursor."""
|
||||
ws = self._manager.get(ws_id)
|
||||
ui = ws.ui if ws is not None else None
|
||||
if not isinstance(ui, SessionUIBase):
|
||||
return None, 0
|
||||
buf = ui._event_buffer
|
||||
earliest = buf[0][0] if buf else None
|
||||
return earliest, ui._event_id
|
||||
|
||||
def listener_poisoned(self, ws_id: str) -> bool:
|
||||
"""True once any live SSE listener on the ws has poisoned (overflow)."""
|
||||
ws = self._manager.get(ws_id)
|
||||
ui = ws.ui if ws is not None else None
|
||||
if not isinstance(ui, SessionUIBase):
|
||||
return False
|
||||
return any(getattr(q, "poisoned", False) for q in list(ui._listeners))
|
||||
|
||||
def max_event_id(self, ws_id: str) -> int | None:
|
||||
"""The storage high-water ``MAX(conversations.event_id)`` — what a
|
||||
restarted node's fresh UI seeds ``_event_id`` from."""
|
||||
result: int | None = get_storage().get_max_event_id(ws_id)
|
||||
return result
|
||||
|
||||
def fetch_history(self, ws_id: str) -> dict[str, Any]:
|
||||
r = self._http.get(
|
||||
f"/v1/api/workstreams/{ws_id}/history",
|
||||
headers={"Authorization": f"Bearer {self._token}"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
result: dict[str, Any] = r.json()
|
||||
return result
|
||||
|
||||
# -- fault-injection knobs -----------------------------------------------
|
||||
# Armed in-process by the Tier-2 runner (single writer at a time — see
|
||||
# __init__). A plain int is deliberate: CPython makes the loop thread's
|
||||
# increment/decrement and the runner thread's read each atomic, and the
|
||||
# arm-then-consume ordering means they never race.
|
||||
|
||||
def delay_load(self, ms: int) -> None:
|
||||
"""Hold ``storage.load_messages`` itself open for ``ms`` (0 = off).
|
||||
|
||||
``delay_history`` sleeps in the FAULT LAYER — before the route —
|
||||
so two delayed requests never overlap inside the #884 flight
|
||||
machinery (the first flight completes and pops before the second
|
||||
arrives at the route). This knob sleeps INSIDE the shared
|
||||
reconstruction's ``load_messages`` (sync, called via
|
||||
``asyncio.to_thread`` — the sleep parks only that worker), which
|
||||
is the same layer the unit tests gate, so held flights genuinely
|
||||
overlap and join/miss behavior is observable end to end via
|
||||
``load_calls``.
|
||||
"""
|
||||
self._load_delay_ms = ms
|
||||
|
||||
@property
|
||||
def load_calls(self) -> int:
|
||||
"""``load_messages`` entries (pre-sleep) — the flight-layer twin
|
||||
of ``history_requests`` (which counts HTTP arrivals): a JOINED
|
||||
request never enters ``load_messages``, so join=1 / miss=2."""
|
||||
return self._load_calls
|
||||
|
||||
def fail_history(self, count: int) -> None:
|
||||
"""Make the next ``count`` ``GET …/history`` responses a 500 — the
|
||||
failed refetch the #890 guard-before-wipe must survive."""
|
||||
self._history_fail_remaining = count
|
||||
|
||||
def delay_history(self, ms: int) -> None:
|
||||
"""Hold each ``GET …/history`` ``ms`` ms before forwarding (0
|
||||
clears). Opens the clear_ui-refetch quiesce window that the row
|
||||
affordance gate (``busy || _historyStale``) must close."""
|
||||
self._history_delay_ms = ms
|
||||
|
||||
@property
|
||||
def history_fail_remaining(self) -> int:
|
||||
"""Unconsumed forced-failure budget — 0 proves the armed failure
|
||||
actually fired (assert backend state, never scripted absence)."""
|
||||
return self._history_fail_remaining
|
||||
|
||||
# -- teardown ------------------------------------------------------------
|
||||
|
||||
def stop(self, *, hard: bool = False) -> None:
|
||||
"""Stop the node.
|
||||
|
||||
``hard=True`` skips the per-workstream ``manager.close`` sweep — a
|
||||
graceful close routes through ``cleanup_session_ui`` →
|
||||
``session.cancel()``, whose bash cancel path PERSISTS a
|
||||
synthesized "Cancelled by user" result while the old node is
|
||||
still alive, which masks crash states. A hard stop leaves any
|
||||
in-flight tool call genuinely unresulted in storage, modelling a
|
||||
SIGKILL/OOM death (the coord-orphan-rewind scenario's premise).
|
||||
The 2s graceful-shutdown timeout (uvicorn config) force-closes
|
||||
open SSE streams, and the lifespan teardown still runs, so the
|
||||
#885 daemon threads are joined on both paths.
|
||||
"""
|
||||
if not hard:
|
||||
with contextlib.suppress(Exception):
|
||||
for ws in list(self._manager.list_all()):
|
||||
with contextlib.suppress(Exception):
|
||||
self._manager.close(ws.id)
|
||||
# hard=True relies on ``timeout_graceful_shutdown=2`` (set in the
|
||||
# uvicorn config above) to force-close the pane's EventSource:
|
||||
# should_exit alone still runs the ASGI lifespan teardown, so the
|
||||
# #885 daemon threads and the sse_executor are joined either way
|
||||
# (``force_exit`` would SKIP the lifespan and leak them — the
|
||||
# fanout thread blocks on queue.get() forever). NOTE: the killed
|
||||
# workstream's in-flight tool keeps executing on this process's
|
||||
# session thread and persists its result at natural completion —
|
||||
# hard-kill scenarios must use a paced tool that outlives their
|
||||
# observation window.
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=20)
|
||||
with contextlib.suppress(Exception):
|
||||
self._http.close()
|
||||
with contextlib.suppress(OSError):
|
||||
self._sock.close()
|
||||
# Restore any patched cap defaults.
|
||||
for meth, defaults in self._orig_defaults:
|
||||
meth.__defaults__ = defaults
|
||||
# Restore the storage singleton's load_messages (delay_load knob).
|
||||
with contextlib.suppress(Exception):
|
||||
self._patched_storage.load_messages = self._orig_load_messages # type: ignore[method-assign]
|
||||
|
||||
|
||||
def make_listen_socket(port: int, *, sndbuf: int = _DEFAULT_SNDBUF) -> socket.socket:
|
||||
"""Bound + listening socket the way :class:`RecoveryServer` binds its own.
|
||||
|
||||
``SO_REUSEPORT`` on every listener (same process, same uid) is what
|
||||
lets a restart scenario bind the successor's socket while the prior
|
||||
node still holds the port — the seam behind the gap-free handoff
|
||||
documented at the ``sock`` parameter. ``SO_SNDBUF`` matches the
|
||||
server's small send buffer so accepted connections inherit identical
|
||||
backpressure behavior regardless of which side bound the socket.
|
||||
"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, sndbuf)
|
||||
s.bind(("127.0.0.1", port)) # port=0 -> ephemeral; fixed -> restart reuse
|
||||
s.listen(128)
|
||||
return s
|
||||
|
||||
|
||||
def _tcp_ready(port: int, timeout: float) -> bool:
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def bash_toolcall_script(
|
||||
call_id: str, command: str, *, finish_reason: str = "tool_calls"
|
||||
) -> dict[str, Any]:
|
||||
"""A scripted assistant turn issuing ONE bash tool call."""
|
||||
return dict(
|
||||
tool_calls=[{"id": call_id, "name": "bash", "arguments": json.dumps({"command": command})}],
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
|
||||
def parallel_bash_script(commands: dict[str, str]) -> dict[str, Any]:
|
||||
"""A scripted assistant turn issuing SEVERAL bash tool calls at once
|
||||
(the parallel-pool storm), ``{call_id: command}``.
|
||||
|
||||
Each command is prefixed with a no-op ``: <call_id>;`` so the tool
|
||||
ARGUMENTS are distinct per call while the OUTPUT is unchanged (``:``
|
||||
ignores its args and prints nothing). Identical-argument parallel
|
||||
calls otherwise trip the session's repeat-tool-call guard, which
|
||||
appends a warning to the PERSISTED result only (not the live event) —
|
||||
an orthogonal divergence that would mask the recovery behavior the
|
||||
convergence assertions test.
|
||||
"""
|
||||
return dict(
|
||||
tool_calls=[
|
||||
{
|
||||
"id": cid,
|
||||
"name": "bash",
|
||||
"arguments": json.dumps({"command": f": {cid}; {cmd}"}),
|
||||
}
|
||||
for cid, cmd in commands.items()
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
|
||||
def final_text_script(content: str = "done") -> dict[str, Any]:
|
||||
"""The scripted assistant turn that ends the agent loop (no tools)."""
|
||||
return dict(content=content, finish_reason="stop")
|
||||
@@ -4,9 +4,6 @@ import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -219,98 +216,6 @@ def _seed_static_state(mgr: MCPClientManager, name: str, **overrides: Any) -> St
|
||||
return state
|
||||
|
||||
|
||||
def _run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any, timeout: float = 10) -> Any:
|
||||
"""Submit *coro* to *loop*, wait for the result.
|
||||
|
||||
The ONE copy shared by the MCP test files — four hand-synced copies
|
||||
had already drifted on the timeout (5s hardcoded vs a 10s default).
|
||||
The timeout is an upper bound on waiting, not a behavior assertion,
|
||||
so the most generous variant won the merge.
|
||||
"""
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
|
||||
def _drain_background(mgr: MCPClientManager, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Deterministically await ``mgr``'s tracked background tasks.
|
||||
|
||||
Replaces fixed sleeps for synchronizing with scheduled dead-grant
|
||||
drops / spawned refreshes: exact, and immune to slow-runner flake.
|
||||
"""
|
||||
|
||||
async def _drain() -> None:
|
||||
tasks = [t for t in list(mgr._background_tasks) if not t.done()]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
_run_on_loop(loop, _drain())
|
||||
|
||||
|
||||
def _poll_until(predicate: Callable[[], bool], timeout: float, interval: float = 0.05) -> bool:
|
||||
"""Poll *predicate* until true or *timeout* elapses — the ONE wait loop.
|
||||
|
||||
Shared by the live MCP smoke tests' condition helpers so the
|
||||
deadline/poll pattern doesn't accrete per-file hand-synced copies.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Grab an ephemeral localhost port for a live-server subprocess.
|
||||
|
||||
Shared by the live MCP smoke tests (flaky-server, push-refresh) so
|
||||
the socket-probe helpers stay in one place instead of drifting per
|
||||
file.
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
def _tcp_accepts(port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_tcp_ready(port: int, timeout: float) -> bool:
|
||||
"""Poll until something accepts TCP on 127.0.0.1:*port* (live tests)."""
|
||||
return _poll_until(lambda: _tcp_accepts(port), timeout)
|
||||
|
||||
|
||||
def _wait_session_live(mgr: MCPClientManager, name: str, timeout: float) -> bool:
|
||||
"""Poll until static server *name* has a live session (live tests)."""
|
||||
|
||||
def _live() -> bool:
|
||||
state = mgr._static_servers.get(name)
|
||||
return state is not None and state.session is not None
|
||||
|
||||
return _poll_until(_live, timeout)
|
||||
|
||||
|
||||
def _popen_mcp_server(script_path: Any, port: int) -> subprocess.Popen[bytes]:
|
||||
"""Start a FastMCP live-server subprocess, streams to DEVNULL.
|
||||
|
||||
The shared spawn primitive for the live MCP smoke tests
|
||||
(flaky-server flap loop, push-refresh) — the readiness wait and the
|
||||
skip-vs-raise-on-failure policy legitimately differ per test and
|
||||
stay at the call sites. ``sys.executable`` runs the same interpreter,
|
||||
so a server-side import gap surfaces as a failed TCP wait, not here.
|
||||
"""
|
||||
return subprocess.Popen(
|
||||
[sys.executable, str(script_path), str(port)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def make_oidc_test_config(**overrides: Any) -> OIDCConfig:
|
||||
"""Build a test ``OIDCConfig`` with sensible defaults.
|
||||
|
||||
@@ -430,40 +335,6 @@ def mock_openai_client():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_config_store():
|
||||
"""Factory for a lightweight ConfigStore double.
|
||||
|
||||
``make_config_store(**overrides)`` returns an object whose ``.get(key)``
|
||||
yields the override when present, else the registered SettingDef default —
|
||||
mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads
|
||||
as its ``False`` default on a miss, never ``None``). Shared by the
|
||||
``server.require_project`` gate / advisory tests.
|
||||
"""
|
||||
|
||||
_unset = object()
|
||||
|
||||
def _make(**overrides: Any) -> Any:
|
||||
from turnstone.core.settings_registry import SETTINGS
|
||||
|
||||
class _ConfigStoreDouble:
|
||||
def get(self, key: str, default: Any = _unset) -> Any:
|
||||
# Mirror ConfigStore.get precedence exactly: cache (overrides)
|
||||
# first, then a caller-supplied default, then the registry
|
||||
# default, then None — so a reused caller passing an explicit
|
||||
# default for an unset key gets the same value production would.
|
||||
if key in overrides:
|
||||
return overrides[key]
|
||||
if default is not _unset:
|
||||
return default
|
||||
defn = SETTINGS.get(key)
|
||||
return defn.default if defn else None
|
||||
|
||||
return _ConfigStoreDouble()
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_policy_cache():
|
||||
"""Drop the in-process tool-policy cache between tests.
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b"
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"system": "Output-guard: deploy output looked clean.",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b"
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
}
|
||||
],
|
||||
"model": "qwen3.6-27b",
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Look up the weather for a city.",
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"system": "Output-guard: deploy output looked clean.",
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-sonnet-4-6",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"temperature": 1.0,
|
||||
"thinking": {
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
],
|
||||
"model": "claude-opus-4-8",
|
||||
"output_config": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"thinking": {
|
||||
"display": "summarized",
|
||||
"type": "adaptive"
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gemini-2.5-pro",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -34,10 +34,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
}
|
||||
},
|
||||
"temperature": 0.5
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
}
|
||||
],
|
||||
"model": "gpt-4o-mini",
|
||||
"reasoning_effort": "medium",
|
||||
"stream": true,
|
||||
"stream_options": {
|
||||
"include_usage": true
|
||||
},
|
||||
"temperature": 0.5,
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"max_output_tokens": 4096,
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_retention": "24h",
|
||||
"reasoning": {
|
||||
"effort": "medium"
|
||||
},
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"tools": [
|
||||
|
||||
+51
-1186
File diff suppressed because it is too large
Load Diff
@@ -128,72 +128,6 @@ def test_stdout_streams_to_ui_from_drain_thread():
|
||||
assert any("streamed-line" in c for c in chunks)
|
||||
|
||||
|
||||
def test_leaked_drain_stops_emitting_chunks_after_return(tmp_path):
|
||||
"""A double-``setsid`` grandchild escapes the session-group kill and
|
||||
holds the stdout pipe open past the drain join (``bash.drain_leaked``)
|
||||
— the leaked drain thread must STOP forwarding chunks to the UI once
|
||||
``_exec_bash`` returns (the ``emit_done`` gate). Without the gate,
|
||||
its lines land in later turns' panes: the UI batches per call_id,
|
||||
some providers reuse call_ids across turns, and the client grafts
|
||||
stray chunks under the completed row.
|
||||
|
||||
The grandchild's writer loop is time-bounded (~8s) so even a failed
|
||||
cleanup cannot outlive the test session, and the finally kills it so
|
||||
the drain thread hits EOF before the leaked-thread guard sweeps.
|
||||
"""
|
||||
pidfile = str(tmp_path / "leak.pid")
|
||||
chunks: list[str] = []
|
||||
|
||||
class RecordingUI(NullUI):
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
chunks.append(chunk)
|
||||
|
||||
session = make_session(tool_timeout=30, ui=RecordingUI())
|
||||
# setsid detaches the grandchild from the session group (killpg
|
||||
# misses it); it inherits our stdout pipe and keeps writing.
|
||||
command = (
|
||||
f"setsid bash -c 'echo $$ > {pidfile}; "
|
||||
"for i in $(seq 1 80); do echo leak-$i; sleep 0.1; done' & echo fg-done"
|
||||
)
|
||||
leak_pid = None
|
||||
try:
|
||||
finished, result = _run_in_thread(
|
||||
lambda: session._exec_bash({"call_id": "c1", "command": command}),
|
||||
timeout=20,
|
||||
)
|
||||
assert finished, "_exec_bash hung on the escaped grandchild"
|
||||
assert result is not None
|
||||
_call_id, output = result
|
||||
assert "fg-done" in output
|
||||
# The call has RETURNED (gate set). The grandchild is still
|
||||
# writing; give its lines time to traverse the leaked drain.
|
||||
seen_at_return = len(chunks)
|
||||
time.sleep(1.0)
|
||||
# ``<= +1``: the gate documents a one-line residual race (a line
|
||||
# already past the is_set() check when the event sets). A broken
|
||||
# gate keeps forwarding ~10 lines/sec and still fails loudly.
|
||||
assert len(chunks) <= seen_at_return + 1, (
|
||||
"leaked drain thread kept forwarding chunks to the UI after "
|
||||
"the tool returned — the emit_done gate is not holding"
|
||||
)
|
||||
finally:
|
||||
deadline = time.monotonic() + 5
|
||||
while leak_pid is None and time.monotonic() < deadline:
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
leak_pid = int(f.read().strip())
|
||||
except (FileNotFoundError, ValueError):
|
||||
time.sleep(0.05)
|
||||
if leak_pid is not None:
|
||||
_kill_pid(leak_pid)
|
||||
# Let the drain thread hit EOF before the leaked-thread
|
||||
# guard sweeps the test's thread table.
|
||||
deadline = time.monotonic() + 5
|
||||
while _pid_alive(leak_pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def test_cancel_midbash_reports_unknown():
|
||||
"""An external ``cancel()`` during a running bash unblocks the process-bounded
|
||||
wait and reports UNKNOWN (unknown-never-none), not a clean result."""
|
||||
|
||||
@@ -9,18 +9,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
# discord.utils.escape_markdown passes 'count' as positional to re.sub,
|
||||
# which is deprecated in Python 3.13+, and discord.Client's event
|
||||
# registration calls asyncio.iscoroutinefunction, deprecated in 3.14+.
|
||||
# Both are discord.py bugs (fixed in newer releases); suppress here to
|
||||
# keep the test output clean.
|
||||
pytestmark = [
|
||||
pytest.mark.filterwarnings(
|
||||
"ignore:.*'count' is passed as positional argument:DeprecationWarning"
|
||||
),
|
||||
pytest.mark.filterwarnings(
|
||||
"ignore:.*'asyncio.iscoroutinefunction' is deprecated:DeprecationWarning"
|
||||
),
|
||||
]
|
||||
# which is deprecated in Python 3.13+. This is a discord.py bug (fixed
|
||||
# in newer releases); suppress here to keep the test output clean.
|
||||
pytestmark = pytest.mark.filterwarnings(
|
||||
"ignore:.*'count' is passed as positional argument:DeprecationWarning"
|
||||
)
|
||||
|
||||
discord = pytest.importorskip("discord")
|
||||
|
||||
@@ -137,66 +130,6 @@ class TestDiscordConfig:
|
||||
assert cfg.auto_approve is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowed mentions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_real_bot():
|
||||
"""Construct a TurnstoneBot around a real ``commands.Bot`` (no gateway).
|
||||
|
||||
Storage is mocked and the module's httpx client is patched out; the
|
||||
underlying discord client object is real so tests can assert against
|
||||
the actual connection-state defaults every message create inherits.
|
||||
"""
|
||||
from turnstone.channels.discord.bot import TurnstoneBot
|
||||
from turnstone.channels.discord.config import DiscordConfig
|
||||
|
||||
with patch("turnstone.channels.discord.bot.httpx.AsyncClient", return_value=AsyncMock()):
|
||||
return TurnstoneBot(
|
||||
DiscordConfig(bot_token="token-test"), "http://localhost:8080", MagicMock()
|
||||
)
|
||||
|
||||
|
||||
class TestAllowedMentions:
|
||||
"""Approval headers and previews carry user/model text verbatim, so the
|
||||
client-level allowed_mentions default must suppress every mention
|
||||
resolution (@everyone/@here, users, roles) on the wire."""
|
||||
|
||||
def test_client_default_resolves_to_none(self):
|
||||
bot = _make_real_bot()
|
||||
|
||||
am = bot._bot.allowed_mentions
|
||||
assert am is not None
|
||||
# AllowedMentions has no value equality — compare the wire form.
|
||||
assert am.to_dict() == {"parse": []}
|
||||
|
||||
def test_message_create_inherits_client_default(self):
|
||||
"""A message create with no per-send override carries the default.
|
||||
|
||||
``abc.Messageable.send`` assembles its payload via
|
||||
``handle_message_parameters(previous_allowed_mentions=state.allowed_mentions)``;
|
||||
drive the same assembly with the client's state value and assert
|
||||
the wire payload pins ``allowed_mentions`` to parse-nothing while
|
||||
the hostile text itself stays verbatim.
|
||||
"""
|
||||
from discord.http import handle_message_parameters
|
||||
|
||||
bot = _make_real_bot()
|
||||
state_default = bot._bot._connection.allowed_mentions
|
||||
assert state_default is not None
|
||||
|
||||
hostile = "@everyone @here <@123456789012345678> <@&987654321098765432>"
|
||||
with handle_message_parameters(
|
||||
content=hostile,
|
||||
previous_allowed_mentions=state_default,
|
||||
) as params:
|
||||
payload = params.payload
|
||||
assert payload is not None
|
||||
assert payload["allowed_mentions"] == {"parse": []}
|
||||
assert payload["content"] == hostile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -320,160 +320,6 @@ class TestPreviewSanitization:
|
||||
assert out == "short and clean"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mrkdwn field escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMrkdwnFieldEscaping:
|
||||
"""User/model-authored fields must reach the Slack wire entity-escaped
|
||||
while the bot's own mrkdwn framing stays live."""
|
||||
|
||||
def test_approval_card_escapes_hostile_fields(self) -> None:
|
||||
from turnstone.channels.slack.routes import SlackRoute
|
||||
from turnstone.sdk.events import ApproveRequestEvent
|
||||
|
||||
bot, _router, client = _make_bot()
|
||||
|
||||
event = ApproveRequestEvent(
|
||||
ws_id="ws-1",
|
||||
cycle_id="cyc-1",
|
||||
items=[
|
||||
{
|
||||
"call_id": "c-1",
|
||||
"func_name": "tasks",
|
||||
"approval_label": "@everyone <cmd>",
|
||||
"preview": "title=<!channel> ping <@U123> a & b <tag>",
|
||||
"needs_approval": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
route = SlackRoute(channel="C01SAPU5414", user_id="U9", thread_ts="1.2")
|
||||
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
||||
|
||||
client.chat_postMessage.assert_awaited_once()
|
||||
body = client.chat_postMessage.call_args[1]["blocks"][0]["text"]["text"]
|
||||
# Broadcast keywords, raw mention syntax, and entities arrive escaped.
|
||||
assert "<!channel>" in body
|
||||
assert "<@U123>" in body
|
||||
assert "a & b <tag>" in body
|
||||
assert "<cmd>" in body
|
||||
assert "<!channel>" not in body
|
||||
assert "<@U123>" not in body
|
||||
# A literal @everyone is inert outside angle brackets — kept as-is.
|
||||
assert "@everyone" in body
|
||||
# Bot-authored framing stays live mrkdwn.
|
||||
assert body.startswith("*Tool Approval Required*")
|
||||
assert "```" in body
|
||||
|
||||
def test_policy_deny_notice_escapes_names_but_feedback_stays_verbatim(self) -> None:
|
||||
from turnstone.channels._routing import PolicyVerdict
|
||||
from turnstone.channels.slack.routes import SlackRoute
|
||||
from turnstone.sdk.events import ApproveRequestEvent
|
||||
|
||||
bot, router, client = _make_bot()
|
||||
router.evaluate_tool_policies = AsyncMock(
|
||||
return_value=PolicyVerdict(kind="deny", denied_tools=["evil<!channel>tool"])
|
||||
)
|
||||
|
||||
event = ApproveRequestEvent(
|
||||
ws_id="ws-1",
|
||||
cycle_id="cyc-1",
|
||||
items=[{"call_id": "c-1", "func_name": "evil<!channel>tool", "needs_approval": True}],
|
||||
)
|
||||
route = SlackRoute(channel="C01SAPU5414", user_id="U9", thread_ts="1.2")
|
||||
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
||||
|
||||
# Slack-rendered notice is escaped...
|
||||
text = client.chat_postMessage.call_args[1]["text"]
|
||||
assert "evil<!channel>tool" in text
|
||||
assert "<!channel>" not in text
|
||||
# ...but the feedback routed back to the server stays verbatim —
|
||||
# projection happens per audience at render, never upstream.
|
||||
router.send_approval.assert_awaited_once_with(
|
||||
"ws-1",
|
||||
"cyc-1",
|
||||
approved=False,
|
||||
feedback="Blocked by tool policy: evil<!channel>tool",
|
||||
)
|
||||
|
||||
def test_intent_verdict_escapes_judge_fields(self) -> None:
|
||||
from turnstone.channels.slack.bot import PendingApproval
|
||||
from turnstone.channels.slack.routes import SlackRoute
|
||||
from turnstone.sdk.events import IntentVerdictEvent
|
||||
|
||||
bot, _router, client = _make_bot()
|
||||
bot._pending_approval[("ws-1", "cyc-1")] = PendingApproval( # type: ignore[attr-defined]
|
||||
channel="C1",
|
||||
message_ts="999.000",
|
||||
owner_user_id="U9",
|
||||
cycle_id="cyc-1",
|
||||
call_ids=frozenset({"c-1"}),
|
||||
)
|
||||
|
||||
event = IntentVerdictEvent(
|
||||
ws_id="ws-1",
|
||||
call_id="c-1",
|
||||
func_name="bash <!everyone>",
|
||||
risk_level="high",
|
||||
confidence=0.9,
|
||||
intent_summary="pings <!channel> & <@U123>",
|
||||
)
|
||||
route = SlackRoute(channel="C1", user_id="U9", thread_ts="1.2")
|
||||
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
||||
|
||||
client.chat_update.assert_awaited_once()
|
||||
blocks = client.chat_update.call_args[1]["blocks"]
|
||||
verdict_text = blocks[-1]["text"]["text"]
|
||||
assert "bash <!everyone>" in verdict_text
|
||||
assert "pings <!channel> & <@U123>" in verdict_text
|
||||
assert "<!channel>" not in verdict_text
|
||||
assert "<!everyone>" not in verdict_text
|
||||
# Bot framing survives around the escaped fields.
|
||||
assert verdict_text.startswith("*Judge Verdict: ")
|
||||
|
||||
def test_error_event_escapes_hostile_message(self) -> None:
|
||||
from turnstone.channels.slack.routes import SlackRoute
|
||||
from turnstone.sdk.events import ErrorEvent
|
||||
|
||||
bot, _router, client = _make_bot()
|
||||
|
||||
event = ErrorEvent(ws_id="ws-1", message="boom <!channel> & <@U123>")
|
||||
route = SlackRoute(channel="C1", user_id="U9", thread_ts="1.2")
|
||||
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
||||
|
||||
text = client.chat_postMessage.call_args[1]["text"]
|
||||
assert text.startswith("*Error:* ")
|
||||
assert "boom <!channel> & <@U123>" in text
|
||||
assert "<!channel>" not in text
|
||||
|
||||
def test_send_escapes_notification_content(self) -> None:
|
||||
bot, _router, client = _make_bot()
|
||||
|
||||
_run(
|
||||
bot.send( # type: ignore[attr-defined]
|
||||
"C01SAPU5414:U12345",
|
||||
"Task '<!channel> deploy & retry <now>' is idle",
|
||||
)
|
||||
)
|
||||
|
||||
text = client.chat_postMessage.call_args[1]["text"]
|
||||
assert "<!channel> deploy & retry <now>" in text
|
||||
assert "<!channel>" not in text
|
||||
|
||||
def test_session_opener_mention_survives_unescaped(self) -> None:
|
||||
"""The opener's ``<@user>`` mention is bot-authored mrkdwn at a
|
||||
trusted callsite — per-field escaping must never reach it."""
|
||||
bot, router, client = _make_bot()
|
||||
router.get_or_create_workstream = AsyncMock(return_value=("ws-new", True))
|
||||
|
||||
body = {"channel_id": "C01SAPU5414", "user_id": "U777", "text": ""}
|
||||
_run(bot._on_slash_command(AsyncMock(), body)) # type: ignore[attr-defined]
|
||||
|
||||
texts = [c.kwargs.get("text", "") for c in client.chat_postMessage.call_args_list]
|
||||
assert any("<@U777> started a turnstone session." in t for t in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -186,41 +186,6 @@ class TestDisplayPath:
|
||||
assert "SUMMARY" not in contents
|
||||
assert contents == ["q", "a"] # true transcript, no injected summary
|
||||
|
||||
def test_include_compaction_projects_marker_as_system_row(self, storage_backend):
|
||||
"""The /history display path (include_compaction=True) surfaces the
|
||||
marker IN PLACE as a first-class system row — source="compaction",
|
||||
meta = the marker's stored fields — so the UI re-renders its
|
||||
compaction card after a reload. Export/search (default False)
|
||||
stay on the drop path pinned above."""
|
||||
st = storage_backend
|
||||
ws = _register(st)
|
||||
st.save_message(ws, "user", "q")
|
||||
st.save_message(ws, "assistant", "a")
|
||||
wm = st.get_compaction_watermark(ws, 0)
|
||||
st.save_message(
|
||||
ws,
|
||||
"assistant",
|
||||
"SUMMARY",
|
||||
source="compaction",
|
||||
meta=json.dumps(
|
||||
{"watermark": wm, "before_tokens": 900, "after_tokens": 80, "trigger": "manual"}
|
||||
),
|
||||
)
|
||||
st.save_message(ws, "user", "later question")
|
||||
|
||||
msgs = st.load_messages(ws, include_compaction=True)
|
||||
assert [m.get("content") for m in msgs] == ["q", "a", "SUMMARY", "later question"]
|
||||
marker = msgs[2]
|
||||
assert marker["role"] == "system" # display row, not a fake assistant turn
|
||||
assert marker.get("_source") == "compaction"
|
||||
meta = marker.get("_source_meta")
|
||||
assert meta == {
|
||||
"watermark": wm,
|
||||
"before_tokens": 900,
|
||||
"after_tokens": 80,
|
||||
"trigger": "manual",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: compaction writes the marker, resume is bounded
|
||||
|
||||
@@ -25,7 +25,6 @@ summary turns are recognized.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -116,7 +115,7 @@ class TestSummaryTurnProvenance:
|
||||
session._generate_title()
|
||||
|
||||
uc.assert_called_once()
|
||||
prompt = uc.call_args[0][0][-1].text
|
||||
prompt = uc.call_args[0][0][-1]["content"]
|
||||
assert COMPACTION_SUMMARY_LABEL in prompt # titled FROM the real message
|
||||
|
||||
|
||||
@@ -382,282 +381,3 @@ class TestWindDownSpill:
|
||||
session._do_auto_compact(my_generation=3, carry_spill=True)
|
||||
assert cm.call_args.kwargs["carry_spill"] is True
|
||||
assert cm.call_args.kwargs["my_generation"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coordinator handles — the id↔meaning pairing crosses DETERMINISTICALLY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
TASKS = [
|
||||
{
|
||||
"id": "tsk_b4fbdb7d95a7",
|
||||
"title": "summarise the incident timeline",
|
||||
"status": "in_progress",
|
||||
"child_ws_id": "ws_a1b2c3d4",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"id": "tsk_0d1e2f3a4b5c",
|
||||
"title": "cut p99 latency to <200ms",
|
||||
"status": "needs_user",
|
||||
"child_ws_id": "",
|
||||
"note": "waiting on the change window",
|
||||
},
|
||||
]
|
||||
CHILDREN = [
|
||||
{"ws_id": "ws_a1b2c3d4", "name": "incident-timeline", "state": "running"},
|
||||
{"ws_id": "ws_e5f6a7b8", "name": "postmortem-draft", "state": "idle"},
|
||||
]
|
||||
|
||||
|
||||
def _coord_client(tasks=None, children=None) -> MagicMock:
|
||||
"""A coord client stubbed at the two in-process storage reads the handles
|
||||
block makes — the same calls ``_exec_tasks`` / ``_exec_list_workstreams``
|
||||
already make on the worker thread."""
|
||||
client = MagicMock()
|
||||
client.tasks_get.return_value = {"version": 1, "tasks": TASKS if tasks is None else tasks}
|
||||
client.list_children.return_value = {
|
||||
"children": CHILDREN if children is None else children,
|
||||
"truncated": False,
|
||||
}
|
||||
return client
|
||||
|
||||
|
||||
def _coord_session(mock_openai_client, *, coord_client=..., **kwargs):
|
||||
from turnstone.core.workstream import WorkstreamKind
|
||||
|
||||
return make_session(
|
||||
client=mock_openai_client,
|
||||
context_window=10_000,
|
||||
compact_max_tokens=100,
|
||||
max_tokens=1_000,
|
||||
tool_timeout=10,
|
||||
kind=WorkstreamKind.COORDINATOR,
|
||||
user_id="u1",
|
||||
coord_client=_coord_client() if coord_client is ... else coord_client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _compact(s, *, carry_spill: bool = False, summary: str = "DENSE") -> str:
|
||||
s.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "run the incident review"},
|
||||
{"role": "assistant", "content": "on it"},
|
||||
]
|
||||
)
|
||||
s._msg_tokens = [1, 1]
|
||||
with patch.object(s, "_utility_completion", return_value=_stub_summary(summary)):
|
||||
assert s._compact_messages(auto=True, carry_spill=carry_spill) is True
|
||||
return s.messages[1].text or ""
|
||||
|
||||
|
||||
class TestCoordinatorHandles:
|
||||
"""The pairing an id needs — task id↔title↔status, child id↔name↔state —
|
||||
is written by the HARNESS from storage, not transcribed by the summarizer.
|
||||
|
||||
The load-bearing test is ``test_pairing_survives_a_summary_that_has_no
|
||||
_ids``: the summarizer returns text containing no id at all (the density
|
||||
failure this exists for), and every id still crosses.
|
||||
"""
|
||||
|
||||
def test_pairing_survives_a_summary_that_has_no_ids(self, tmp_db, mock_openai_client):
|
||||
s = _coord_session(mock_openai_client)
|
||||
text = _compact(s, summary="## Decisions\nreviewed the incident.")
|
||||
|
||||
assert "## Handles" in text
|
||||
# Both halves of every handle, and the ids character-for-character.
|
||||
for task in TASKS:
|
||||
assert task["id"] in text
|
||||
assert task["title"] in text
|
||||
assert task["status"] in text
|
||||
for child in CHILDREN:
|
||||
assert child["ws_id"] in text
|
||||
assert child["name"] in text
|
||||
assert child["state"] in text
|
||||
assert "waiting on the change window" in text # the note says what is needed
|
||||
assert "→ child `ws_a1b2c3d4`" in text # task↔child linkage kept
|
||||
|
||||
def test_summarizer_is_never_asked_for_handles(self, tmp_db, mock_openai_client):
|
||||
"""The design decision, pinned: the compactor prompt is kind-INDEPENDENT.
|
||||
|
||||
Deterministic insertion is the whole point — a prompt section asking the
|
||||
model to transcribe ids would spend attention to get a fallible copy, and
|
||||
could contradict the block storage renders. If someone adds one, this
|
||||
fails and they must revisit that trade rather than stack both.
|
||||
"""
|
||||
coord = _coord_session(mock_openai_client)
|
||||
interactive = make_session(
|
||||
client=mock_openai_client, context_window=10_000, tool_timeout=10
|
||||
)
|
||||
prompts = []
|
||||
for s in (coord, interactive):
|
||||
s.messages = turns_from_dicts(
|
||||
[
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
]
|
||||
)
|
||||
s._msg_tokens = [1, 1]
|
||||
with patch.object(s, "_utility_completion", return_value=_stub_summary()) as uc:
|
||||
assert s._compact_messages(auto=True) is True
|
||||
prompts.append(uc.call_args[0][0][0].text)
|
||||
|
||||
assert prompts[0] == prompts[1]
|
||||
assert "Handles" not in prompts[0]
|
||||
|
||||
def test_interactive_session_never_reads_and_never_renders(self, tmp_db, mock_openai_client):
|
||||
"""Kind gate: an interactive session has no task envelope and no
|
||||
children, so the reads are skipped entirely — not merely rendered
|
||||
empty — and its summary is what it was before this existed."""
|
||||
client = _coord_client()
|
||||
s = make_session(
|
||||
client=mock_openai_client,
|
||||
context_window=10_000,
|
||||
compact_max_tokens=100,
|
||||
tool_timeout=10,
|
||||
coord_client=client, # present but irrelevant: kind decides
|
||||
)
|
||||
text = _compact(s)
|
||||
assert "## Handles" not in text
|
||||
client.tasks_get.assert_not_called()
|
||||
client.list_children.assert_not_called()
|
||||
|
||||
def test_coordinator_without_a_client_is_silent(self, tmp_db, mock_openai_client):
|
||||
"""Eval / rehydration shells run coordinator-kind sessions with no coord
|
||||
client; compaction must not care."""
|
||||
s = _coord_session(mock_openai_client, coord_client=None)
|
||||
assert "## Handles" not in _compact(s)
|
||||
|
||||
def test_empty_task_list_and_no_children_render_nothing(self, tmp_db, mock_openai_client):
|
||||
s = _coord_session(mock_openai_client, coord_client=_coord_client([], []))
|
||||
assert "## Handles" not in _compact(s)
|
||||
|
||||
def test_a_failed_read_costs_the_block_not_the_history(self, tmp_db, mock_openai_client):
|
||||
"""Isolation: the compaction is the expensive, already-paid work — a
|
||||
side read that raises must never turn it into a lost history."""
|
||||
client = _coord_client()
|
||||
client.tasks_get.side_effect = RuntimeError("storage down")
|
||||
client.list_children.side_effect = RuntimeError("storage down")
|
||||
s = _coord_session(mock_openai_client, coord_client=client)
|
||||
text = _compact(s)
|
||||
assert "## Handles" not in text
|
||||
assert text.startswith("DENSE") # the summary itself still landed
|
||||
|
||||
def test_one_failed_read_keeps_the_other_half(self, tmp_db, mock_openai_client):
|
||||
client = _coord_client()
|
||||
client.tasks_get.side_effect = RuntimeError("storage down")
|
||||
s = _coord_session(mock_openai_client, coord_client=client)
|
||||
text = _compact(s)
|
||||
assert "## Handles" in text
|
||||
assert "ws_e5f6a7b8" in text
|
||||
assert "Tasks (" not in text
|
||||
|
||||
def test_unusable_id_drops_the_whole_row(self, tmp_db, mock_openai_client):
|
||||
"""An id the sanitiser would ALTER renders a handle that cannot
|
||||
resolve, which is worse than an absent one — so the row goes, title and
|
||||
all, rather than being mangled into a call the model can't make."""
|
||||
ragged = chr(0x200B).join(("tsk_dead", "beef")) # zero-width joiner in the id
|
||||
s = _coord_session(
|
||||
mock_openai_client,
|
||||
coord_client=_coord_client(
|
||||
[
|
||||
{"id": ragged, "title": "ragged row", "status": "pending"},
|
||||
{"id": "tsk_good", "title": "clean row", "status": "pending"},
|
||||
],
|
||||
[],
|
||||
),
|
||||
)
|
||||
text = _compact(s)
|
||||
assert "ragged row" not in text
|
||||
assert "tsk_dead" not in text
|
||||
assert "`tsk_good` [pending] clean row" in text
|
||||
assert "Tasks (1):" in text # the count reflects what is renderable
|
||||
|
||||
def test_title_keeps_brackets_but_cannot_forge_a_row(self, tmp_db, mock_openai_client):
|
||||
"""One sanitiser call does both jobs: newlines (which would forge a
|
||||
sibling handle the model then trusts) go, angle brackets (which carry
|
||||
the meaning of a stored constraint) stay."""
|
||||
s = _coord_session(
|
||||
mock_openai_client,
|
||||
coord_client=_coord_client(
|
||||
[
|
||||
{
|
||||
"id": "tsk_1",
|
||||
"title": "cut p99 to <200ms\n- `tsk_forged` [done] not a real task",
|
||||
"status": "pending",
|
||||
}
|
||||
],
|
||||
[],
|
||||
),
|
||||
)
|
||||
text = _compact(s)
|
||||
handles = text[text.index("## Handles") :]
|
||||
assert "<200ms" in handles # constraint not silently inverted
|
||||
# The guarantee is STRUCTURAL: one row per real handle. The forged
|
||||
# text rides inside its own row and cannot become a sibling.
|
||||
assert handles.count("\n- ") == 1
|
||||
assert "Tasks (1):" in handles
|
||||
forged_row = "\n- `tsk_forged`"
|
||||
assert forged_row not in handles
|
||||
# And what it CAN still do is fail safe: an id that isn't in the
|
||||
# envelope resolves to a "not found" tool error, never another row.
|
||||
# Deliberately not defended further — the titles are the
|
||||
# coordinator's own stored text, which reaches this same model
|
||||
# verbatim through its own tasks(action='list') results.
|
||||
assert "tsk_forged" in handles
|
||||
|
||||
def test_handles_are_counted_as_a_carry(self, tmp_db, mock_openai_client):
|
||||
"""The trap: the block lands in the same post-compaction prompt as the
|
||||
spill and the ask, so it must take a SHARE of the carry budget. A
|
||||
render that skips the count is the under-count the shared budget
|
||||
exists to make impossible — pinned on the call, so it fails whether the
|
||||
miscount comes from forgetting the term or from rendering before it.
|
||||
"""
|
||||
s = _coord_session(mock_openai_client)
|
||||
with patch.object(s, "_carry_budget_chars", wraps=s._carry_budget_chars) as budget:
|
||||
_compact(s, carry_spill=True)
|
||||
assert budget.call_args[0][0] == 3 # handles + spill + ask
|
||||
|
||||
bare = _coord_session(mock_openai_client, coord_client=_coord_client([], []))
|
||||
with patch.object(bare, "_carry_budget_chars", wraps=bare._carry_budget_chars) as budget:
|
||||
_compact(bare, carry_spill=True)
|
||||
assert budget.call_args[0][0] == 2 # no handles, no third share
|
||||
|
||||
def test_block_fits_the_budget_and_cuts_only_at_row_boundaries(
|
||||
self, tmp_db, mock_openai_client
|
||||
):
|
||||
"""Overflow drops WHOLE handles and says how many went. Head+tail
|
||||
truncation through a list would leave a half-copied id — a call that
|
||||
cannot resolve, dressed as one that can."""
|
||||
many = [{"id": f"tsk_{i:04d}", "title": "x" * 180, "status": "pending"} for i in range(60)]
|
||||
s = _coord_session(mock_openai_client, coord_client=_coord_client(many, CHILDREN))
|
||||
budget = s._carry_budget_chars(1)
|
||||
block = s._render_handles_block(*s._coordinator_handle_rows(), budget)
|
||||
|
||||
assert len(block) <= budget
|
||||
assert "Tasks (60):" in block # the heading counts ALL of them
|
||||
rendered = [t["id"] for t in many if f"`{t['id']}`" in block]
|
||||
assert 0 < len(rendered) < 60
|
||||
assert f"… and {60 - len(rendered)} more" in block
|
||||
assert "tasks(action='list')" in block # the authoritative source
|
||||
# No id was cut in half: every backticked id in the block is a WHOLE id
|
||||
# that storage actually returned.
|
||||
known = {t["id"] for t in many} | {c["ws_id"] for c in CHILDREN}
|
||||
assert set(re.findall(r"^- `([^`]+)`", block, re.M)) <= known
|
||||
# Children are reserved room rather than starved off the page.
|
||||
assert "ws_e5f6a7b8" in block
|
||||
|
||||
def test_persisted_checkpoint_carries_the_handles(self, tmp_db, mock_openai_client):
|
||||
"""The reopen path is the whole point: an idle coordinator that gets
|
||||
rehydrated reads the checkpoint row, so the handles must be IN it."""
|
||||
s = _coord_session(mock_openai_client)
|
||||
s._ws_id = "ws-coord"
|
||||
with (
|
||||
patch("turnstone.core.session.get_compaction_watermark", return_value=7),
|
||||
patch("turnstone.core.session.save_message") as saved,
|
||||
):
|
||||
_compact(s)
|
||||
assert "## Handles" in saved.call_args[0][2]
|
||||
assert "tsk_b4fbdb7d95a7" in saved.call_args[0][2]
|
||||
|
||||
@@ -257,63 +257,6 @@ def test_searxng_engines_default_empty(tmp_path, monkeypatch):
|
||||
assert config_mod.get_searxng_engines() == ""
|
||||
|
||||
|
||||
def _reset_workspace_cache():
|
||||
config_mod._workspace_dir = None
|
||||
config_mod._workspace_dir_loaded = False
|
||||
|
||||
|
||||
def test_workspace_dir_from_config(tmp_path, monkeypatch):
|
||||
"""get_workspace_dir() reads from config.toml [tools] workspace_dir."""
|
||||
_reset_cache()
|
||||
_reset_workspace_cache()
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[tools]\nworkspace_dir = "/srv/projects"\n')
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.delenv("TURNSTONE_WORKSPACE", raising=False)
|
||||
|
||||
assert config_mod.get_workspace_dir() == "/srv/projects"
|
||||
|
||||
|
||||
def test_workspace_dir_config_wins_over_env(tmp_path, monkeypatch):
|
||||
"""get_workspace_dir(): config.toml [tools] workspace_dir wins over env."""
|
||||
_reset_cache()
|
||||
_reset_workspace_cache()
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[tools]\nworkspace_dir = "/srv/projects"\n')
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.setenv("TURNSTONE_WORKSPACE", "/ignored")
|
||||
|
||||
assert config_mod.get_workspace_dir() == "/srv/projects"
|
||||
|
||||
|
||||
def test_workspace_dir_fallback_to_env(tmp_path, monkeypatch):
|
||||
"""get_workspace_dir() falls back to $TURNSTONE_WORKSPACE."""
|
||||
_reset_cache()
|
||||
_reset_workspace_cache()
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text("[tools]\n")
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.setenv("TURNSTONE_WORKSPACE", "/workspace")
|
||||
|
||||
assert config_mod.get_workspace_dir() == "/workspace"
|
||||
|
||||
|
||||
def test_workspace_dir_none_when_unset(tmp_path, monkeypatch):
|
||||
"""get_workspace_dir() returns None when neither config nor env is set."""
|
||||
_reset_cache()
|
||||
_reset_workspace_cache()
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text("[tools]\n")
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.delenv("TURNSTONE_WORKSPACE", raising=False)
|
||||
|
||||
assert config_mod.get_workspace_dir() is None
|
||||
|
||||
|
||||
def test_apply_config_judge_section(tmp_path):
|
||||
"""apply_config() loads [judge] section and maps to argparse dests."""
|
||||
_reset_cache()
|
||||
|
||||
+103
-263
@@ -1843,169 +1843,6 @@ class TestConsoleProxy:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_BRAND_HARNESS = r"""
|
||||
// Faithful two-walk DOM click dispatch. The capture flag is RECORDED: an
|
||||
// earlier version of this harness dropped it, which silently encoded the
|
||||
// false premise that at target both phases fire in registration order, and
|
||||
// so wrongly rejected a same-element capture listener that Chrome accepts.
|
||||
// `document` is a real path member, so its non-capture listeners are
|
||||
// modelled too. stopPropagation gates the walk BETWEEN nodes without
|
||||
// suppressing the rest of the current node's listeners.
|
||||
const order = [];
|
||||
const navigations = [];
|
||||
|
||||
function fail(msg) { throw new Error(msg); }
|
||||
|
||||
function makeEl(cls, tag) {
|
||||
return {
|
||||
className: cls || "", tagName: tag || "DIV", attrs: {},
|
||||
children: [], parent: null, textContent: "", listeners: [],
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
|
||||
appendChild(c) { c.parent = this; this.children.push(c); return c; },
|
||||
addEventListener(type, fn, capture) {
|
||||
this.listeners.push([type, fn, capture === true]);
|
||||
},
|
||||
removeEventListener() {},
|
||||
contains(n) { while (n) { if (n === this) return true; n = n.parent; } return false; },
|
||||
querySelector(sel) { return find(this, sel); },
|
||||
};
|
||||
}
|
||||
|
||||
function hasClass(el, c) {
|
||||
return (" " + el.className + " ").includes(" " + c + " ");
|
||||
}
|
||||
|
||||
// Descendant combinator: the last token must match, and for a two-token
|
||||
// selector some ancestor must match the first.
|
||||
function find(root, sel) {
|
||||
const parts = sel.trim().split(/\s+/).map((t) => t.replace(/^\./, ""));
|
||||
const want = parts[parts.length - 1];
|
||||
const anc = parts.length > 1 ? parts[0] : null;
|
||||
const walk = (el) => {
|
||||
for (const c of el.children) {
|
||||
if (hasClass(c, want)) {
|
||||
if (!anc) return c;
|
||||
for (let p = c.parent; p; p = p.parent) if (hasClass(p, anc)) return c;
|
||||
}
|
||||
const hit = walk(c);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(root);
|
||||
}
|
||||
|
||||
// The rail as shell.js builds it: .rail-brand > .brand-home > .brand-sub,
|
||||
// with .rail-collapse a SIBLING of .brand-home inside .rail-brand.
|
||||
const root = makeEl("root");
|
||||
const railBrand = makeEl("rail-brand");
|
||||
const brand = makeEl("brand-home", "BUTTON");
|
||||
const sub = makeEl("brand-sub");
|
||||
sub.textContent = "server";
|
||||
root.appendChild(railBrand);
|
||||
railBrand.appendChild(brand);
|
||||
brand.appendChild(makeEl("brand-mark"));
|
||||
brand.appendChild(sub);
|
||||
const collapse = makeEl("rail-collapse", "BUTTON");
|
||||
railBrand.appendChild(collapse);
|
||||
|
||||
// shell.js's own bubble-phase handler on the same element.
|
||||
brand.addEventListener("click", () => order.push("showHome"));
|
||||
|
||||
const documentNode = makeEl("document-node");
|
||||
const document = {
|
||||
readyState: "loading",
|
||||
querySelector: (sel) => find(root, sel),
|
||||
getElementById: () => null,
|
||||
createElement: (tag) => makeEl("", tag.toUpperCase()),
|
||||
addEventListener(type, fn, capture) {
|
||||
documentNode.listeners.push([type, fn, capture === true]);
|
||||
},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
class FakeES { close() {} }
|
||||
const window = {
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
|
||||
EventSource: FakeES,
|
||||
};
|
||||
// Recording href setter. A plain string initialised to the node root made
|
||||
// "never navigated" and "navigated to the node root" produce identical
|
||||
// failures; these are different bugs and must read differently.
|
||||
let _href = "/node/n1/current";
|
||||
Object.defineProperty(window, "location", {
|
||||
value: Object.defineProperty({}, "href", {
|
||||
get: () => _href,
|
||||
set: (v) => { _href = v; navigations.push(v); order.push("nav"); },
|
||||
}),
|
||||
});
|
||||
|
||||
function dispatchClick(target) {
|
||||
const path = [];
|
||||
for (let n = target; n; n = n.parent) path.push(n);
|
||||
path.push(documentNode);
|
||||
let stopped = false;
|
||||
const ev = {
|
||||
target, defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
stopPropagation() { stopped = true; },
|
||||
};
|
||||
const invoke = (node, phase) => {
|
||||
for (const [type, fn, capture] of node.listeners.slice()) {
|
||||
if (type !== "click") continue;
|
||||
if (phase === "capturing" && !capture) continue;
|
||||
if (phase === "bubbling" && capture) continue;
|
||||
fn.call(node, ev);
|
||||
}
|
||||
};
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
invoke(path[i], "capturing");
|
||||
if (stopped) return ev;
|
||||
}
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
invoke(path[i], "bubbling");
|
||||
if (stopped) return ev;
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
new Function("window", "document", SHIM)(window, document);
|
||||
|
||||
const ready = documentNode.listeners.filter(([t]) => t === "DOMContentLoaded");
|
||||
if (!ready.length) fail("shim never registered a DOMContentLoaded hook");
|
||||
for (const [, fn] of ready) fn();
|
||||
|
||||
// Post-wire, pre-click: the affordance must be visible and labelled.
|
||||
// aria-label first: it separates "nothing ran at all" from "only the
|
||||
// sub-label repoint was lost", which otherwise trip the same assertion.
|
||||
if (brand.getAttribute("aria-label") !== "Back to console")
|
||||
fail("aria-label not repointed: " + brand.getAttribute("aria-label"));
|
||||
if (sub.textContent === "server")
|
||||
fail("brand-sub still reads 'server' - no visible way back");
|
||||
if (!/console/i.test(sub.textContent))
|
||||
fail("brand-sub does not name the console: " + sub.textContent);
|
||||
|
||||
// Click a CHILD span, as a real user does: the handler must match via
|
||||
// contains(), not target identity.
|
||||
const ev = dispatchClick(sub);
|
||||
|
||||
if (!ev.defaultPrevented)
|
||||
fail("shim did not preventDefault on the brand click");
|
||||
if (!navigations.length)
|
||||
fail("brand click did not navigate at all (order=[" + order.join(",") + "])");
|
||||
if (navigations[navigations.length - 1] !== "/")
|
||||
fail("brand navigated to " + navigations[navigations.length - 1] + ", expected /");
|
||||
if (order.join(",") !== "nav")
|
||||
fail("expected order [nav], got [" + order.join(",") + "]");
|
||||
|
||||
// A sibling control inside .rail-brand must not be hijacked.
|
||||
const before = navigations.length;
|
||||
dispatchClick(collapse);
|
||||
if (navigations.length !== before) fail("sibling .rail-collapse click was hijacked");
|
||||
"""
|
||||
|
||||
|
||||
class TestProxyRewriting:
|
||||
"""Test the JS shim and HTML rewriting logic."""
|
||||
|
||||
@@ -2023,110 +1860,96 @@ class TestProxyRewriting:
|
||||
assert "window.fetch" in _JS_PROXY_SHIM
|
||||
assert "window.EventSource" in _JS_PROXY_SHIM
|
||||
|
||||
def test_js_shim_repoints_rail_brand_at_console(self, tmp_path):
|
||||
"""Runtime guard: clicking the rail brand on a proxied page must
|
||||
navigate to the console root, not run shell.js's showHome().
|
||||
|
||||
Executed under node against a stub DOM rather than asserted as
|
||||
substrings: the previous back-to-console affordance (the picker's
|
||||
menu item) was lost precisely because its ``#ui-header`` anchor
|
||||
stopped existing while every string-presence assertion kept passing.
|
||||
A string assertion cannot see a selector that stopped matching, so
|
||||
``tests/test_shell_js.py`` guards the selector literals themselves.
|
||||
|
||||
Expected click orders were MEASURED in Chrome, not reasoned: a
|
||||
correct wiring gives ``[nav]``, a document bubble listener gives
|
||||
``[showHome, nav]``, and dropping stopPropagation gives
|
||||
``[nav, showHome]``.
|
||||
|
||||
``new Function(...)`` in the harness doubles as the shim's only
|
||||
syntax check in the default CI lane -- ``node --check`` never sees
|
||||
this string, since it lives in a Python constant.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
def test_js_shim_carries_node_id_placeholder(self):
|
||||
"""The picker reads the current node_id from the shim's _nodeId
|
||||
closure variable; the placeholder must be present and substitutable."""
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
shim = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps("/node/n1"))
|
||||
script = tmp_path / "brand_harness.mjs"
|
||||
script.write_text(
|
||||
"const SHIM = " + json.dumps(shim) + ";\n" + _BRAND_HARNESS,
|
||||
encoding="utf-8",
|
||||
assert "NODE_ID_PLACEHOLDER" in _JS_PROXY_SHIM
|
||||
replaced = _JS_PROXY_SHIM.replace("NODE_ID_PLACEHOLDER", "node-a")
|
||||
assert "node-a" in replaced
|
||||
assert "NODE_ID_PLACEHOLDER" not in replaced
|
||||
|
||||
def test_js_shim_includes_picker_pieces(self):
|
||||
"""Picker logic ships in the same IIFE as the prefix shim — verify
|
||||
the moving parts are present so a future refactor doesn't silently
|
||||
drop them. /v1/api/cluster/nodes is the lazy-fetch target;
|
||||
#ui-header is the DOM anchor; console-node-pill is the trigger
|
||||
class; ws-tab-dropdown is the menu shell we share with the
|
||||
workstream chevron menu (style + behaviour parity); ArrowDown is
|
||||
the keyboard-nav primitive that disambiguates this from a plain
|
||||
click-only menu."""
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
# limit=1000 matches the collector's hard cap; without it the
|
||||
# picker would silently drop nodes past the 100-default in
|
||||
# clusters with >100 nodes.
|
||||
assert "/v1/api/cluster/nodes?limit=1000" in _JS_PROXY_SHIM
|
||||
assert "ui-header" in _JS_PROXY_SHIM
|
||||
assert "console-node-pill" in _JS_PROXY_SHIM
|
||||
assert "ws-tab-dropdown" in _JS_PROXY_SHIM
|
||||
assert "ArrowDown" in _JS_PROXY_SHIM
|
||||
assert "DOMContentLoaded" in _JS_PROXY_SHIM
|
||||
|
||||
def test_proxy_style_drops_banner_styles(self):
|
||||
"""The legacy banner CSS classes (.console-banner, .ts-header-back-link
|
||||
offsets, .dashboard-overlay top:32px hack) should be gone — the new
|
||||
picker lives inside #ui-header and doesn't need overlay offsets."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE
|
||||
|
||||
assert ".console-banner" not in _CONSOLE_PROXY_STYLE
|
||||
assert "dashboard-overlay" not in _CONSOLE_PROXY_STYLE
|
||||
assert ".console-node-pill" in _CONSOLE_PROXY_STYLE
|
||||
assert ".console-node-menu" in _CONSOLE_PROXY_STYLE
|
||||
|
||||
def test_proxy_style_uses_canonical_degraded_color(self):
|
||||
"""Degraded health dot must use --accent (the canonical "needs
|
||||
attention" token used by the cluster-overview node table at
|
||||
console/static/style.css:548) and not --yellow. Yellow is reserved
|
||||
for the dash-state attention dot, a stronger signal."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE
|
||||
|
||||
assert "console-node-menu-item-dot--degraded" in _CONSOLE_PROXY_STYLE
|
||||
# The degraded rule sits on its own line; assert it uses --accent
|
||||
# by checking the CSS substring has --accent and not --yellow.
|
||||
idx = _CONSOLE_PROXY_STYLE.find("console-node-menu-item-dot--degraded")
|
||||
rule = _CONSOLE_PROXY_STYLE[idx : idx + 200]
|
||||
assert "var(--accent)" in rule
|
||||
assert "var(--yellow)" not in rule
|
||||
|
||||
def test_html_rewriting_changes_static_paths(self):
|
||||
"""Simulate the proxy_index rewriting logic."""
|
||||
sample_html = (
|
||||
'<link rel="stylesheet" href="/static/style.css">\n'
|
||||
'<script src="/static/app.js"></script>'
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(["node", str(script)], capture_output=True, text=True, timeout=15)
|
||||
except FileNotFoundError:
|
||||
pytest.skip("node binary not available on PATH")
|
||||
assert proc.returncode == 0, (
|
||||
f"rail-brand back-to-console runtime check failed.\n"
|
||||
f"stdout={proc.stdout!r}\nstderr={proc.stderr!r}"
|
||||
prefix = "/node/test-node"
|
||||
rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/')
|
||||
rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/')
|
||||
assert "/node/test-node/static/style.css" in rewritten
|
||||
assert "/node/test-node/static/app.js" in rewritten
|
||||
# Originals should be gone
|
||||
assert 'href="/static/' not in rewritten
|
||||
assert 'src="/static/' not in rewritten
|
||||
|
||||
def test_shim_injection_after_body(self):
|
||||
"""Simulate the proxy shim injection — the shim ships the node-id
|
||||
and prefix as JS literals and renders the picker at runtime, so
|
||||
we assert the substituted JS literals land in the page."""
|
||||
from turnstone.console.server import _CONSOLE_PROXY_STYLE, _JS_PROXY_SHIM
|
||||
|
||||
sample_html = "<html><body><div>content</div></body></html>"
|
||||
prefix = "/node/node-a"
|
||||
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
|
||||
'"NODE_ID_PLACEHOLDER"', json.dumps("node-a")
|
||||
)
|
||||
|
||||
def test_shim_injection_after_body(self, monkeypatch):
|
||||
"""Drive the REAL ``proxy_index`` against the REAL node index page.
|
||||
|
||||
The previous version asserted ``startswith("<html><body><script>")``
|
||||
on a string the test itself had concatenated three lines earlier --
|
||||
unconditionally true, and green even if proxy_index stopped
|
||||
injecting entirely.
|
||||
|
||||
Feeding the real ``turnstone/ui/static/index.html`` also pins
|
||||
something nothing else does: the injection is a literal
|
||||
``page.replace("<body>", ...)``, so the day that file grows a
|
||||
``<body class="...">`` attribute the ENTIRE shim silently vanishes --
|
||||
prefix rewriting included, not just back-to-console.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
|
||||
from turnstone.console import server as csrv
|
||||
|
||||
index = (
|
||||
Path(__file__).resolve().parent.parent / "turnstone/ui/static/index.html"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "<body>" in index, (
|
||||
"the node index no longer has a bare <body>; proxy_index's "
|
||||
"literal replace() would silently inject nothing at all."
|
||||
)
|
||||
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=index.encode(),
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
request=httpx.Request("GET", "http://n:1/"),
|
||||
)
|
||||
|
||||
async def _mock_get(*a, **kw):
|
||||
return upstream
|
||||
|
||||
proxy_client = MagicMock(spec=httpx.AsyncClient)
|
||||
proxy_client.get = MagicMock(side_effect=_mock_get)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
|
||||
path_params={"node_id": "node-a"},
|
||||
url=SimpleNamespace(query=""),
|
||||
)
|
||||
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda r: {})
|
||||
monkeypatch.setattr(csrv, "_get_server_url", lambda r, n: "http://n:1")
|
||||
|
||||
resp = asyncio.run(csrv.proxy_index(request))
|
||||
assert resp.status_code == 200
|
||||
body = resp.body.decode()
|
||||
|
||||
# The shim is injected, and injected at <body> rather than appended.
|
||||
assert "<body><script>" in body, "shim not injected immediately after <body>"
|
||||
assert '"/node/node-a"' in body, "prefix literal missing from the shim"
|
||||
assert "PREFIX_PLACEHOLDER" not in body
|
||||
assert "wireBrandHome" in body, "back-to-console wiring absent from the page"
|
||||
# Static + shared rewriting still happen on the same pass.
|
||||
assert 'href="/static/' not in body
|
||||
assert 'src="/static/' not in body
|
||||
assert "/node/node-a/static/" in body
|
||||
assert 'href="/shared/' not in body
|
||||
assert "/node/node-a/shared/" in body
|
||||
injection = _CONSOLE_PROXY_STYLE + "<script>" + shim_js + "</script>"
|
||||
result = sample_html.replace("<body>", "<body>" + injection, 1)
|
||||
assert '"node-a"' in result
|
||||
assert '"/node/node-a"' in result
|
||||
assert "PREFIX_PLACEHOLDER" not in result
|
||||
assert "NODE_ID_PLACEHOLDER" not in result
|
||||
assert result.startswith("<html><body><style>")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2358,6 +2181,23 @@ class TestProxySharedStatic:
|
||||
assert 'href="/shared/' not in rewritten
|
||||
assert 'src="/shared/' not in rewritten
|
||||
|
||||
def test_proxy_shim_injected_in_html(self):
|
||||
"""Verify shim is injected as inline script in proxied HTML."""
|
||||
|
||||
from turnstone.console.server import _JS_PROXY_SHIM
|
||||
|
||||
sample_html = "<html><body><div>content</div></body></html>"
|
||||
prefix = "/node/test-node"
|
||||
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
|
||||
'"NODE_ID_PLACEHOLDER"', json.dumps("test-node")
|
||||
)
|
||||
shim = "<script>" + shim_js + "</script>"
|
||||
result = sample_html.replace("<body>", "<body>" + shim, 1)
|
||||
assert "<script>" in result
|
||||
assert "/node/test-node" in result
|
||||
assert "window.fetch" in result
|
||||
assert "window.EventSource" in result
|
||||
|
||||
def test_proxy_shared_static_unknown_node_returns_404(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@@ -217,91 +217,3 @@ def test_whitespace_only_coord_alias_falls_through() -> None:
|
||||
)
|
||||
_invoke(factory)
|
||||
assert registry.captured_alias == "registry-default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coordinator MCP gate (#725) — flag × getter matrix, resolved per construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _capture_chatsession_kwargs(
|
||||
*,
|
||||
settings: dict[str, Any],
|
||||
mcp_client_getter: Any = None,
|
||||
getter_passed: bool = True,
|
||||
) -> Any:
|
||||
"""Run the factory through to a (patched) ChatSession and return the
|
||||
captured construction kwargs. ChatSession's own contract is covered
|
||||
elsewhere; the unit under test here is the factory's MCP gate."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests._coord_test_helpers import _fake_registry
|
||||
|
||||
extra: dict[str, Any] = {}
|
||||
if getter_passed:
|
||||
extra["mcp_client_getter"] = mcp_client_getter
|
||||
factory = build_console_session_factory(
|
||||
registry=_fake_registry(),
|
||||
config_store=_FakeConfigStore(dict(settings)), # type: ignore[arg-type]
|
||||
node_id="console",
|
||||
coord_client_factory=lambda ws_id, uid: MagicMock(),
|
||||
**extra,
|
||||
)
|
||||
ui = MagicMock()
|
||||
ui._user_id = ""
|
||||
with patch("turnstone.console.session_factory.ChatSession") as cs:
|
||||
factory(ui, ws_id="w1")
|
||||
assert cs.call_count == 1
|
||||
return cs.call_args.kwargs
|
||||
|
||||
|
||||
def test_mcp_getter_passes_live_manager_unconditionally() -> None:
|
||||
"""Node parity: the factory passes the live console manager to every
|
||||
coordinator session (the console counterpart of the node factory's
|
||||
mcp_ref[0] read) — whether MCP tools surface is the persona's call,
|
||||
exactly as for interactive sessions."""
|
||||
manager = MagicMock()
|
||||
got = _capture_chatsession_kwargs(settings={}, mcp_client_getter=lambda: manager)
|
||||
assert got["mcp_client"] is manager
|
||||
|
||||
|
||||
def test_mcp_getter_none_manager_passes_none() -> None:
|
||||
"""Nothing configured (create_mcp_client returned None): the session
|
||||
gets None, not a crash."""
|
||||
got = _capture_chatsession_kwargs(settings={}, mcp_client_getter=lambda: None)
|
||||
assert got["mcp_client"] is None
|
||||
|
||||
|
||||
def test_mcp_no_getter_is_backward_compatible() -> None:
|
||||
got = _capture_chatsession_kwargs(settings={}, getter_passed=False)
|
||||
assert got["mcp_client"] is None
|
||||
|
||||
|
||||
def test_mcp_getter_resolved_per_construction() -> None:
|
||||
"""The getter is consulted at EVERY construction — a manager
|
||||
(re)constructed by the console ensure-helper after factory build must
|
||||
reach the next session. An instance captured at factory-build time
|
||||
fails this row."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests._coord_test_helpers import _fake_registry
|
||||
|
||||
holder: dict[str, Any] = {"mgr": None}
|
||||
factory = build_console_session_factory(
|
||||
registry=_fake_registry(),
|
||||
config_store=_FakeConfigStore({}), # type: ignore[arg-type]
|
||||
node_id="console",
|
||||
coord_client_factory=lambda ws_id, uid: MagicMock(),
|
||||
mcp_client_getter=lambda: holder["mgr"],
|
||||
)
|
||||
ui = MagicMock()
|
||||
ui._user_id = ""
|
||||
with patch("turnstone.console.session_factory.ChatSession") as cs:
|
||||
factory(ui, ws_id="w1")
|
||||
first = cs.call_args.kwargs["mcp_client"]
|
||||
manager = MagicMock()
|
||||
holder["mgr"] = manager # the ensure-helper lazily constructed it
|
||||
factory(ui, ws_id="w2")
|
||||
second = cs.call_args.kwargs["mcp_client"]
|
||||
assert first is None
|
||||
assert second is manager
|
||||
|
||||
@@ -168,17 +168,3 @@ def test_unbounded_render_inputs_are_capped() -> None:
|
||||
assert "more preview lines not shown" in body
|
||||
assert "RAW_CAP" in body
|
||||
assert "truncated for display" in body
|
||||
|
||||
|
||||
def test_retry_note_validates_backoff_before_rendering() -> None:
|
||||
"""retry_in is a server-emitted backoff coerced with Number(); like the
|
||||
part/total pair just below it, it must be finiteness-validated (and
|
||||
non-negative) so a malformed value can't render "retrying in NaNs". The
|
||||
error text is kept regardless — it is the load-bearing half of the note."""
|
||||
body = _body()
|
||||
assert "Number.isFinite(secs) && secs >= 0" in body, (
|
||||
"retry_in must be validated (finite, non-negative) before its seconds render"
|
||||
)
|
||||
assert "Math.round(Number(evt.retry_in))" not in body, (
|
||||
"retry_in must not be Math.round(Number(...))'d without a finiteness guard"
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -476,7 +476,7 @@ def test_coord_spawn_metrics_increments_messages_and_resets_tool_count() -> None
|
||||
ui = ConsoleCoordinatorUI(ws_id="coord-ws", user_id="u1")
|
||||
ui._ws_messages = 5
|
||||
ui._ws_turn_tool_calls = 3
|
||||
_coord_spawn_metrics(ui)
|
||||
_coord_spawn_metrics(MagicMock(), ui)
|
||||
assert ui._ws_messages == 6
|
||||
assert ui._ws_turn_tool_calls == 0
|
||||
|
||||
@@ -489,7 +489,7 @@ def test_coord_spawn_metrics_tolerates_ui_without_counters() -> None:
|
||||
class _StubUI:
|
||||
pass
|
||||
|
||||
_coord_spawn_metrics(_StubUI()) # must not raise
|
||||
_coord_spawn_metrics(MagicMock(), _StubUI()) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+129
-275
@@ -2563,6 +2563,135 @@ def test_wait_progress_callback_errors_dont_break_loop(populated_storage):
|
||||
assert result["complete"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cleanup_dead_task_child_refs (#bug-6, #13)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_tasks(storage: SQLiteBackend, ws_id: str, tasks: list[dict[str, Any]]) -> None:
|
||||
"""Helper: persist a minimal task envelope for a coordinator."""
|
||||
storage.save_workstream_config(
|
||||
ws_id,
|
||||
{"tasks": json.dumps({"version": 1, "tasks": tasks}, separators=(",", ":"))},
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_blanks_dead_links(populated_storage):
|
||||
"""Tasks whose child_ws_id references a missing workstream get the
|
||||
link blanked; tasks with live links (or no link) are untouched."""
|
||||
client = _make_read_client(populated_storage)
|
||||
_save_tasks(
|
||||
populated_storage,
|
||||
"coord-1",
|
||||
[
|
||||
{"id": "t1", "title": "alive-linked", "status": "done", "child_ws_id": "child-a"},
|
||||
{"id": "t2", "title": "dead-linked", "status": "done", "child_ws_id": "ghost-xyz"},
|
||||
{"id": "t3", "title": "unlinked", "status": "pending", "child_ws_id": ""},
|
||||
],
|
||||
)
|
||||
blanked = client.cleanup_dead_task_child_refs("coord-1")
|
||||
assert blanked == 1
|
||||
envelope = client.tasks_get("coord-1")
|
||||
tasks_by_id = {t["id"]: t for t in envelope["tasks"]}
|
||||
# Live link preserved.
|
||||
assert tasks_by_id["t1"]["child_ws_id"] == "child-a"
|
||||
# Dead link blanked.
|
||||
assert tasks_by_id["t2"]["child_ws_id"] == ""
|
||||
# Unlinked task untouched.
|
||||
assert tasks_by_id["t3"]["child_ws_id"] == ""
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_all_alive_is_noop(populated_storage):
|
||||
"""When every child_ws_id resolves, the cleanup returns 0 and does
|
||||
not rewrite the envelope (we verify via a no-op save spy)."""
|
||||
client = _make_read_client(populated_storage)
|
||||
_save_tasks(
|
||||
populated_storage,
|
||||
"coord-1",
|
||||
[{"id": "t1", "title": "alive", "status": "done", "child_ws_id": "child-a"}],
|
||||
)
|
||||
saves: list[dict[str, str]] = []
|
||||
real_save = populated_storage.save_workstream_config
|
||||
|
||||
def _spy_save(ws_id, cfg): # type: ignore[no-untyped-def]
|
||||
saves.append(cfg)
|
||||
return real_save(ws_id, cfg)
|
||||
|
||||
populated_storage.save_workstream_config = _spy_save # type: ignore[method-assign]
|
||||
try:
|
||||
blanked = client.cleanup_dead_task_child_refs("coord-1")
|
||||
finally:
|
||||
populated_storage.save_workstream_config = real_save # type: ignore[method-assign]
|
||||
assert blanked == 0
|
||||
assert saves == []
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_empty_envelope(populated_storage):
|
||||
"""A coordinator with no tasks persisted returns 0 without
|
||||
raising — the cleanup runs on every close, including those that
|
||||
never used the tasks tool."""
|
||||
client = _make_read_client(populated_storage)
|
||||
blanked = client.cleanup_dead_task_child_refs("coord-1")
|
||||
assert blanked == 0
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_corrupt_envelope_skips(populated_storage):
|
||||
"""A corrupt envelope (unparseable JSON in workstream_config.tasks)
|
||||
returns 0 rather than raising — the cleanup is best-effort and
|
||||
must not block the close flow."""
|
||||
populated_storage.save_workstream_config("coord-1", {"tasks": "{not json"})
|
||||
client = _make_read_client(populated_storage)
|
||||
assert client.cleanup_dead_task_child_refs("coord-1") == 0
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_uses_task_lock(populated_storage):
|
||||
"""The cleanup must acquire the same per-ws _task_lock that
|
||||
tasks_add/update/remove/reorder hold, so a close racing an
|
||||
in-flight mutation can't lose writes (#bug-6). Verified by
|
||||
swapping the cached lock for a stand-in that records acquisition."""
|
||||
client = _make_read_client(populated_storage)
|
||||
|
||||
class _RecordingLock:
|
||||
"""Mimics threading.Lock — counts __enter__ / __exit__ pairs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.acquired = 0
|
||||
self.released = 0
|
||||
|
||||
def __enter__(self) -> _RecordingLock:
|
||||
self.acquired += 1
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.released += 1
|
||||
|
||||
recording = _RecordingLock()
|
||||
# Prime the cache under the cache-lock so the client's _task_lock()
|
||||
# lookup returns our stand-in instead of allocating a real Lock.
|
||||
with client._task_lock_cache_lock:
|
||||
client._task_lock_cache["coord-1"] = recording # type: ignore[assignment]
|
||||
client.cleanup_dead_task_child_refs("coord-1")
|
||||
assert recording.acquired == 1
|
||||
assert recording.released == 1
|
||||
|
||||
|
||||
def test_cleanup_dead_task_child_refs_storage_batch_failure_swallows(populated_storage):
|
||||
"""If get_workstreams_batch raises, the cleanup returns 0 rather
|
||||
than propagating — close flow is resilient to storage hiccups."""
|
||||
client = _make_read_client(populated_storage)
|
||||
_save_tasks(
|
||||
populated_storage,
|
||||
"coord-1",
|
||||
[{"id": "t1", "title": "dead", "status": "done", "child_ws_id": "ghost"}],
|
||||
)
|
||||
|
||||
def _boom(ws_ids): # type: ignore[no-untyped-def]
|
||||
raise RuntimeError("storage down")
|
||||
|
||||
populated_storage.get_workstreams_batch = _boom # type: ignore[method-assign]
|
||||
assert client.cleanup_dead_task_child_refs("coord-1") == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# inspect_workstream — three-tier output compression
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2911,278 +3040,3 @@ def test_format_inspect_tiered_full_tier_omits_tier_note():
|
||||
parsed = json.loads(out)
|
||||
assert parsed["_tier"] == "full"
|
||||
assert "_tier_note" not in parsed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# needs_user status + note field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tasks_add_accepts_needs_user_status(tmp_path):
|
||||
"""The status that marks a task as parked on the operator — the one
|
||||
signal the idle-tasks nudge gates on."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="pick a backend", status="needs_user")
|
||||
assert "error" not in task
|
||||
assert task["status"] == "needs_user"
|
||||
|
||||
|
||||
def test_tasks_add_still_rejects_unknown_status(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
assert "error" in client.tasks_add("coord-1", title="t", status="needs-operator")
|
||||
|
||||
|
||||
def test_tasks_add_omits_note_key_when_unset(tmp_path):
|
||||
"""Absent-by-default keeps the envelope small: it is read and
|
||||
re-serialised on every mutation and fed back to the model by
|
||||
``tasks(action='list')``, where an always-present empty string would
|
||||
spend budget on nothing."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="plain")
|
||||
assert "note" not in task
|
||||
|
||||
|
||||
def test_tasks_add_stores_note_when_set(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="ask", note="which backend is canonical?")
|
||||
assert task["note"] == "which backend is canonical?"
|
||||
|
||||
|
||||
def test_tasks_add_rejects_note_over_200(tmp_path):
|
||||
"""Same reject-don't-truncate rule as the title, for the same reason:
|
||||
the note is the operator-facing ask, which is precisely the string
|
||||
where silent trimming loses what the field exists to carry."""
|
||||
client = _task_client(tmp_path)
|
||||
result = client.tasks_add("coord-1", title="t", note="a" * 201)
|
||||
assert "error" in result
|
||||
assert "too long" in result["error"]
|
||||
boundary = client.tasks_add("coord-1", title="t2", note="a" * 200)
|
||||
assert "error" not in boundary
|
||||
assert len(boundary["note"]) == 200
|
||||
|
||||
|
||||
def test_tasks_update_sets_and_clears_note(tmp_path):
|
||||
"""``note`` follows ``child_ws_id``, not ``title``: it is optional, so
|
||||
an empty string is a CLEAR rather than an error."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="ask")
|
||||
updated = client.tasks_update("coord-1", task_id=task["id"], note="need a decision")
|
||||
assert updated["note"] == "need a decision"
|
||||
cleared = client.tasks_update("coord-1", task_id=task["id"], note="")
|
||||
assert "note" not in cleared
|
||||
|
||||
|
||||
def test_tasks_update_rejects_note_over_200_and_leaves_task_intact(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="ask", note="short")
|
||||
result = client.tasks_update("coord-1", task_id=task["id"], note="a" * 201)
|
||||
assert "error" in result
|
||||
envelope = client.tasks_get("coord-1")
|
||||
assert envelope["tasks"][0]["note"] == "short"
|
||||
|
||||
|
||||
def test_tasks_note_survives_reorder(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
a = client.tasks_add("coord-1", title="a", note="keep me")
|
||||
b = client.tasks_add("coord-1", title="b")
|
||||
client.tasks_reorder("coord-1", task_ids=[b["id"], a["id"]])
|
||||
envelope = client.tasks_get("coord-1")
|
||||
by_id = {t["id"]: t for t in envelope["tasks"]}
|
||||
assert by_id[a["id"]]["note"] == "keep me"
|
||||
|
||||
|
||||
def test_legacy_task_row_without_note_round_trips(tmp_path):
|
||||
"""There is no backfill — rows written before the field existed have
|
||||
no ``note`` key, and every reader must tolerate its absence."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="legacy")
|
||||
assert "note" not in task
|
||||
updated = client.tasks_update("coord-1", task_id=task["id"], status="in_progress")
|
||||
assert updated["status"] == "in_progress"
|
||||
assert "note" not in updated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write-path sanitisation (title / note)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tasks_stores_title_and_note_verbatim(tmp_path):
|
||||
"""Storage is NOT sanitised. The strict sanitiser strips angle
|
||||
brackets, so sanitising here would rewrite ordinary planning text —
|
||||
"cut p99 latency to <200ms" would store as "...to 200ms", inverting
|
||||
the constraint. That is the silent mutation the reject-don't-truncate
|
||||
rule forbids. Sanitisation lives at each render instead, in the
|
||||
flavour that audience needs: ``sanitize_display`` (brackets kept) on
|
||||
the operator surfaces, ``sanitize_name`` (brackets deleted) in the
|
||||
model-facing nudge body."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add(
|
||||
"coord-1",
|
||||
title="cut p99 latency to <200ms",
|
||||
note="which of <staging|prod> is canonical?",
|
||||
)
|
||||
assert task["title"] == "cut p99 latency to <200ms"
|
||||
assert task["note"] == "which of <staging|prod> is canonical?"
|
||||
|
||||
|
||||
def test_tasks_length_check_measures_what_the_model_sent(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
result = client.tasks_add("coord-1", title="a" * 201)
|
||||
assert "error" in result and "201 chars" in result["error"]
|
||||
|
||||
|
||||
def test_tasks_add_rejects_unrenderable_title(tmp_path):
|
||||
"""The one carve-out from verbatim storage: text that sanitises to
|
||||
NOTHING renders on no operator surface while ``tasks(list)`` feeds it
|
||||
back to the model every call — an operator-invisible, model-visible
|
||||
payload channel. Rejected with a hint, mirroring reject-don't-truncate.
|
||||
|
||||
The fixture is genuinely-invisible text (zero-width + bidi), which
|
||||
is the whole of the class now that the oracle is
|
||||
``sanitize_display``; ``"<>"`` used to live here and is covered by
|
||||
the accept case below."""
|
||||
client = _task_client(tmp_path)
|
||||
result = client.tasks_add("coord-1", title=chr(0x200B) + chr(0x202E))
|
||||
assert "error" in result
|
||||
assert "no renderable characters" in result["error"]
|
||||
assert "retry" in result["error"]
|
||||
|
||||
|
||||
def test_tasks_add_accepts_bracket_only_title(tmp_path):
|
||||
"""Angle brackets RENDER on every operator surface now, so a
|
||||
bracket-only title is ordinary text: stored verbatim and shown
|
||||
verbatim by the pane's display sanitiser. Refusing it was the
|
||||
unreachable rejection the display fix removed."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="<>")
|
||||
assert "error" not in task
|
||||
assert task["title"] == "<>"
|
||||
shown = _sanitize_task_envelope_for_display(client.tasks_get("coord-1"))
|
||||
assert shown["tasks"][0]["title"] == "<>"
|
||||
|
||||
|
||||
def test_tasks_add_rejects_unrenderable_note(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
result = client.tasks_add("coord-1", title="t", note=chr(0x200B) * 2)
|
||||
assert "error" in result and "no renderable characters" in result["error"]
|
||||
|
||||
|
||||
def test_tasks_update_rejects_unrenderable_title_and_note(tmp_path):
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="keep", note="keep note")
|
||||
res_t = client.tasks_update("coord-1", task_id=task["id"], title=chr(0x202E) + chr(0x200B))
|
||||
assert "error" in res_t and "no renderable characters" in res_t["error"]
|
||||
res_n = client.tasks_update("coord-1", task_id=task["id"], note=chr(0x200B) * 3)
|
||||
assert "error" in res_n and "no renderable characters" in res_n["error"]
|
||||
# Either reject leaves the stored row untouched.
|
||||
row = client.tasks_get("coord-1")["tasks"][0]
|
||||
assert row["title"] == "keep" and row["note"] == "keep note"
|
||||
|
||||
|
||||
def test_bracket_only_note_survives_update_and_renders(tmp_path):
|
||||
"""The update branch's half of the accept case: brackets are not the
|
||||
unrenderable class any more, on either mutation path."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="keep")
|
||||
updated = client.tasks_update("coord-1", task_id=task["id"], note="<>")
|
||||
assert "error" not in updated
|
||||
assert updated["note"] == "<>"
|
||||
shown = _sanitize_task_envelope_for_display(client.tasks_get("coord-1"))
|
||||
assert shown["tasks"][0]["note"] == "<>"
|
||||
|
||||
|
||||
def test_tasks_unrenderable_check_runs_after_length(tmp_path):
|
||||
"""Order ruling: length first (measured on what the model sent), then
|
||||
renderability — a 250-char zero-width run hears "too long", not
|
||||
"unrenderable", so the two hints cannot mask each other."""
|
||||
client = _task_client(tmp_path)
|
||||
result = client.tasks_add("coord-1", title=chr(0x200B) * 250)
|
||||
assert "error" in result and "too long" in result["error"]
|
||||
|
||||
|
||||
def test_tasks_update_empty_note_still_clears_after_the_reject(tmp_path):
|
||||
"""The reject must not eat the legal CLEAR: ``note=""`` stays a
|
||||
clear; only a NON-empty note that sanitises to nothing is rejected."""
|
||||
client = _task_client(tmp_path)
|
||||
task = client.tasks_add("coord-1", title="t", note="real")
|
||||
cleared = client.tasks_update("coord-1", task_id=task["id"], note="")
|
||||
assert "error" not in cleared
|
||||
assert "note" not in cleared
|
||||
|
||||
|
||||
def test_display_sanitiser_cleans_the_operator_facing_copy(tmp_path):
|
||||
"""The pane's copy IS sanitised — a bidi override must not make the
|
||||
operator read an ask in an order different from the stored one."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
client = _task_client(tmp_path)
|
||||
client.tasks_add("coord-1", title="plain", note="do not \u202eevorppa")
|
||||
envelope = client.tasks_get("coord-1")
|
||||
shown = _sanitize_task_envelope_for_display(envelope)
|
||||
assert "\u202e" not in shown["tasks"][0]["note"]
|
||||
# ...and the stored copy is untouched.
|
||||
assert "\u202e" in client.tasks_get("coord-1")["tasks"][0]["note"]
|
||||
|
||||
|
||||
def test_display_sanitiser_preserves_angle_brackets(tmp_path):
|
||||
"""The direct regression pin. A stored "hold p99 <200ms" reached
|
||||
the tasks pane as "hold p99 200ms" — the constraint inverted on the
|
||||
operator's surface — while ``tasks(action='list')`` handed the model
|
||||
the original. The display sanitiser keeps brackets; only the
|
||||
model-facing nudge body deletes them."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
client = _task_client(tmp_path)
|
||||
client.tasks_add(
|
||||
"coord-1",
|
||||
title="hold p99 <200ms",
|
||||
child_ws_id="ws-<a>",
|
||||
note="which of <staging|prod> is canonical?",
|
||||
)
|
||||
row = _sanitize_task_envelope_for_display(client.tasks_get("coord-1"))["tasks"][0]
|
||||
assert row["title"] == "hold p99 <200ms"
|
||||
assert row["note"] == "which of <staging|prod> is canonical?"
|
||||
# ``child_ws_id`` joined the display sanitiser in the same change,
|
||||
# so it is cleaned but not bracket-stripped either.
|
||||
assert row["child_ws_id"] == "ws-<a>"
|
||||
|
||||
|
||||
def test_display_sanitiser_cleans_child_ws_id(tmp_path):
|
||||
"""It is model-supplied and renders on the pane, so it carries the
|
||||
same steering risk as title/note — unlike the server-minted ``id``,
|
||||
which is coerced only."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
out = _sanitize_task_envelope_for_display(
|
||||
{"version": 1, "tasks": [{"id": "t", "child_ws_id": "ws" + chr(0x202E) + "1"}]}
|
||||
)
|
||||
assert out["tasks"][0]["child_ws_id"] == "ws 1"
|
||||
|
||||
|
||||
def test_display_sanitiser_passes_ragged_rows_through(tmp_path):
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
out = _sanitize_task_envelope_for_display({"version": 1, "tasks": ["not a dict", 42]})
|
||||
assert out["tasks"] == ["not a dict", 42]
|
||||
|
||||
|
||||
def test_display_sanitiser_coerces_ragged_rows_like_the_nudge_card(tmp_path):
|
||||
"""Shared ``field_str`` coercion: ``str(x or "")`` mapped ``0`` to
|
||||
``""`` while the nudge card's producer rendered ``"0"`` — two
|
||||
operator-facing surfaces disagreeing on the same stored row. And a
|
||||
coerced ``status`` means the FE's ``task.status || "pending"``
|
||||
fallback cannot mislabel a ragged row as pending."""
|
||||
from turnstone.console.server import _sanitize_task_envelope_for_display
|
||||
|
||||
out = _sanitize_task_envelope_for_display(
|
||||
{"version": 1, "tasks": [{"id": "t", "title": 0, "status": 0, "child_ws_id": None}]}
|
||||
)
|
||||
row = out["tasks"][0]
|
||||
assert row["title"] == "0"
|
||||
assert row["status"] == "0"
|
||||
assert row["child_ws_id"] == ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user