mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e09940745 | |||
| 72bd62d3d8 | |||
| d31f89b2e3 | |||
| 9bae8f1a10 | |||
| a012561195 | |||
| 4198b59a0f | |||
| 4f6ef13ce9 | |||
| 52716ed611 | |||
| 6c9a7d7351 | |||
| 3518f7953c | |||
| 48769e5a97 | |||
| adb4ff6399 | |||
| 131a1ec943 | |||
| 1cded9b430 | |||
| 62c741eb8a | |||
| 3362917e1e | |||
| 698cbbf988 | |||
| e47a08b7bc | |||
| aaa427debd | |||
| 611af76971 | |||
| bbe28ecab3 | |||
| 93a9fd3c28 | |||
| 62ff3217d0 | |||
| b086390558 | |||
| d08a57dfc2 | |||
| 9fdf51ff3d | |||
| 45471894da | |||
| c0b5952573 | |||
| b9d5b5b671 | |||
| 274c97135e | |||
| e87f8e19c2 |
+34
-107
@@ -5,55 +5,40 @@
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":separateMajorReleases"
|
||||
],
|
||||
"labels": [
|
||||
"dependencies"
|
||||
"gitIgnoredAuthors": [
|
||||
"41898282+github-actions[bot]@users.noreply.github.com"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"timezone": "America/New_York",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
]
|
||||
"schedule": ["before 9am on Monday"]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored KaTeX version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"katex-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["katex-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "katex",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Highlight.js version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"hljs-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["hljs-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "highlight.js",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Mermaid version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"mermaid-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "mermaid",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
@@ -62,14 +47,8 @@
|
||||
{
|
||||
"description": "LLM SDKs — always review manually",
|
||||
"groupName": "LLM SDKs",
|
||||
"matchPackageNames": [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"mcp"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"matchPackageNames": ["openai", "anthropic", "mcp"],
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
@@ -83,73 +62,38 @@
|
||||
"httpx-sse",
|
||||
"pydantic"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Database layer",
|
||||
"groupName": "Database",
|
||||
"matchPackageNames": [
|
||||
"sqlalchemy",
|
||||
"alembic",
|
||||
"psycopg"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"matchPackageNames": ["sqlalchemy", "alembic", "psycopg"],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Security-critical — always review manually",
|
||||
"groupName": "Security",
|
||||
"matchPackageNames": [
|
||||
"PyJWT",
|
||||
"pyjwt",
|
||||
"bcrypt"
|
||||
],
|
||||
"matchPackageNames": ["PyJWT", "pyjwt", "bcrypt"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": [
|
||||
"structlog",
|
||||
"redis",
|
||||
"croniter",
|
||||
"discord.py"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Vendored JS — requires manual file download after merge",
|
||||
"description": "Vendored JS — CI workflow downloads files automatically",
|
||||
"groupName": "Vendored JS",
|
||||
"matchPackageNames": [
|
||||
"katex",
|
||||
"highlight.js",
|
||||
"mermaid"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": false,
|
||||
"prBodyNotes": [
|
||||
"This PR updates version references only.",
|
||||
"After merging, run `scripts/update-vendored-js.sh <lib> <version>` to download the actual files."
|
||||
]
|
||||
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Dev/test tooling",
|
||||
@@ -162,46 +106,29 @@
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Docker base images",
|
||||
"groupName": "Docker Images",
|
||||
"matchManagers": [
|
||||
"dockerfile",
|
||||
"docker-compose"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "TypeScript SDK dev dependencies",
|
||||
"groupName": "TypeScript SDK",
|
||||
"matchFileNames": [
|
||||
"sdk/typescript/**"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchFileNames": ["sdk/typescript/**"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions — group all action updates",
|
||||
"groupName": "GitHub Actions",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchManagers": ["github-actions"],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Complete Vendored JS Updates
|
||||
|
||||
# When Renovate bumps a vendored JS version in pyproject.toml, this
|
||||
# workflow downloads the actual files and commits them to the PR branch
|
||||
# so the PR is merge-ready without manual intervention.
|
||||
#
|
||||
# Note: the commit is made with GITHUB_TOKEN, so it won't re-trigger CI
|
||||
# automatically. The reviewer should re-run CI once this workflow passes,
|
||||
# or Renovate's next rebase will trigger it.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to update"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
vendor-js:
|
||||
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve PR head ref
|
||||
id: ref
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
|
||||
else
|
||||
ref="${{ github.head_ref }}"
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
- name: Detect vendored JS changes
|
||||
id: detect
|
||||
run: |
|
||||
updates=()
|
||||
for lib in katex hljs mermaid; do
|
||||
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
|
||||
[[ -z "$version" ]] && continue
|
||||
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
|
||||
updates+=("${lib}:${version}")
|
||||
done
|
||||
if [[ ${#updates[@]} -eq 0 ]]; then
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "${updates[@]}" > /tmp/updates.txt
|
||||
echo "Libs to update:"
|
||||
cat /tmp/updates.txt
|
||||
fi
|
||||
|
||||
- name: Download vendored files
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
while IFS=: read -r lib version; do
|
||||
echo "::group::Updating ${lib} to ${version}"
|
||||
bash scripts/update-vendored-js.sh "$lib" "$version"
|
||||
echo "::endgroup::"
|
||||
done < /tmp/updates.txt
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: download vendored JS files"
|
||||
git push
|
||||
+3
-3
@@ -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.1 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
@@ -25,12 +25,12 @@ ENV UV_COMPILE_BYTECODE=1
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
--extra all
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
--extra all
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
@@ -290,7 +290,7 @@ All entry points read `~/.config/turnstone/config.toml`. CLI flags override conf
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
api_key = ""
|
||||
tavily_key = "" # only needed for local/vLLM models without native search
|
||||
# tavily_key = "" # only needed for local/vLLM models without native search
|
||||
|
||||
[model]
|
||||
name = "" # empty = auto-detect
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# TLS overlay — enables mTLS across the turnstone cluster.
|
||||
#
|
||||
# Usage (requires base compose.yaml with production profile):
|
||||
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
|
||||
#
|
||||
# The tls-init service bootstraps a CA and issues a cert for Redis.
|
||||
# All turnstone services auto-provision their own certs via the
|
||||
# console's ACME endpoint.
|
||||
|
||||
services:
|
||||
# Bootstrap: create CA + Redis cert before anything starts.
|
||||
# Runs as root to create directories in the volume, then chowns
|
||||
# to turnstone:turnstone with restrictive perms (keys 0600).
|
||||
tls-init:
|
||||
build: .
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
turnstone-admin tls-bootstrap --out /certs --issue redis
|
||||
chown -R turnstone:turnstone /certs
|
||||
find /certs -type d -exec chmod 750 {} +
|
||||
find /certs -type f -name '*key.pem' -exec chmod 600 {} +
|
||||
find /certs -type f ! -name '*key.pem' -exec chmod 640 {} +
|
||||
volumes:
|
||||
- tls-certs:/certs
|
||||
networks:
|
||||
- turnstone-net
|
||||
restart: "no"
|
||||
|
||||
# Console: runs the internal CA + ACME server
|
||||
console:
|
||||
depends_on:
|
||||
tls-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "console"
|
||||
TURNSTONE_CONSOLE_URL: "http://console:8090"
|
||||
command:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
- --redis-tls
|
||||
- --redis-tls-ca=/certs/ca.pem
|
||||
|
||||
# Server: auto-provisions certs via console ACME, serves HTTPS
|
||||
server:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "server"
|
||||
# Disable healthcheck — server serves HTTPS with mTLS which the
|
||||
# stdlib healthcheck script can't satisfy. The base compose
|
||||
# healthcheck uses plain HTTP which won't work on an HTTPS listener.
|
||||
# TODO: wire healthcheck with client cert from /certs volume
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
# Bridge: mTLS to server + Redis TLS
|
||||
bridge:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
server:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "bridge"
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
- --redis-tls
|
||||
- --redis-tls-ca=/certs/ca.pem
|
||||
|
||||
# Channel: Redis TLS
|
||||
channel:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "channel"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--redis-host=redis
|
||||
--redis-port=6379
|
||||
--redis-tls
|
||||
--redis-tls-ca=/certs/ca.pem
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
|
||||
# Redis: TLS with certs from bootstrap
|
||||
redis:
|
||||
depends_on:
|
||||
tls-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
ARGS="--tls-port 6379 --port 0 \
|
||||
--tls-cert-file /certs/certs/redis/cert.pem \
|
||||
--tls-key-file /certs/certs/redis/key.pem \
|
||||
--tls-ca-cert-file /certs/ca.pem \
|
||||
--tls-auth-clients no"
|
||||
if [ -n "$$REDIS_PASSWORD" ]; then
|
||||
ARGS="$$ARGS --requirepass $$REDIS_PASSWORD"
|
||||
fi
|
||||
exec redis-server $$ARGS
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli --tls --cacert /certs/ca.pem -a $$REDIS_PASSWORD ping; else redis-cli --tls --cacert /certs/ca.pem ping; fi"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
tls-certs:
|
||||
+21
-6
@@ -452,9 +452,12 @@ after `/clear` or `/new` commands).
|
||||
{"type": "clear_ui"}
|
||||
```
|
||||
|
||||
**`cancelled`** -- the generation was cancelled by the user (via the Stop
|
||||
button or `POST /v1/api/cancel`). The client should finalize any in-progress
|
||||
assistant message with whatever partial content was streamed.
|
||||
**`cancelled`** -- a cancel request was acknowledged (via the Stop button or
|
||||
`POST /v1/api/cancel`). This signals that cancellation is in progress, not
|
||||
that it is complete. The worker thread may still be finishing — wait for
|
||||
`stream_end` before transitioning to a ready state. The client should clear
|
||||
any in-progress assistant rendering but not re-enable the send button until
|
||||
`stream_end` arrives.
|
||||
|
||||
```json
|
||||
{"type": "cancelled"}
|
||||
@@ -793,23 +796,35 @@ containing the resumed session's messages.
|
||||
|
||||
Cancels the active generation in a workstream. Sets a cooperative cancellation
|
||||
flag that is checked at multiple points in the generation loop (per streaming
|
||||
chunk, before tool execution, inside bash commands). The session transitions to
|
||||
`idle` state and preserves any partial content already streamed.
|
||||
chunk, before tool execution, inside bash commands). Also closes the underlying
|
||||
HTTP stream to the LLM provider, unblocking any pending read immediately.
|
||||
The session transitions to `idle` state and preserves any partial content
|
||||
already streamed.
|
||||
|
||||
If the workstream is waiting for tool approval or plan review, the pending
|
||||
prompt is automatically denied/rejected to unblock the worker thread.
|
||||
|
||||
Calling this endpoint when the workstream is already idle is a harmless no-op.
|
||||
|
||||
**Force cancel:** When `force` is `true`, the server abandons the stuck worker
|
||||
thread immediately and transitions the workstream to `idle`. The abandoned
|
||||
thread continues to wind down in the background (killing any running
|
||||
subprocesses and exiting at the next cancellation checkpoint). During this
|
||||
wind-down it may emit a final `stream_end` event which the server suppresses
|
||||
for the orphaned thread. Use force cancel when cooperative cancel has not
|
||||
resolved within a few seconds — the web UI offers this as a "Force Stop"
|
||||
button automatically.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"ws_id": "abc123"}
|
||||
{"ws_id": "abc123", "force": false}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|----------------------|
|
||||
| `ws_id`| string | yes | Target workstream ID |
|
||||
| `force`| bool | no | Abandon stuck worker immediately (default: `false`) |
|
||||
|
||||
**Response:**
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ turnstone/
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.42/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.44/ 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)
|
||||
|
||||
@@ -40,12 +40,23 @@ running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
|
||||
thinking --> idle : cancel() called\n_emit_state("idle")
|
||||
thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note left of idle
|
||||
**Cancel escalation:**
|
||||
1. **Cooperative**: cancel() sets event + closes
|
||||
SDK stream → worker exits at next checkpoint
|
||||
2. **Force**: force=true abandons the worker
|
||||
thread, emits stream_end immediately.
|
||||
Orphaned thread still kills subprocesses
|
||||
but skips message mutations (generation
|
||||
counter prevents stale writes).
|
||||
end note
|
||||
|
||||
note right of thinking
|
||||
**Emitted via:**
|
||||
session._emit_state(state)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
|
||||
size 200083
|
||||
oid sha256:7bf27afa267d5b8d6da38e83213ed1b8e87639d5105a0a1ccc2e5a4bf4d3b67e
|
||||
size 185282
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id)` | `StatusResponse` |
|
||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
| | `stream_global_events()` | `Iterator[ServerEvent]` |
|
||||
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# TLS / mTLS
|
||||
|
||||
Turnstone supports end-to-end transport encryption with mutual TLS (mTLS) for
|
||||
inter-service communication, powered by [lacme](https://pypi.org/project/lacme/).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
This:
|
||||
1. Bootstraps an internal CA and issues certs for Redis/PostgreSQL
|
||||
2. Starts the console with TLS enabled (internal CA + ACME server)
|
||||
3. Server nodes auto-provision certs via the console's ACME endpoint
|
||||
4. All inter-service communication uses mTLS
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Console (CA + ACME Server)
|
||||
+-- CertificateAuthority (owns root key, signs certs)
|
||||
+-- ACMEResponder (mounted at /acme, RFC 8555)
|
||||
+-- GET /acme/ca.pem (root cert for node bootstrapping)
|
||||
|
|
||||
| ACME protocol (auto-approve, no challenge validation)
|
||||
+-----------+-----------+
|
||||
| | |
|
||||
Server(s) Bridge Channel GW
|
||||
(auto-cert (mTLS (mTLS
|
||||
+ renewal) client) client)
|
||||
```
|
||||
|
||||
**Two cert paths on the console:**
|
||||
- **Internal cert** (mTLS): Always from the internal CA. Used for cluster
|
||||
service mesh communication.
|
||||
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
|
||||
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Settings (ConfigStore / Admin Settings tab)
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `tls.enabled` | `false` | Master switch for internal mTLS |
|
||||
| `tls.acme_directory` | `""` | External ACME CA URL for console frontend cert |
|
||||
|
||||
### Bootstrap Config (config.toml)
|
||||
|
||||
These are needed before storage is available:
|
||||
|
||||
```toml
|
||||
[redis]
|
||||
tls = false
|
||||
tls_ca = "" # path to CA cert
|
||||
tls_cert = "" # path to client cert
|
||||
tls_key = "" # path to client key
|
||||
|
||||
[database]
|
||||
sslmode = "prefer" # disable, allow, prefer, require, verify-full
|
||||
sslrootcert = "" # path to CA cert
|
||||
sslcert = "" # path to client cert
|
||||
sslkey = "" # path to client key
|
||||
```
|
||||
|
||||
### Hardcoded Defaults
|
||||
|
||||
| Parameter | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| CA common name | "Turnstone CA" | |
|
||||
| CA validity | 10 years | |
|
||||
| Cert validity | 48 hours | Short-lived, auto-renewed |
|
||||
| Renewal interval | 24 hours | Half of validity |
|
||||
| ACME auto-approve | true | Internal network, no challenge validation |
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
### Offline Bootstrap
|
||||
|
||||
Create a CA and infrastructure certs without a running console:
|
||||
|
||||
```bash
|
||||
# Bootstrap CA + Redis + PostgreSQL certs
|
||||
turnstone-admin tls-bootstrap --out /certs --issue redis --issue postgres
|
||||
|
||||
# Output:
|
||||
# /certs/ca.pem (CA root certificate)
|
||||
# /certs/certs/redis/ (Redis cert + key)
|
||||
# /certs/certs/postgres/ (PostgreSQL cert + key)
|
||||
```
|
||||
|
||||
The output directory is chmod 0700 (contains the CA private key).
|
||||
|
||||
### Online Cert Issuance
|
||||
|
||||
Request certs from a running console's ACME endpoint:
|
||||
|
||||
```bash
|
||||
# Download CA root cert (TOFU — verify fingerprint)
|
||||
turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
|
||||
|
||||
# Request a cert for a domain
|
||||
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
|
||||
|
||||
# List issued certs
|
||||
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
|
||||
```
|
||||
|
||||
### Console URL Discovery
|
||||
|
||||
If `--console-url` is not provided, the CLI discovers it from the `services`
|
||||
table in the shared database. The console registers itself on startup.
|
||||
|
||||
---
|
||||
|
||||
## Admin UI
|
||||
|
||||
The **TLS** tab in the console admin panel (System group) shows:
|
||||
- CA status (common name, certificate count)
|
||||
- Certificate table (domain, SANs, issued, expires)
|
||||
- Force-renew and delete actions per certificate
|
||||
|
||||
---
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
client = TurnstoneServer(
|
||||
base_url="https://server:8080",
|
||||
token="tok_xxx",
|
||||
ca_cert="/path/to/ca.pem",
|
||||
client_cert="/path/to/cert.pem",
|
||||
client_key="/path/to/key.pem",
|
||||
)
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneServer } from "@turnstone/sdk";
|
||||
import { Agent } from "undici";
|
||||
import * as fs from "fs";
|
||||
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
ca: fs.readFileSync("/path/to/ca.pem"),
|
||||
cert: fs.readFileSync("/path/to/cert.pem"),
|
||||
key: fs.readFileSync("/path/to/key.pem"),
|
||||
},
|
||||
});
|
||||
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "https://server:8080",
|
||||
token: "tok_xxx",
|
||||
// Node.js 18+ uses undici under the hood
|
||||
fetch: (url, init) =>
|
||||
fetch(url, { ...init, dispatcher: agent } as RequestInit),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Node Bootstrap Flow
|
||||
|
||||
1. Node starts, connects to shared database (plain connection)
|
||||
2. Discovers console URL from `services` table
|
||||
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
|
||||
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry)
|
||||
6. All subsequent inter-service communication uses mTLS
|
||||
|
||||
### Console Startup Flow
|
||||
|
||||
1. Read `tls.enabled` from ConfigStore
|
||||
2. Initialize CA (load from DB or generate new root key)
|
||||
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
|
||||
4. Issue console certs (internal + optional frontend)
|
||||
5. Start CA-direct auto-renewal (no network, signs directly)
|
||||
6. Register console URL in services table with heartbeat
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cert expired / mTLS connection refused
|
||||
|
||||
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
|
||||
restart the service to re-request a cert.
|
||||
|
||||
### "No console service found"
|
||||
|
||||
The console registers itself in the `services` table on startup. If the console
|
||||
hasn't started or the registration expired (1 hour TTL), nodes can't discover
|
||||
it. Use `--console-url` explicitly.
|
||||
|
||||
### Let's Encrypt for console frontend
|
||||
|
||||
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
|
||||
in the admin Settings tab. The console will request a publicly trusted cert
|
||||
for its HTTPS endpoint. Internal mTLS still uses the private CA.
|
||||
|
||||
### Verifying the cert chain
|
||||
|
||||
```bash
|
||||
openssl s_client -connect server:8080 -CAfile ca.pem
|
||||
```
|
||||
+8
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.8.8"
|
||||
version = "0.9.1"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -53,7 +53,8 @@ anthropic = ["anthropic>=0.39"]
|
||||
postgres = ["psycopg[binary]>=3.2"]
|
||||
ddg = ["ddgs>=9.0"]
|
||||
discord = ["discord.py>=2.4", "redis>=7.2"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg]"]
|
||||
tls = ["lacme>=1.0.4"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -78,7 +79,7 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.42/**/*",
|
||||
"turnstone/shared_static/katex-0.16.44/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
@@ -169,6 +170,10 @@ ignore_missing_imports = true
|
||||
module = ["ddgs", "ddgs.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["lacme", "lacme.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["turnstone.channels.discord.*"]
|
||||
disallow_subclassing_any = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.0",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -3002,7 +3002,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
"$ref": "#/components/schemas/DeleteSettingResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3453,6 +3453,126 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/ca": {
|
||||
"get": {
|
||||
"summary": "CA status: initialization state, CN, cert count, cert inventory",
|
||||
"operationId": "v1_api_admin_tls_ca_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/ca.pem": {
|
||||
"get": {
|
||||
"summary": "Download CA root certificate (PEM format)",
|
||||
"operationId": "v1_api_admin_tls_ca.pem_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs": {
|
||||
"get": {
|
||||
"summary": "List all issued TLS certificates",
|
||||
"operationId": "v1_api_admin_tls_certs_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs/{domain}/renew": {
|
||||
"post": {
|
||||
"summary": "Force-renew a certificate by domain",
|
||||
"operationId": "v1_api_admin_tls_certs_{domain}_renew_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Error 500",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs/{domain}": {
|
||||
"delete": {
|
||||
"summary": "Delete a certificate by domain",
|
||||
"operationId": "v1_api_admin_tls_certs_{domain}_delete",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Console health check",
|
||||
@@ -3507,6 +3627,34 @@
|
||||
"title": "StatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"DeleteSettingResponse": {
|
||||
"description": "DELETE /v1/api/admin/settings/{key} response.",
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "Dotted setting key that was reset",
|
||||
"title": "Key",
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"description": "Registry default value the setting reverted to",
|
||||
"title": "Default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"default"
|
||||
],
|
||||
"title": "DeleteSettingResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthLoginRequest": {
|
||||
"description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.",
|
||||
"properties": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.0",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -1223,6 +1223,12 @@
|
||||
"description": "Target workstream ID",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"force": {
|
||||
"default": false,
|
||||
"description": "Force cancel: abandon the stuck worker thread immediately. Use when cooperative cancel has not resolved within a few seconds.",
|
||||
"title": "Force",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
Generated
+165
-130
@@ -20,6 +20,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -32,6 +33,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -43,6 +45,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -55,26 +58,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.120.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -82,9 +87,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -99,9 +104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -116,9 +121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -133,9 +138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -150,9 +155,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -167,13 +172,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -184,13 +192,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -201,13 +212,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -218,13 +232,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -235,13 +252,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -252,13 +272,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -269,9 +292,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -286,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -303,9 +326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -320,9 +343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -337,9 +360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -387,31 +410,31 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
|
||||
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -432,26 +455,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
|
||||
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
|
||||
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -459,14 +482,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
|
||||
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -475,9 +498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -485,15 +508,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
|
||||
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -739,6 +762,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -760,6 +786,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -781,6 +810,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -802,6 +834,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -912,9 +947,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -954,14 +989,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.120.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -970,21 +1005,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1085,16 +1120,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.10",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1163,19 +1198,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/runner": "4.1.1",
|
||||
"@vitest/snapshot": "4.1.1",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/runner": "4.1.2",
|
||||
"@vitest/snapshot": "4.1.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1186,7 +1221,7 @@
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
@@ -1203,10 +1238,10 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.1",
|
||||
"@vitest/browser-preview": "4.1.1",
|
||||
"@vitest/browser-webdriverio": "4.1.1",
|
||||
"@vitest/ui": "4.1.1",
|
||||
"@vitest/browser-playwright": "4.1.2",
|
||||
"@vitest/browser-preview": "4.1.2",
|
||||
"@vitest/browser-webdriverio": "4.1.2",
|
||||
"@vitest/ui": "4.1.2",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { TurnstoneAPIError } from "./errors.js";
|
||||
import { parseSSEStream } from "./sse.js";
|
||||
|
||||
export interface TlsOptions {
|
||||
/** Path to CA certificate PEM file (Node.js only). */
|
||||
caCert?: string;
|
||||
/** Path to client certificate PEM file for mTLS (Node.js only). */
|
||||
clientCert?: string;
|
||||
/** Path to client key PEM file for mTLS (Node.js only). */
|
||||
clientKey?: string;
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
/** Server base URL (e.g. "http://localhost:8080"). */
|
||||
baseUrl: string;
|
||||
@@ -8,6 +17,13 @@ export interface ClientOptions {
|
||||
token?: string;
|
||||
/** Custom fetch implementation (defaults to globalThis.fetch). */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
/**
|
||||
* TLS certificate paths for documentation and tooling.
|
||||
* The SDK does not read these directly — pass a custom `fetch`
|
||||
* configured with your runtime's TLS agent (e.g. Node.js https.Agent).
|
||||
* See docs/tls.md for examples.
|
||||
*/
|
||||
tls?: TlsOptions;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// Clients
|
||||
export { TurnstoneServer } from "./server.js";
|
||||
export { TurnstoneConsole } from "./console.js";
|
||||
export type { ClientOptions } from "./base.js";
|
||||
export type { ClientOptions, TlsOptions } from "./base.js";
|
||||
|
||||
// Errors
|
||||
export { TurnstoneAPIError } from "./errors.js";
|
||||
|
||||
@@ -93,10 +93,13 @@ export class TurnstoneServer extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(wsId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/cancel", {
|
||||
json: { ws_id: wsId },
|
||||
});
|
||||
async cancel(
|
||||
wsId: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<StatusResponse> {
|
||||
const body: Record<string, unknown> = { ws_id: wsId };
|
||||
if (opts?.force) body.force = true;
|
||||
return this.request("POST", "/v1/api/cancel", { json: body });
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
@@ -39,7 +39,13 @@ def _make_bridge(**overrides) -> Bridge:
|
||||
approval_timeout=1,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Bridge(**defaults)
|
||||
bridge = Bridge(**defaults)
|
||||
# Replace real httpx client with a mock so daemon threads spawned by
|
||||
# _handle_approval / _handle_plan_review don't make real HTTP calls
|
||||
# after the test's patch context exits.
|
||||
bridge._http.close()
|
||||
bridge._http = MagicMock()
|
||||
return bridge
|
||||
|
||||
|
||||
def _approval_items(tool_name: str = "bash") -> list[dict]:
|
||||
|
||||
+401
-1
@@ -1,5 +1,6 @@
|
||||
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
|
||||
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -7,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, _CancelRef
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -407,3 +408,402 @@ class TestStreamFlushBeforeToolCalls:
|
||||
stream_end_idx = next(i for i, e in enumerate(events) if e[0] == "stream_end")
|
||||
late_content = [e for e in events[stream_end_idx + 1 :] if e[0] == "content"]
|
||||
assert late_content == [], f"Content after stream_end: {late_content}"
|
||||
|
||||
|
||||
class TestStreamAbort:
|
||||
"""Tests for cancel() closing the underlying SDK stream."""
|
||||
|
||||
def test_cancel_closes_cancel_stream(self, tmp_db):
|
||||
"""cancel() calls .close() on the stored SDK stream handle."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_stream = mock_stream
|
||||
session.cancel()
|
||||
mock_stream.close.assert_called_once()
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_without_stream_is_safe(self, tmp_db):
|
||||
"""cancel() with no active stream just sets the event."""
|
||||
session = _make_session()
|
||||
assert session._cancel_stream is None
|
||||
session.cancel() # Should not raise
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_stream_close_error_suppressed(self, tmp_db):
|
||||
"""Errors from stream.close() are suppressed."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.close.side_effect = RuntimeError("already closed")
|
||||
session._cancel_stream = mock_stream
|
||||
session.cancel() # Should not raise
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_ref_populated_after_first_chunk(self, tmp_db):
|
||||
"""_cancel_ref is populated by the provider after the first chunk
|
||||
arrives (lazy generator evaluation)."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
sdk_stream = MagicMock()
|
||||
|
||||
def fake_provider_stream():
|
||||
# Simulate provider appending to cancel_ref before first yield
|
||||
session._cancel_ref.append(sdk_stream)
|
||||
yield FakeChunk(content_delta="hi", finish_reason="stop")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=fake_provider_stream(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After stream completes, cancel_stream should be cleared
|
||||
assert session._cancel_stream is None
|
||||
assert len(session._cancel_ref) == 0
|
||||
|
||||
def test_transport_error_during_cancel_becomes_generation_cancelled(self, tmp_db):
|
||||
"""When cancel() closes the stream, the resulting transport error
|
||||
is converted to GenerationCancelled."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def stream_that_errors():
|
||||
yield FakeChunk(content_delta="Hello")
|
||||
session._cancel_event.set()
|
||||
raise ConnectionError("stream closed")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=stream_that_errors(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Should complete as cancelled, not error
|
||||
assert "idle" in ui.states
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
# Partial content preserved
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "Hello"
|
||||
|
||||
def test_non_cancel_exception_not_swallowed(self, tmp_db):
|
||||
"""Exceptions during streaming that aren't caused by cancel
|
||||
should propagate normally."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def stream_that_errors():
|
||||
yield FakeChunk(content_delta="Hello")
|
||||
raise ValueError("unexpected error")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=stream_that_errors(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
pytest.raises(ValueError, match="unexpected error"),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
def test_check_cancelled_between_retries(self, tmp_db):
|
||||
"""_try_stream checks for cancellation between retry attempts."""
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._try_stream(
|
||||
client=MagicMock(),
|
||||
model="test",
|
||||
msgs=[],
|
||||
)
|
||||
|
||||
|
||||
class TestCancelRef:
|
||||
"""Tests for the _CancelRef list proxy."""
|
||||
|
||||
def test_append_sets_cancel_stream(self, tmp_db):
|
||||
"""Appending a stream handle to _CancelRef sets _cancel_stream eagerly."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
assert session._cancel_stream is None
|
||||
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
assert session._cancel_stream is mock_stream
|
||||
|
||||
def test_append_closes_stream_when_already_cancelled(self, tmp_db):
|
||||
"""If cancel is already set when a stream is appended, it is closed immediately."""
|
||||
session = _make_session()
|
||||
session.cancel() # Set cancel event before stream is created
|
||||
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
mock_stream.close.assert_called_once()
|
||||
|
||||
def test_append_does_not_close_stream_when_not_cancelled(self, tmp_db):
|
||||
"""Stream is not closed if cancel hasn't been requested."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
mock_stream.close.assert_not_called()
|
||||
assert session._cancel_stream is mock_stream
|
||||
|
||||
def test_append_close_error_suppressed(self, tmp_db):
|
||||
"""Errors from stream.close() during eager close are suppressed."""
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.close.side_effect = RuntimeError("already closed")
|
||||
|
||||
session._cancel_ref.append(mock_stream) # Should not raise
|
||||
|
||||
def test_cancel_ref_is_cancel_ref_instance(self, tmp_db):
|
||||
"""ChatSession._cancel_ref is a _CancelRef instance."""
|
||||
session = _make_session()
|
||||
assert isinstance(session._cancel_ref, _CancelRef)
|
||||
|
||||
def test_cancel_ref_cleared_after_stream_ends(self, tmp_db):
|
||||
"""_cancel_ref is cleared in the send() finally block after streaming."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_ref.append(mock_stream)
|
||||
assert len(session._cancel_ref) == 1
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After send() completes, _cancel_ref is cleared in the finally block
|
||||
assert len(session._cancel_ref) == 0
|
||||
|
||||
|
||||
class TestForceCancelGeneration:
|
||||
"""Tests for per-generation tracking that prevents orphaned-thread side-effects."""
|
||||
|
||||
def test_check_cancelled_raises_for_orphaned_generation(self, tmp_db):
|
||||
"""_check_cancelled raises GenerationCancelled when my_generation is stale."""
|
||||
session = _make_session()
|
||||
session._generation = 2 # Simulate two generations having run
|
||||
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled(my_generation=1) # Generation 1 is orphaned
|
||||
|
||||
def test_check_cancelled_ok_for_current_generation(self, tmp_db):
|
||||
"""_check_cancelled does not raise when my_generation matches current."""
|
||||
session = _make_session()
|
||||
session._generation = 3
|
||||
session._check_cancelled(my_generation=3) # Should not raise
|
||||
|
||||
def test_force_cancel_orphaned_thread_does_not_mutate_messages(self, tmp_db):
|
||||
"""An abandoned generation (force-cancel) cannot append to session.messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
# We can't trivially test the full threading scenario in a unit test,
|
||||
# so directly verify that _check_cancelled raises when my_generation
|
||||
# is stale, which is what guards _stream_response against orphaned
|
||||
# (force-cancelled) threads continuing to mutate messages.
|
||||
session._generation = 5
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled(my_generation=4) # orphaned generation
|
||||
|
||||
def test_new_cancel_event_per_generation_in_send(self, tmp_db):
|
||||
"""send() replaces _cancel_event with a fresh Event each generation."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
original_event = session._cancel_event
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After send() completes, _cancel_event should be a NEW Event
|
||||
# (not the same object as before the call).
|
||||
assert session._cancel_event is not original_event
|
||||
assert not session._cancel_event.is_set()
|
||||
|
||||
|
||||
class TestForceCancelThreaded:
|
||||
"""Force cancel with actual threads — verifies orphaned thread behavior."""
|
||||
|
||||
def test_force_cancel_orphan_does_not_mutate_messages(self, tmp_db):
|
||||
"""After force cancel + new send(), the orphaned thread must not
|
||||
append stale content to session.messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
old_done = threading.Event()
|
||||
|
||||
def slow_stream():
|
||||
yield FakeChunk(content_delta="Old content")
|
||||
barrier.set() # signal: first chunk delivered
|
||||
time.sleep(2) # simulate stuck stream
|
||||
yield FakeChunk(content_delta=" more", finish_reason="stop")
|
||||
|
||||
# Start generation 1 (will get stuck)
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
|
||||
def run_old():
|
||||
with contextlib.suppress(Exception):
|
||||
session.send("old message")
|
||||
old_done.set()
|
||||
|
||||
t1 = threading.Thread(target=run_old, daemon=True)
|
||||
t1.start()
|
||||
assert barrier.wait(timeout=5), "stream did not start"
|
||||
|
||||
# Force cancel: simulate what the server does
|
||||
session.cancel()
|
||||
# Increment generation as new send() would
|
||||
session._generation += 1
|
||||
session._cancel_event = threading.Event()
|
||||
|
||||
# Wait for old thread to notice generation mismatch and exit
|
||||
assert old_done.wait(timeout=10), "orphaned thread did not exit"
|
||||
|
||||
# The orphaned thread should NOT have appended its content
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
# May have partial content from before cancel, but NOT the full
|
||||
# "Old content more" that would appear without the generation guard
|
||||
for msg in assistant_msgs:
|
||||
assert "more" not in msg.get("content", "")
|
||||
|
||||
def test_force_cancel_then_new_send_succeeds(self, tmp_db):
|
||||
"""A new send() after force cancel works cleanly."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
|
||||
def stuck_stream():
|
||||
yield FakeChunk(content_delta="stuck")
|
||||
barrier.set()
|
||||
time.sleep(2)
|
||||
yield FakeChunk(content_delta=" end", finish_reason="stop")
|
||||
|
||||
# Start stuck generation
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=stuck_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
t = threading.Thread(target=lambda: session.send("old"), daemon=True)
|
||||
t.start()
|
||||
assert barrier.wait(timeout=5), "stream did not start"
|
||||
|
||||
# Force cancel
|
||||
session.cancel()
|
||||
|
||||
# New generation should work
|
||||
fresh_stream = iter([FakeChunk(content_delta="Fresh response")])
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fresh_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("new message")
|
||||
|
||||
# The new generation should have completed successfully
|
||||
assert "idle" in ui.states
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert any("Fresh response" in m.get("content", "") for m in assistant_msgs)
|
||||
|
||||
@@ -390,6 +390,7 @@ class TestApprovalVerdictDisplay:
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
|
||||
+29
-16
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -179,10 +180,13 @@ class TestErrorHandling:
|
||||
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
|
||||
judge = _make_judge(provider)
|
||||
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_provider_error_heuristic_still_returned(self):
|
||||
@@ -211,10 +215,13 @@ class TestErrorHandling:
|
||||
result_mock.content = ""
|
||||
|
||||
judge = _make_judge(provider)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -254,10 +261,13 @@ class TestMultiTurnToolUse:
|
||||
provider.create_completion.side_effect = [turn1, turn2]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
assert provider.create_completion.call_count == 2
|
||||
@@ -299,10 +309,13 @@ class TestMultiTurnToolUse:
|
||||
]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
|
||||
@@ -401,6 +401,64 @@ class TestSessionIntegration:
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert "error" in prepared
|
||||
assert "Unknown tool" in prepared["error"]
|
||||
# Error lists available tools so the model can self-correct
|
||||
assert "bash" in prepared["error"]
|
||||
# Surfaces warning to user
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "nonexistent" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
def test_prepare_tool_strips_whitespace_from_name(self, tmp_db):
|
||||
"""Local models may produce tool names with leading/trailing whitespace."""
|
||||
session = self._make_session(mcp_client=None)
|
||||
tc = {
|
||||
"id": "call_strip",
|
||||
"function": {"name": " bash\n", "arguments": '{"command": "echo hi"}'},
|
||||
}
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert prepared["func_name"] == "bash"
|
||||
assert "error" not in prepared
|
||||
|
||||
def test_prepare_tool_malformed_json_surfaces_error(self, tmp_db):
|
||||
"""Malformed JSON args should surface a warning to the user and
|
||||
give the model a hint about expected format."""
|
||||
session = self._make_session(mcp_client=None)
|
||||
tc = {
|
||||
"id": "call_bad",
|
||||
"function": {"name": "bash", "arguments": "{command: echo hi}"},
|
||||
}
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert "error" in prepared
|
||||
assert "JSON parse error" in prepared["error"]
|
||||
assert "command" in prepared["error"] # hint about expected key
|
||||
assert "Please retry" in prepared["error"]
|
||||
# User-facing warning
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "Malformed tool call" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
def test_ensure_tool_call_ids_dict(self, tmp_db):
|
||||
"""_ensure_tool_call_ids fills empty IDs on streaming-style dict."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
tool_calls_acc = {
|
||||
0: {"id": "", "function": {"name": "bash", "arguments": "{}"}},
|
||||
1: {"id": "", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
}
|
||||
ChatSession._ensure_tool_call_ids(tool_calls_acc)
|
||||
ids = [tc["id"] for tc in tool_calls_acc.values()]
|
||||
assert all(id_.startswith("call_") for id_ in ids)
|
||||
assert len(set(ids)) == 2 # unique
|
||||
|
||||
def test_ensure_tool_call_ids_list(self, tmp_db):
|
||||
"""_ensure_tool_call_ids fills empty IDs on list (agent path)."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
tool_calls = [
|
||||
{"id": None, "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "call_existing", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
ChatSession._ensure_tool_call_ids(tool_calls)
|
||||
assert tool_calls[0]["id"].startswith("call_")
|
||||
assert tool_calls[1]["id"] == "call_existing" # preserved
|
||||
|
||||
def test_mcp_command_no_client(self, tmp_db):
|
||||
session = self._make_session(mcp_client=None)
|
||||
@@ -1368,3 +1426,130 @@ class TestShutdownCleanup:
|
||||
assert mgr.get_prompts() == []
|
||||
assert mgr._resource_map == {}
|
||||
assert mgr._prompt_map == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP probe and unreachable server handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTCPProbe:
|
||||
"""MCPClientManager._tcp_probe should fail fast on unreachable servers."""
|
||||
|
||||
def test_tcp_probe_unreachable_raises_connection_error(self):
|
||||
"""Unreachable host raises ConnectionError, not TimeoutError."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError, match="unreachable"):
|
||||
await mgr._tcp_probe("test-server", "http://127.0.0.1:1")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_parses_url_correctly(self):
|
||||
"""Port and host are extracted from the URL."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
# Non-routable port — should fail with ConnectionError
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "https://127.0.0.1:1/mcp")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_default_port_http(self):
|
||||
"""Default port 80 used for http:// URLs without explicit port."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
# Will fail (nothing on port 80), but should not crash on parsing
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "http://127.0.0.1")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_dns_failure(self):
|
||||
"""Unresolvable hostname raises ConnectionError."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "http://this.host.does.not.exist.invalid:8080/mcp")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class TestConnectOneUnreachable:
|
||||
"""_connect_one should handle unreachable HTTP servers gracefully."""
|
||||
|
||||
def test_unreachable_http_server_raises_connection_error(self):
|
||||
"""Unreachable HTTP MCP server raises ConnectionError without spinning."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError, match="unreachable"):
|
||||
await mgr._connect_one(
|
||||
"bad-server",
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:1/mcp",
|
||||
},
|
||||
)
|
||||
|
||||
mgr._loop.run_until_complete(_run())
|
||||
mgr._loop.close()
|
||||
|
||||
# Server should NOT be in sessions (connection failed)
|
||||
assert "bad-server" not in mgr._sessions
|
||||
|
||||
def test_connect_all_continues_after_unreachable_server(self):
|
||||
"""_connect_all logs error and continues to next server."""
|
||||
mgr = MCPClientManager(
|
||||
{
|
||||
"bad": {"type": "http", "url": "http://127.0.0.1:1/mcp"},
|
||||
}
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(mgr._connect_all())
|
||||
loop.close()
|
||||
|
||||
assert "bad" not in mgr._sessions
|
||||
assert "bad" in mgr._last_error
|
||||
|
||||
|
||||
class TestSafeCloseStack:
|
||||
"""_safe_close_stack should suppress errors from broken anyio scopes."""
|
||||
|
||||
def test_suppresses_runtime_error(self):
|
||||
"""RuntimeError from broken cancel scope is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
# Simulate a broken close that raises RuntimeError
|
||||
async def _broken_close():
|
||||
raise RuntimeError("Attempted to exit cancel scope in a different task")
|
||||
|
||||
stack.aclose = _broken_close
|
||||
# Should not raise
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_suppresses_cancelled_error(self):
|
||||
"""CancelledError during close is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
async def _cancel_close():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
stack.aclose = _cancel_close
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -4,6 +4,7 @@ from turnstone.core.metacognition import (
|
||||
NUDGE_COMPLETION,
|
||||
NUDGE_CORRECTION,
|
||||
NUDGE_DENIAL,
|
||||
NUDGE_REPEAT,
|
||||
NUDGE_RESUME,
|
||||
NUDGE_START,
|
||||
NUDGE_TOOL_ERROR,
|
||||
@@ -288,3 +289,22 @@ class TestToolErrorNudge:
|
||||
def test_not_with_zero_memories(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
|
||||
|
||||
|
||||
class TestRepeatNudge:
|
||||
def test_format(self):
|
||||
assert format_nudge("repeat") == NUDGE_REPEAT
|
||||
|
||||
def test_fires(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5) is True
|
||||
|
||||
def test_cooldown(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5) is True
|
||||
assert should_nudge("repeat", state, message_count=6) is False
|
||||
|
||||
def test_no_memory_requirement(self):
|
||||
"""Repeat nudge should fire even with zero memories."""
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5, memory_count=0) is True
|
||||
|
||||
@@ -140,6 +140,34 @@ class TestOpenAIProvider:
|
||||
def test_provider_name(self) -> None:
|
||||
assert self.provider.provider_name == "openai"
|
||||
|
||||
# -- _sanitize_messages ---------------------------------------------------
|
||||
|
||||
def test_sanitize_messages_none_content_no_tool_calls(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": None}]
|
||||
assert self.provider._sanitize_messages(msgs) == [{"role": "assistant", "content": ""}]
|
||||
|
||||
def test_sanitize_messages_none_content_with_tool_calls(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]}]
|
||||
result = self.provider._sanitize_messages(msgs)
|
||||
assert result[0]["content"] is None
|
||||
assert result[0]["tool_calls"] == [{"id": "1"}]
|
||||
|
||||
def test_sanitize_messages_empty_string_passthrough(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": ""}]
|
||||
assert self.provider._sanitize_messages(msgs) == msgs
|
||||
|
||||
def test_sanitize_messages_non_assistant_unchanged(self) -> None:
|
||||
msgs = [{"role": "user", "content": None}]
|
||||
result = self.provider._sanitize_messages(msgs)
|
||||
assert result[0]["content"] is None
|
||||
|
||||
def test_sanitize_messages_does_not_mutate_original(self) -> None:
|
||||
original = {"role": "assistant", "content": None}
|
||||
self.provider._sanitize_messages([original])
|
||||
assert original["content"] is None
|
||||
|
||||
# -- convert_tools --------------------------------------------------------
|
||||
|
||||
def test_convert_tools_passthrough(self) -> None:
|
||||
tools = [
|
||||
{
|
||||
|
||||
@@ -699,6 +699,7 @@ class TestWebSearchGating:
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
patch("turnstone.core.web_search._ddg_available", return_value=False),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
@@ -754,3 +755,150 @@ class TestWebSearchGating:
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
|
||||
class TestMCPToolGating:
|
||||
"""MCP tools should not be offered when no MCP servers provide them."""
|
||||
|
||||
def test_mcp_tools_filtered_without_mcp_client(self, tmp_db, mock_openai_client):
|
||||
"""read_resource and use_prompt excluded when no MCP client."""
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
assert session._mcp_client is None
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
def test_read_resource_filtered_when_no_resources(self, tmp_db, mock_openai_client):
|
||||
"""read_resource excluded when MCP client has no resources."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 2
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" in names
|
||||
|
||||
def test_use_prompt_filtered_when_no_prompts(self, tmp_db, mock_openai_client):
|
||||
"""use_prompt excluded when MCP client has no prompts."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 3
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "use_prompt" not in names
|
||||
assert "read_resource" in names
|
||||
|
||||
def test_mcp_tools_kept_when_servers_have_both(self, tmp_db, mock_openai_client):
|
||||
"""Both tools present when MCP client has resources and prompts."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 1
|
||||
mcp_client.prompt_count = 1
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" in names
|
||||
assert "use_prompt" in names
|
||||
|
||||
def test_mcp_tools_filtered_with_tool_search_active(self, tmp_db, mock_openai_client):
|
||||
"""Gating applies even when tool_search is active (client-side path)."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
tool_search="on",
|
||||
)
|
||||
assert session._tool_search is not None
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
def test_mcp_tools_filtered_with_native_tool_search(self, tmp_db, mock_openai_client):
|
||||
"""Gating applies when provider handles tool search natively."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
tool_search="on",
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_tool_search=True)
|
||||
with patch.object(session, "_get_capabilities", return_value=caps):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for TLS admin API endpoints and CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_manager():
|
||||
"""Create an initialized TLSManager."""
|
||||
import asyncio
|
||||
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr = TLSManager(get_storage())
|
||||
asyncio.run(mgr.init_ca())
|
||||
# Issue a test cert
|
||||
asyncio.run(mgr.issue_console_certs(["test.internal", "localhost"]))
|
||||
return mgr
|
||||
|
||||
|
||||
# ── Admin API endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app(tls_manager):
|
||||
"""Create a minimal Starlette app with TLS endpoints."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
|
||||
from turnstone.console.server import (
|
||||
tls_ca_cert,
|
||||
tls_ca_status,
|
||||
tls_delete_cert,
|
||||
tls_list_certs,
|
||||
tls_renew_cert,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/certs", tls_list_certs),
|
||||
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
|
||||
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
return app
|
||||
|
||||
|
||||
def test_list_certs(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.get("/certs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["certs"]) >= 1
|
||||
assert data["certs"][0]["domain"] == "test.internal"
|
||||
|
||||
|
||||
def test_renew_cert(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.post("/certs/test.internal/renew")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["domain"] == "test.internal"
|
||||
|
||||
|
||||
def test_renew_cert_not_found(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.post("/certs/nonexistent.internal/renew")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_cert(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.delete("/certs/test.internal")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] == "test.internal"
|
||||
# Verify it's gone
|
||||
resp = client.get("/certs")
|
||||
domains = [c["domain"] for c in resp.json()["certs"]]
|
||||
assert "test.internal" not in domains
|
||||
|
||||
|
||||
def test_delete_cert_not_found(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.delete("/certs/nonexistent.internal")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cli_bootstrap(tmp_path):
|
||||
"""Test offline CA bootstrap."""
|
||||
import argparse
|
||||
|
||||
from turnstone.admin import _cmd_tls_bootstrap
|
||||
|
||||
out = tmp_path / "certs"
|
||||
args = argparse.Namespace(out=str(out), issue=["redis.internal", "pg.internal"])
|
||||
_cmd_tls_bootstrap(args)
|
||||
|
||||
assert (out / "ca.pem").exists()
|
||||
assert b"BEGIN CERTIFICATE" in (out / "ca.pem").read_bytes()
|
||||
# Check certs were issued
|
||||
assert (out / "certs" / "redis.internal").exists()
|
||||
assert (out / "certs" / "pg.internal").exists()
|
||||
|
||||
|
||||
def test_cli_bootstrap_no_issue(tmp_path):
|
||||
"""Bootstrap with no --issue creates CA only."""
|
||||
import argparse
|
||||
|
||||
from turnstone.admin import _cmd_tls_bootstrap
|
||||
|
||||
out = tmp_path / "certs"
|
||||
args = argparse.Namespace(out=str(out), issue=[])
|
||||
_cmd_tls_bootstrap(args)
|
||||
|
||||
assert (out / "ca.pem").exists()
|
||||
# No certs dir
|
||||
certs_dir = out / "certs"
|
||||
if certs_dir.exists():
|
||||
assert len(list(certs_dir.iterdir())) == 0
|
||||
|
||||
|
||||
# ── Config parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redis_tls_config_map():
|
||||
"""Redis TLS keys are in the config map."""
|
||||
from turnstone.core.config import _CONFIG_MAP
|
||||
|
||||
redis_map = _CONFIG_MAP["redis"]
|
||||
assert "tls" in redis_map
|
||||
assert "tls_ca" in redis_map
|
||||
assert "tls_cert" in redis_map
|
||||
assert "tls_key" in redis_map
|
||||
|
||||
|
||||
def test_database_ssl_config_map():
|
||||
"""Database SSL keys are in the config map."""
|
||||
from turnstone.core.config import _CONFIG_MAP
|
||||
|
||||
db_map = _CONFIG_MAP["database"]
|
||||
assert "sslmode" in db_map
|
||||
assert "sslrootcert" in db_map
|
||||
assert "sslcert" in db_map
|
||||
assert "sslkey" in db_map
|
||||
|
||||
|
||||
# ── Auth enforcement ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tls_endpoints_require_auth(tls_manager):
|
||||
"""TLS admin endpoints return 401 without auth."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_status, tls_list_certs
|
||||
|
||||
# No auth middleware — request.state.auth_result will be missing
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/certs", tls_list_certs),
|
||||
]
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/ca")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/certs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── SDK TLS params ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sdk_client_cert_requires_both():
|
||||
"""SDK raises ValueError if only one of client_cert/client_key provided."""
|
||||
from turnstone.sdk._base import _BaseClient
|
||||
|
||||
with pytest.raises(ValueError, match="Both client_cert and client_key"):
|
||||
_BaseClient(
|
||||
base_url="http://localhost:8080",
|
||||
client_cert="/path/to/cert.pem",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Both client_cert and client_key"):
|
||||
_BaseClient(
|
||||
base_url="http://localhost:8080",
|
||||
client_key="/path/to/key.pem",
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for TLSClient — service node certificate provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
# ── Console URL discovery ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_discover_console_url():
|
||||
"""TLSClient discovers console URL from services table."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
storage = get_storage()
|
||||
storage.register_service("console", "console", "http://console:8080")
|
||||
|
||||
client = TLSClient(storage=storage, hostnames=["node-1"])
|
||||
url = client._discover_console_url()
|
||||
assert url == "http://console:8080"
|
||||
|
||||
|
||||
def test_discover_console_url_missing():
|
||||
"""TLSClient raises if no console registered."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
|
||||
with pytest.raises(RuntimeError, match="No console service found"):
|
||||
client._discover_console_url()
|
||||
|
||||
|
||||
def test_explicit_console_url_skips_discovery():
|
||||
"""When console_url is provided, discovery is skipped."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
storage=get_storage(),
|
||||
console_url="http://explicit:9090",
|
||||
hostnames=["node-1"],
|
||||
)
|
||||
assert client._console_url == "http://explicit:9090"
|
||||
|
||||
|
||||
# ── SSL context construction ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_none_before_init():
|
||||
"""SSL contexts are None before init()."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
storage=get_storage(),
|
||||
console_url="http://localhost:8080",
|
||||
hostnames=["node-1"],
|
||||
)
|
||||
assert client.get_server_ssl_context() is None
|
||||
assert client.get_client_ssl_context() is None
|
||||
assert not client.initialized
|
||||
|
||||
|
||||
# ── Backward compatibility ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bridge_tls_defaults():
|
||||
"""Bridge with default TLS params works without changes."""
|
||||
from turnstone.mq.bridge import Bridge
|
||||
|
||||
# Default: tls_verify=True, tls_cert=None — no mTLS
|
||||
bridge = Bridge(server_url="http://localhost:8080")
|
||||
assert bridge._tls_verify is True
|
||||
assert bridge._tls_cert is None
|
||||
|
||||
|
||||
def test_collector_tls_defaults():
|
||||
"""Collector with default TLS params works without changes."""
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
|
||||
broker_mock = MagicMock()
|
||||
collector = ClusterCollector(broker=broker_mock)
|
||||
# Should create httpx client without errors
|
||||
assert collector._http_client is not None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for TLSManager — console CA and ACME server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_manager():
|
||||
"""Create a TLSManager backed by test storage."""
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
return TLSManager(get_storage())
|
||||
|
||||
|
||||
# ── CA initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init_ca(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
assert tls_manager.ca_initialized
|
||||
root_pem = tls_manager.get_root_cert_pem()
|
||||
assert b"BEGIN CERTIFICATE" in root_pem
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init_ca_persists(tls_manager):
|
||||
"""CA root survives re-initialization (loaded from storage)."""
|
||||
await tls_manager.init_ca()
|
||||
pem1 = tls_manager.get_root_cert_pem()
|
||||
|
||||
# Create a new manager on the same storage
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr2 = TLSManager(get_storage())
|
||||
await mgr2.init_ca()
|
||||
pem2 = mgr2.get_root_cert_pem()
|
||||
|
||||
assert pem1 == pem2 # Same CA loaded from DB
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_responder_before_init(tls_manager):
|
||||
with pytest.raises(RuntimeError, match="CA not initialized"):
|
||||
tls_manager.get_responder()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_responder(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
responder = tls_manager.get_responder()
|
||||
assert responder is not None
|
||||
# Should be an ASGI app (callable)
|
||||
assert callable(responder)
|
||||
|
||||
|
||||
# ── Cert issuance ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_console_certs_internal(tls_manager):
|
||||
"""Console certs issued from internal CA when no external directory."""
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal", "localhost"])
|
||||
assert tls_manager.internal_bundle is not None
|
||||
assert tls_manager.frontend_bundle is not None
|
||||
assert tls_manager.internal_bundle.domain == "console.internal"
|
||||
assert b"BEGIN CERTIFICATE" in tls_manager.internal_bundle.cert_pem
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_console_certs_persists(tls_manager):
|
||||
"""Certs loaded from storage on re-issue."""
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal"])
|
||||
bundle1 = tls_manager.internal_bundle
|
||||
|
||||
# New manager, same storage
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr2 = TLSManager(get_storage())
|
||||
await mgr2.init_ca()
|
||||
await mgr2.issue_console_certs(["console.internal"])
|
||||
bundle2 = mgr2.internal_bundle
|
||||
|
||||
assert bundle1.cert_pem == bundle2.cert_pem
|
||||
|
||||
|
||||
# ── SSL contexts ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_none_before_certs(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
assert tls_manager.get_server_ssl_context() is None
|
||||
assert tls_manager.get_client_ssl_context() is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_after_certs(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal"])
|
||||
server_ctx = tls_manager.get_server_ssl_context()
|
||||
client_ctx = tls_manager.get_client_ssl_context()
|
||||
assert server_ctx is not None
|
||||
assert client_ctx is not None
|
||||
import ssl
|
||||
|
||||
assert isinstance(server_ctx, ssl.SSLContext)
|
||||
assert isinstance(client_ctx, ssl.SSLContext)
|
||||
|
||||
|
||||
# ── Root cert endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tls_ca_cert_endpoint(tls_manager):
|
||||
"""Test the CA cert download endpoint via test client."""
|
||||
await tls_manager.init_ca()
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
||||
|
||||
# Middleware that grants full access (config-token style: no user_id)
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/ca", tls_ca_status),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# CA cert download
|
||||
resp = client.get("/ca.pem")
|
||||
assert resp.status_code == 200
|
||||
assert b"BEGIN CERTIFICATE" in resp.content
|
||||
assert resp.headers["content-type"] == "application/x-pem-file"
|
||||
|
||||
# CA status
|
||||
resp = client.get("/ca")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["ca_cn"] == "Turnstone CA"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tls_endpoints_disabled():
|
||||
"""Endpoints return 404/disabled when TLS not enabled."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/ca", tls_ca_status),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
# No tls_manager on state
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/ca.pem")
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = client.get("/ca")
|
||||
data = resp.json()
|
||||
assert data["enabled"] is False
|
||||
|
||||
|
||||
# ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_dispatcher_wired(tls_manager):
|
||||
"""Verify the event dispatcher has subscribers."""
|
||||
assert tls_manager._event_dispatcher is not None
|
||||
# Should have at least 4 subscriptions (issued, renewed, expiring, failed)
|
||||
# The exact check depends on lacme's EventDispatcher internals,
|
||||
# so just verify the dispatcher exists and the manager initializes cleanly
|
||||
await tls_manager.init_ca()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for TLS storage backend and lacme Store adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
# ── Account keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_account_key():
|
||||
s = get_storage()
|
||||
s.save_tls_account_key(
|
||||
"default",
|
||||
"-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----",
|
||||
)
|
||||
result = s.load_tls_account_key("default")
|
||||
assert result is not None
|
||||
assert "EC PRIVATE KEY" in result
|
||||
|
||||
|
||||
def test_load_account_key_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_account_key("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_account_key_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_account_key("default", "key-v1")
|
||||
s.save_tls_account_key("default", "key-v2")
|
||||
assert s.load_tls_account_key("default") == "key-v2"
|
||||
|
||||
|
||||
# ── CA ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_ca():
|
||||
s = get_storage()
|
||||
s.save_tls_ca("Turnstone CA", "cert-pem-data", "key-pem-data")
|
||||
result = s.load_tls_ca("Turnstone CA")
|
||||
assert result is not None
|
||||
assert result["cert_pem"] == "cert-pem-data"
|
||||
assert result["key_pem"] == "key-pem-data"
|
||||
assert result["name"] == "Turnstone CA"
|
||||
|
||||
|
||||
def test_load_ca_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_ca("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_ca_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_ca("CA", "cert-v1", "key-v1")
|
||||
s.save_tls_ca("CA", "cert-v2", "key-v2")
|
||||
result = s.load_tls_ca("CA")
|
||||
assert result["cert_pem"] == "cert-v2"
|
||||
assert result["key_pem"] == "key-v2"
|
||||
|
||||
|
||||
# ── Certificates ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_cert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert(
|
||||
domain="node-1.internal",
|
||||
cert_pem="cert-data",
|
||||
fullchain_pem="fullchain-data",
|
||||
key_pem="key-data",
|
||||
issued_at="2026-03-25T00:00:00",
|
||||
expires_at="2026-03-27T00:00:00",
|
||||
meta=json.dumps({"domains": ["node-1.internal", "10.0.1.5"]}),
|
||||
)
|
||||
result = s.load_tls_cert("node-1.internal")
|
||||
assert result is not None
|
||||
assert result["domain"] == "node-1.internal"
|
||||
assert result["cert_pem"] == "cert-data"
|
||||
assert result["fullchain_pem"] == "fullchain-data"
|
||||
assert result["key_pem"] == "key-data"
|
||||
assert result["issued_at"] == "2026-03-25T00:00:00"
|
||||
assert result["expires_at"] == "2026-03-27T00:00:00"
|
||||
meta = json.loads(result["meta"])
|
||||
assert meta["domains"] == ["node-1.internal", "10.0.1.5"]
|
||||
|
||||
|
||||
def test_load_cert_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_cert("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_cert_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("d", "c1", "f1", "k1", "2026-01-01", "2026-01-02")
|
||||
s.save_tls_cert("d", "c2", "f2", "k2", "2026-02-01", "2026-02-02")
|
||||
result = s.load_tls_cert("d")
|
||||
assert result["cert_pem"] == "c2"
|
||||
assert result["issued_at"] == "2026-02-01"
|
||||
|
||||
|
||||
def test_list_certs_empty():
|
||||
s = get_storage()
|
||||
assert s.list_tls_certs() == []
|
||||
|
||||
|
||||
def test_list_certs():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("alpha.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
s.save_tls_cert("beta.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
certs = s.list_tls_certs()
|
||||
assert len(certs) == 2
|
||||
assert certs[0]["domain"] == "alpha.internal" # sorted by domain
|
||||
assert certs[1]["domain"] == "beta.internal"
|
||||
|
||||
|
||||
def test_delete_cert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("d", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
assert s.delete_tls_cert("d") is True
|
||||
assert s.load_tls_cert("d") is None
|
||||
|
||||
|
||||
def test_delete_cert_missing():
|
||||
s = get_storage()
|
||||
assert s.delete_tls_cert("nonexistent") is False
|
||||
|
||||
|
||||
# ── StorageStore adapter ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_adapter():
|
||||
"""Create a StorageStore backed by the test database."""
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
return StorageStore(get_storage())
|
||||
|
||||
|
||||
def test_adapter_save_load_ca(store_adapter):
|
||||
store_adapter.save_ca("test-ca", b"cert-pem", b"key-pem")
|
||||
result = store_adapter.load_ca("test-ca")
|
||||
assert result is not None
|
||||
cert_pem, key_pem = result
|
||||
assert cert_pem == b"cert-pem"
|
||||
assert key_pem == b"key-pem"
|
||||
|
||||
|
||||
def test_adapter_load_ca_missing(store_adapter):
|
||||
assert store_adapter.load_ca("missing") is None
|
||||
|
||||
|
||||
def test_adapter_save_load_cert(store_adapter):
|
||||
lacme = pytest.importorskip("lacme")
|
||||
now = datetime.now(UTC)
|
||||
bundle = lacme.CertBundle(
|
||||
domain="test.internal",
|
||||
domains=("test.internal", "10.0.1.1"),
|
||||
cert_pem=b"cert",
|
||||
fullchain_pem=b"fullchain",
|
||||
key_pem=b"key",
|
||||
issued_at=now,
|
||||
expires_at=now,
|
||||
)
|
||||
store_adapter.save_cert(bundle)
|
||||
loaded = store_adapter.load_cert("test.internal")
|
||||
assert loaded is not None
|
||||
assert loaded.domain == "test.internal"
|
||||
assert loaded.domains == ("test.internal", "10.0.1.1")
|
||||
assert loaded.cert_pem == b"cert"
|
||||
assert loaded.fullchain_pem == b"fullchain"
|
||||
assert loaded.key_pem == b"key"
|
||||
|
||||
|
||||
def test_adapter_list_certs(store_adapter):
|
||||
lacme = pytest.importorskip("lacme")
|
||||
now = datetime.now(UTC)
|
||||
for name in ["alpha", "beta"]:
|
||||
bundle = lacme.CertBundle(
|
||||
domain=f"{name}.internal",
|
||||
domains=(f"{name}.internal",),
|
||||
cert_pem=b"c",
|
||||
fullchain_pem=b"f",
|
||||
key_pem=b"k",
|
||||
issued_at=now,
|
||||
expires_at=now,
|
||||
)
|
||||
store_adapter.save_cert(bundle)
|
||||
certs = store_adapter.list_certs()
|
||||
assert len(certs) == 2
|
||||
assert certs[0].domain == "alpha.internal"
|
||||
|
||||
|
||||
def test_adapter_load_cert_missing(store_adapter):
|
||||
assert store_adapter.load_cert("missing") is None
|
||||
|
||||
|
||||
def test_adapter_account_key_roundtrip(store_adapter):
|
||||
"""Test account key save/load with real cryptography objects."""
|
||||
pytest.importorskip("lacme")
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
store_adapter.save_account_key(key)
|
||||
loaded = store_adapter.load_account_key()
|
||||
assert loaded is not None
|
||||
# Verify it's a usable EC key
|
||||
assert loaded.key_size == key.key_size
|
||||
|
||||
|
||||
def test_adapter_account_key_missing(store_adapter):
|
||||
assert store_adapter.load_account_key() is None
|
||||
@@ -0,0 +1,124 @@
|
||||
# turnstone.toml — shared bootstrap configuration
|
||||
#
|
||||
# This file is read once at startup. Values here are overridden by
|
||||
# environment variables, which are in turn overridden by CLI flags.
|
||||
#
|
||||
# All sections are optional. Missing sections use binary defaults.
|
||||
# Config file location precedence:
|
||||
# 1. --config flag
|
||||
# 2. $TURNSTONE_CONFIG env var
|
||||
# 3. ~/.config/turnstone/config.toml
|
||||
|
||||
# --- LLM API (turnstone, node, eval) ---
|
||||
|
||||
[api]
|
||||
# base_url = "" # API endpoint; empty = binary default
|
||||
# api_key = "" # env: OPENAI_API_KEY or ANTHROPIC_API_KEY
|
||||
|
||||
# --- Default Model (turnstone, node, eval) ---
|
||||
|
||||
[model]
|
||||
# name = "" # Model ID; empty = provider default (gpt-5 / claude-sonnet-4)
|
||||
# temperature = 0.0 # 0 = provider default
|
||||
# reasoning_effort = "" # "low", "medium", "high", "max"
|
||||
# context_window = 0 # 0 = auto-detect from provider capabilities
|
||||
# max_tokens = 0 # 0 = provider default
|
||||
|
||||
# --- Named Models (turnstone, node, eval) ---
|
||||
# Define model aliases with per-model overrides. Useful for local model
|
||||
# servers or mixing providers. Reference by name with --model flag.
|
||||
#
|
||||
# [models.local]
|
||||
# name = "llama-3-70b"
|
||||
# provider = "openai"
|
||||
# base_url = "http://localhost:8000/v1"
|
||||
# context_window = 8192
|
||||
#
|
||||
# [models.local.capabilities]
|
||||
# supports_vision = false
|
||||
# supports_web_search = false
|
||||
#
|
||||
# [models.claude]
|
||||
# name = "claude-opus-4-6"
|
||||
# provider = "anthropic"
|
||||
|
||||
# --- Database (turnstone, node, console) ---
|
||||
|
||||
[database]
|
||||
# url = "" # postgres://user:pass@host/db or /path/to.db
|
||||
# env: TURNSTONE_DB_URL
|
||||
# SSL params (passed through to SQLAlchemy connection):
|
||||
# sslmode = "prefer" # disable, allow, prefer, require, verify-ca, verify-full
|
||||
# sslrootcert = "" # path to CA cert for verify-ca/verify-full
|
||||
# sslcert = "" # path to client cert (mTLS)
|
||||
# sslkey = "" # path to client key (mTLS)
|
||||
|
||||
# --- Redis (bridge, console, channel) ---
|
||||
|
||||
[redis]
|
||||
# url = "" # redis://host:6379/0 or rediss://host:6380/0
|
||||
# env: TURNSTONE_REDIS_URL
|
||||
# TLS params (passed through to Redis connection):
|
||||
# tls = false # enable TLS (also auto-enabled by rediss:// scheme)
|
||||
# tls_ca = "" # path to CA cert
|
||||
# tls_cert = "" # path to client cert (mTLS)
|
||||
# tls_key = "" # path to client key (mTLS)
|
||||
|
||||
# --- Auth (node, console) ---
|
||||
|
||||
[auth]
|
||||
# enabled = true # env: TURNSTONE_AUTH_ENABLED
|
||||
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
|
||||
# env: TURNSTONE_JWT_SECRET
|
||||
# token = "" # Static config token for full access
|
||||
# env: TURNSTONE_AUTH_TOKEN
|
||||
|
||||
# --- Logging (turnstone, node, console) ---
|
||||
|
||||
[log]
|
||||
# level = "" # "debug", "info", "warn", "error"
|
||||
# empty = binary default (warn for CLI, info for servers)
|
||||
# env: TURNSTONE_LOG_LEVEL
|
||||
# json = false # JSON output; auto-enabled when stderr is not a TTY
|
||||
|
||||
# --- Session (turnstone, node) ---
|
||||
|
||||
[session]
|
||||
# instructions = "" # Default system message
|
||||
# compact_max_tokens = 32768 # Max tokens for context compaction summary
|
||||
# auto_compact_pct = 0.8 # Trigger compaction at this % of context window
|
||||
|
||||
# --- Tools (turnstone, node) ---
|
||||
|
||||
[tools]
|
||||
# timeout = 120 # Tool execution timeout in seconds
|
||||
# skip_permissions = false # Auto-approve all tool calls
|
||||
|
||||
# --- Judge (turnstone, node) ---
|
||||
|
||||
[judge]
|
||||
# enabled = true # Enable intent validation
|
||||
# confidence_threshold = 0.7 # Minimum confidence for heuristic verdicts
|
||||
# output_guard = true # Scan tool output for security signals
|
||||
# redact_secrets = true # Redact detected credentials in output
|
||||
|
||||
# --- Memory (turnstone, node) ---
|
||||
|
||||
[memory]
|
||||
# relevance_k = 5 # Top-K memories for context injection
|
||||
# fetch_limit = 50 # Max memories to fetch for ranking
|
||||
# max_content = 32768 # Max memory content size in chars
|
||||
# nudge_cooldown = 300 # Min seconds between metacognitive nudges
|
||||
# nudges = true # Enable memory nudges
|
||||
|
||||
# --- MCP (turnstone, node) ---
|
||||
|
||||
[mcp]
|
||||
# config_path = "" # Path to MCP servers config file (JSON)
|
||||
# refresh_interval = 14400 # Refresh interval in seconds (default: 4h)
|
||||
|
||||
# --- Server (node, console) ---
|
||||
|
||||
[server]
|
||||
# max_workstreams = 50 # Maximum concurrent workstreams per node
|
||||
# env: TURNSTONE_MAX_WORKSTREAMS
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.8.8"
|
||||
__version__ = "0.9.1"
|
||||
|
||||
@@ -142,6 +142,189 @@ def _cmd_revoke_token(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_tls_bootstrap(args: argparse.Namespace) -> None:
|
||||
"""Initialize CA and issue certs offline."""
|
||||
try:
|
||||
from lacme import CertificateAuthority, FileStore
|
||||
except ImportError:
|
||||
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(PermissionError):
|
||||
os.chmod(out_dir, 0o700) # Restrict access — contains CA private key
|
||||
|
||||
store = FileStore(str(out_dir))
|
||||
ca = CertificateAuthority(store, name="turnstone")
|
||||
ca.init(cn="Turnstone CA", validity_days=3650)
|
||||
print(f"CA initialized in {out_dir} (permissions: 0700)")
|
||||
|
||||
# Write CA cert to a well-known location
|
||||
ca_cert_path = out_dir / "ca.pem"
|
||||
ca_cert_path.write_bytes(ca.root_cert_pem)
|
||||
with contextlib.suppress(PermissionError):
|
||||
os.chmod(ca_cert_path, 0o644)
|
||||
print(f"CA cert: {ca_cert_path}")
|
||||
|
||||
# Issue certs for requested domains
|
||||
for domain in args.issue:
|
||||
bundle = ca.issue([domain], validity_hours=48)
|
||||
store.save_cert(bundle)
|
||||
cert_dir = out_dir / "certs" / domain
|
||||
print(f"Issued: {domain} -> {cert_dir}")
|
||||
|
||||
print(f"\nBootstrap complete. {len(args.issue)} cert(s) issued.")
|
||||
print(f"CA and certs written to: {out_dir}")
|
||||
|
||||
|
||||
def _cmd_tls_issue(args: argparse.Namespace) -> None:
|
||||
"""Request a cert from the console's ACME endpoint."""
|
||||
try:
|
||||
from lacme import SyncClient
|
||||
except ImportError:
|
||||
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
domains = [args.domain] + args.san
|
||||
directory_url = f"{console_url}/acme/directory"
|
||||
print(f"Requesting cert for {domains} from {directory_url}")
|
||||
|
||||
client = SyncClient(
|
||||
directory_url=directory_url,
|
||||
allow_insecure=True,
|
||||
)
|
||||
bundle = client.issue(domains)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "cert.pem").write_bytes(bundle.cert_pem)
|
||||
(out_dir / "fullchain.pem").write_bytes(bundle.fullchain_pem)
|
||||
(out_dir / "key.pem").write_bytes(bundle.key_pem)
|
||||
os.chmod(out_dir / "key.pem", 0o600)
|
||||
|
||||
print(f"Certificate written to {out_dir}/")
|
||||
print(" cert.pem (leaf certificate)")
|
||||
print(" fullchain.pem (cert + chain)")
|
||||
print(" key.pem (private key, 0600)")
|
||||
|
||||
|
||||
def _cmd_tls_ca_cert(args: argparse.Namespace) -> None:
|
||||
"""Download the CA root certificate from the console."""
|
||||
import httpx
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
# Use plain HTTP for bootstrap (node may not have CA cert yet)
|
||||
# WARNING: This is trust-on-first-use (TOFU) — verify the fingerprint
|
||||
base = console_url.replace("https://", "http://")
|
||||
url = f"{base}/acme/ca.pem"
|
||||
print(f"Fetching CA cert from {url}")
|
||||
print("WARNING: Fetching over plain HTTP — verify the fingerprint below")
|
||||
|
||||
resp = httpx.get(url)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Show fingerprint for out-of-band verification
|
||||
import hashlib
|
||||
|
||||
fingerprint = hashlib.sha256(resp.content).hexdigest()
|
||||
print(f"CA cert SHA-256: {fingerprint}")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
Path(args.out).write_bytes(resp.content)
|
||||
print(f"CA cert written to {args.out}")
|
||||
|
||||
|
||||
def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
"""List certificates from the console."""
|
||||
import httpx
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
url = f"{console_url}/v1/api/admin/tls/certs"
|
||||
headers = {}
|
||||
token = getattr(args, "auth_token", "") or _get_config_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = httpx.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
certs = data.get("certs", [])
|
||||
if not certs:
|
||||
print("No certificates issued.")
|
||||
return
|
||||
|
||||
print(f"{'DOMAIN':<30s} {'ISSUED':<22s} {'EXPIRES':<22s}")
|
||||
print("-" * 74)
|
||||
for c in certs:
|
||||
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
|
||||
|
||||
|
||||
def _get_config_token() -> str:
|
||||
"""Try to load auth token from config.toml or environment."""
|
||||
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
cfg = load_config("auth")
|
||||
return str(cfg.get("token", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _discover_console_url() -> str:
|
||||
"""Discover console URL from the services table."""
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
print(
|
||||
"No storage configured. Use --console-url or run from a "
|
||||
"directory with a turnstone database.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
consoles = storage.list_services("console", max_age_seconds=3600)
|
||||
if not consoles:
|
||||
print(
|
||||
"No console found in services table. Use --console-url explicitly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return consoles[0]["url"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for turnstone-admin CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -171,6 +354,35 @@ def main() -> None:
|
||||
p_rt = sub.add_parser("revoke-token", help="Revoke an API token")
|
||||
p_rt.add_argument("--token-id", required=True, help="Token ID to revoke")
|
||||
|
||||
# TLS subcommands
|
||||
p_bootstrap = sub.add_parser(
|
||||
"tls-bootstrap",
|
||||
help="Initialize CA and issue certs offline (no running console needed)",
|
||||
)
|
||||
p_bootstrap.add_argument("--out", required=True, help="Output directory for PEM files")
|
||||
p_bootstrap.add_argument(
|
||||
"--issue",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Domain to issue cert for (repeatable)",
|
||||
)
|
||||
|
||||
p_issue = sub.add_parser("tls-issue", help="Request cert from console ACME")
|
||||
p_issue.add_argument("domain", help="Primary domain for the certificate")
|
||||
p_issue.add_argument("--san", action="append", default=[], help="Additional SAN (repeatable)")
|
||||
p_issue.add_argument("--out", default=".", help="Output directory for PEM files")
|
||||
p_issue.add_argument(
|
||||
"--console-url", default="", help="Console URL (discovered from DB if empty)"
|
||||
)
|
||||
|
||||
p_cacert = sub.add_parser("tls-ca-cert", help="Download CA root certificate")
|
||||
p_cacert.add_argument("--out", default="ca.pem", help="Output file path")
|
||||
p_cacert.add_argument("--console-url", default="", help="Console URL")
|
||||
|
||||
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
|
||||
p_tlslist.add_argument("--console-url", default="", help="Console URL")
|
||||
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -182,5 +394,9 @@ def main() -> None:
|
||||
"list-users": _cmd_list_users,
|
||||
"list-tokens": _cmd_list_tokens,
|
||||
"revoke-token": _cmd_revoke_token,
|
||||
"tls-bootstrap": _cmd_tls_bootstrap,
|
||||
"tls-issue": _cmd_tls_issue,
|
||||
"tls-ca-cert": _cmd_tls_ca_cert,
|
||||
"tls-list": _cmd_tls_list,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -843,6 +843,39 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: TLS / ACME ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca",
|
||||
"GET",
|
||||
"CA status: initialization state, CN, cert count, cert inventory",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca.pem",
|
||||
"GET",
|
||||
"Download CA root certificate (PEM format)",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs",
|
||||
"GET",
|
||||
"List all issued TLS certificates",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs/{domain}/renew",
|
||||
"POST",
|
||||
"Force-renew a certificate by domain",
|
||||
error_codes=[404, 500],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs/{domain}",
|
||||
"DELETE",
|
||||
"Delete a certificate by domain",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Observability ---
|
||||
EndpointSpec(
|
||||
"/health",
|
||||
|
||||
@@ -41,6 +41,11 @@ class CommandRequest(BaseModel):
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description="Force cancel: abandon the stuck worker thread immediately. "
|
||||
"Use when cooperative cancel has not resolved within a few seconds.",
|
||||
)
|
||||
|
||||
|
||||
class CreateWorkstreamRequest(BaseModel):
|
||||
|
||||
@@ -58,6 +58,11 @@ def main() -> None:
|
||||
help="HTTP server port (default: $TURNSTONE_CHANNEL_PORT or 8091)",
|
||||
)
|
||||
|
||||
# -- TLS -----------------------------------------------------------------
|
||||
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file for HTTPS")
|
||||
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
|
||||
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
|
||||
|
||||
# -- Auth ----------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
@@ -189,7 +194,8 @@ def main() -> None:
|
||||
advertise_host = socket.gethostname()
|
||||
else:
|
||||
advertise_host = args.http_host
|
||||
advertise_url = f"http://{advertise_host}:{args.http_port}"
|
||||
scheme = "https" if args.ssl_certfile else "http"
|
||||
advertise_url = f"{scheme}://{advertise_host}:{args.http_port}"
|
||||
service_url = advertise_url
|
||||
|
||||
# Register in service registry
|
||||
@@ -209,11 +215,25 @@ def main() -> None:
|
||||
except Exception:
|
||||
log.exception("channel.heartbeat_failed")
|
||||
|
||||
# TLS: use cert files if available (from bootstrap or TLSClient)
|
||||
ssl_certfile = getattr(args, "ssl_certfile", None)
|
||||
ssl_keyfile = getattr(args, "ssl_keyfile", None)
|
||||
ssl_ca_certs = getattr(args, "ssl_ca_certs", None)
|
||||
if bool(ssl_certfile) != bool(ssl_keyfile):
|
||||
print(
|
||||
"Both --ssl-certfile and --ssl-keyfile are required for TLS",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
channel_app,
|
||||
host=args.http_host,
|
||||
port=args.http_port,
|
||||
log_level="warning",
|
||||
ssl_certfile=ssl_certfile,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_ca_certs=ssl_ca_certs,
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ class ClusterCollector:
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
):
|
||||
self._broker = broker
|
||||
self._prefix = prefix
|
||||
@@ -86,12 +88,33 @@ class ClusterCollector:
|
||||
max_connections=max_poll_workers + 10,
|
||||
max_keepalive_connections=min(max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
)
|
||||
|
||||
# SSE fan-out to browser clients
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
|
||||
def upgrade_tls(self, tls_verify: Any = True, tls_cert: tuple[str, str] | None = None) -> None:
|
||||
"""Replace the httpx client with one using mTLS context."""
|
||||
old = self._http_client
|
||||
self._http_client = httpx.Client(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10, read=self._http_timeout, write=5, pool=self._http_timeout
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=self._max_poll_workers + 10,
|
||||
max_keepalive_connections=min(self._max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
)
|
||||
# Don't close old client — concurrent _fetch_node() threads may still
|
||||
# be using it. It will be GC'd once all references are released, and
|
||||
# the current client is closed in stop().
|
||||
del old
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
|
||||
+314
-2
@@ -727,18 +727,25 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
config_store.get("cluster.node_fan_out_limit") if config_store else _NODE_FAN_OUT_LIMIT
|
||||
)
|
||||
app.state.fan_out_limit = fan_out
|
||||
# Build mTLS context for proxy clients if TLS is enabled
|
||||
_tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
_proxy_ssl = _tls_mgr.get_client_ssl_context() if _tls_mgr and _tls_mgr.ca_initialized else None
|
||||
_proxy_verify: Any = _proxy_ssl if _proxy_ssl else True
|
||||
|
||||
app.state.proxy_client = httpx.AsyncClient(
|
||||
timeout=30,
|
||||
limits=httpx.Limits(
|
||||
max_connections=fan_out + 50,
|
||||
max_keepalive_connections=min(fan_out // 4, 100),
|
||||
),
|
||||
verify=_proxy_verify,
|
||||
)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(
|
||||
max_connections=1100, max_keepalive_connections=100, keepalive_expiry=30
|
||||
),
|
||||
verify=_proxy_verify,
|
||||
)
|
||||
# Start scheduler if configured
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
@@ -769,8 +776,84 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
# Register console in service registry so other services can discover it
|
||||
console_url = getattr(app.state, "console_url", "")
|
||||
_console_heartbeat_task: Any = None
|
||||
if console_url and storage:
|
||||
try:
|
||||
storage.register_service("console", "console", console_url)
|
||||
|
||||
# Periodic heartbeat to keep the registration alive
|
||||
import asyncio
|
||||
|
||||
async def _console_heartbeat() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
storage.heartbeat_service("console", "console")
|
||||
except Exception:
|
||||
log.warning("console.heartbeat_failed", exc_info=True)
|
||||
|
||||
_console_heartbeat_task = asyncio.create_task(_console_heartbeat())
|
||||
except Exception:
|
||||
log.warning("Failed to register console service", exc_info=True)
|
||||
|
||||
# TLS: init CA, issue console certs, start renewal
|
||||
tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
if tls_mgr is not None:
|
||||
import socket
|
||||
|
||||
try:
|
||||
if not tls_mgr.ca_initialized:
|
||||
await tls_mgr.init_ca()
|
||||
hostname = socket.getfqdn()
|
||||
cert_hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
cert_hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
await tls_mgr.issue_console_certs(cert_hostnames)
|
||||
await tls_mgr.start_renewal()
|
||||
# Re-create proxy clients with mTLS context now that certs are ready
|
||||
client_ctx = tls_mgr.get_client_ssl_context()
|
||||
if client_ctx:
|
||||
await app.state.proxy_client.aclose()
|
||||
await app.state.proxy_sse_client.aclose()
|
||||
app.state.proxy_client = httpx.AsyncClient(
|
||||
timeout=30,
|
||||
limits=httpx.Limits(
|
||||
max_connections=app.state.fan_out_limit + 50,
|
||||
max_keepalive_connections=min(app.state.fan_out_limit // 4, 100),
|
||||
),
|
||||
verify=client_ctx,
|
||||
)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(
|
||||
max_connections=1100,
|
||||
max_keepalive_connections=100,
|
||||
keepalive_expiry=30,
|
||||
),
|
||||
verify=client_ctx,
|
||||
)
|
||||
# Upgrade collector httpx client for mTLS node polling
|
||||
app.state.collector.upgrade_tls(tls_verify=client_ctx)
|
||||
log.info("tls.proxy_clients.upgraded")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed — continuing without TLS", exc_info=True)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
if _console_heartbeat_task is not None:
|
||||
_console_heartbeat_task.cancel()
|
||||
# Deregister console from services table
|
||||
if console_url and storage:
|
||||
try:
|
||||
storage.deregister_service("console", "console")
|
||||
except Exception:
|
||||
log.debug("console.deregister_failed", exc_info=True)
|
||||
tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
if tls_mgr is not None:
|
||||
await tls_mgr.stop_renewal()
|
||||
if scheduler is not None:
|
||||
scheduler.stop()
|
||||
await app.state.proxy_sse_client.aclose()
|
||||
@@ -4580,6 +4663,161 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"imported": imported, "skipped": skipped, "errors": errors})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def tls_ca_cert(request: Request) -> Response:
|
||||
"""GET /v1/api/admin/tls/ca.pem — Download CA root certificate."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
return Response(
|
||||
content=mgr.get_root_cert_pem(),
|
||||
media_type="application/x-pem-file",
|
||||
headers={"Content-Disposition": "attachment; filename=turnstone-ca.pem"},
|
||||
)
|
||||
|
||||
|
||||
async def tls_ca_status(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/tls/ca — CA status."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"enabled": False})
|
||||
from turnstone.console.tls import _CA_CN
|
||||
|
||||
certs = mgr.list_certs()
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": True,
|
||||
"ca_cn": _CA_CN,
|
||||
"cert_count": len(certs),
|
||||
"certs": [
|
||||
{
|
||||
"domain": c.domain,
|
||||
"issued_at": c.issued_at.isoformat(),
|
||||
"expires_at": c.expires_at.isoformat(),
|
||||
}
|
||||
for c in certs
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def tls_list_certs(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/tls/certs — List issued certificates."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"certs": []})
|
||||
certs = mgr.list_certs()
|
||||
return JSONResponse(
|
||||
{
|
||||
"certs": [
|
||||
{
|
||||
"domain": c.domain,
|
||||
"domains": list(c.domains),
|
||||
"issued_at": c.issued_at.isoformat(),
|
||||
"expires_at": c.expires_at.isoformat(),
|
||||
}
|
||||
for c in certs
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def tls_renew_cert(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/tls/certs/{domain}/renew — Force cert renewal."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
domain = request.path_params["domain"]
|
||||
try:
|
||||
bundle = mgr.renew_cert(domain)
|
||||
return JSONResponse(
|
||||
{
|
||||
"domain": bundle.domain,
|
||||
"issued_at": bundle.issued_at.isoformat(),
|
||||
"expires_at": bundle.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
async def tls_delete_cert(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/tls/certs/{domain} — Delete a certificate."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
domain = request.path_params["domain"]
|
||||
if not mgr.delete_cert(domain):
|
||||
return JSONResponse({"error": f"No cert for {domain}"}, status_code=404)
|
||||
return JSONResponse({"deleted": domain})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConfigStore env seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_config_from_env(config_store: Any, storage: Any) -> None:
|
||||
"""Seed ConfigStore settings from environment variables.
|
||||
|
||||
Checks for ``TURNSTONE_{SECTION}_{KEY}`` env vars and writes them
|
||||
to ConfigStore if they aren't already set. This allows container
|
||||
deployments to configure settings before the admin UI is available.
|
||||
|
||||
Only seeds known settings from the registry to avoid storing garbage.
|
||||
Uses config_store.set() for proper validation, serialization, and
|
||||
cache invalidation.
|
||||
"""
|
||||
from turnstone.core.settings_registry import SETTINGS
|
||||
|
||||
for key in SETTINGS:
|
||||
env_name = "TURNSTONE_" + key.replace(".", "_").upper()
|
||||
env_val = os.environ.get(env_name)
|
||||
if env_val is None:
|
||||
continue
|
||||
# Only seed if not already stored (check raw storage to avoid
|
||||
# config_store cache, which may not reflect DB state yet)
|
||||
existing = storage.get_system_setting(key)
|
||||
if existing is not None:
|
||||
continue
|
||||
try:
|
||||
config_store.set(key, env_val, changed_by="env")
|
||||
log.info("config.seeded_from_env: %s from %s", key, env_name)
|
||||
except Exception:
|
||||
log.warning("config.seed_failed: %s from %s", key, env_name, exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4595,6 +4833,8 @@ def create_app(
|
||||
proxy_auth_token: str = "",
|
||||
proxy_token_mgr: Any = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
tls_manager: Any = None,
|
||||
console_url: str = "",
|
||||
) -> Starlette:
|
||||
"""Build the Starlette ASGI application for the console dashboard."""
|
||||
_spec = build_console_spec()
|
||||
@@ -4817,6 +5057,20 @@ def create_app(
|
||||
admin_rescan_skill,
|
||||
methods=["POST"],
|
||||
),
|
||||
# TLS / ACME
|
||||
Route("/api/admin/tls/ca", tls_ca_status),
|
||||
Route("/api/admin/tls/ca.pem", tls_ca_cert),
|
||||
Route("/api/admin/tls/certs", tls_list_certs),
|
||||
Route(
|
||||
"/api/admin/tls/certs/{domain}/renew",
|
||||
tls_renew_cert,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/tls/certs/{domain}",
|
||||
tls_delete_cert,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
),
|
||||
Route("/health", health),
|
||||
@@ -4842,6 +5096,15 @@ def create_app(
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.proxy_auth_token = proxy_auth_token
|
||||
app.state.proxy_token_mgr = proxy_token_mgr
|
||||
app.state.console_url = console_url
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
# Mount ACME responder whenever a TLS manager is configured.
|
||||
# ACMEResponder (lacme 1.0.2+) serves /ca.pem natively.
|
||||
if tls_manager is not None:
|
||||
from starlette.routing import Mount as RouteMount
|
||||
|
||||
app.routes.insert(0, RouteMount("/acme", app=tls_manager.get_responder()))
|
||||
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
@@ -4989,7 +5252,15 @@ def main() -> None:
|
||||
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
db_url = os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
auth_storage = init_storage(db_backend, path=db_path, url=db_url)
|
||||
auth_storage = init_storage(
|
||||
db_backend,
|
||||
path=db_path,
|
||||
url=db_url,
|
||||
sslmode=os.environ.get("TURNSTONE_DB_SSLMODE", ""),
|
||||
sslrootcert=os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
|
||||
sslcert=os.environ.get("TURNSTONE_DB_SSLCERT", ""),
|
||||
sslkey=os.environ.get("TURNSTONE_DB_SSLKEY", ""),
|
||||
)
|
||||
except Exception:
|
||||
log.info("Console storage not available — admin API disabled, JWT-only auth")
|
||||
|
||||
@@ -5014,6 +5285,45 @@ def main() -> None:
|
||||
|
||||
cors_origins = parse_cors_origins()
|
||||
|
||||
# TLS: initialize manager if enabled
|
||||
tls_mgr = None
|
||||
# Console URL for service registration — other services use this to discover the console.
|
||||
# Precedence: TURNSTONE_CONSOLE_URL env > auto-detect from bind address.
|
||||
# In Docker Compose, set TURNSTONE_CONSOLE_URL to the service name (e.g. http://console:8090).
|
||||
import socket as _socket
|
||||
|
||||
_console_url_env = os.environ.get("TURNSTONE_CONSOLE_URL", "")
|
||||
if _console_url_env:
|
||||
console_url = _console_url_env
|
||||
else:
|
||||
_advertise_host = args.host
|
||||
if _advertise_host in ("0.0.0.0", "::", ""):
|
||||
_advertise_host = _socket.getfqdn()
|
||||
console_url = f"http://{_advertise_host}:{args.port}"
|
||||
if auth_storage:
|
||||
try:
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
|
||||
_cs = ConfigStore(auth_storage)
|
||||
# Seed ConfigStore from env vars (TURNSTONE_{SECTION}_{KEY})
|
||||
_seed_config_from_env(_cs, auth_storage)
|
||||
if _cs.get("tls.enabled"):
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
tls_mgr = TLSManager(auth_storage, config_store=_cs)
|
||||
# Init CA before create_app so ACME responder can be mounted
|
||||
import asyncio
|
||||
|
||||
asyncio.run(tls_mgr.init_ca())
|
||||
# Upgrade scheme to https if no explicit URL was provided
|
||||
if not _console_url_env:
|
||||
console_url = console_url.replace("http://", "https://")
|
||||
log.info("TLS enabled")
|
||||
except ImportError:
|
||||
log.warning("TLS enabled but lacme not installed — pip install turnstone[tls]")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed", exc_info=True)
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
broker=broker,
|
||||
@@ -5023,9 +5333,11 @@ def main() -> None:
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
tls_manager=tls_mgr,
|
||||
console_url=console_url,
|
||||
)
|
||||
|
||||
log.info("Console starting on http://%s:%s", args.host, args.port)
|
||||
log.info("Console starting on %s", console_url)
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
@@ -64,6 +64,7 @@ function showAdmin() {
|
||||
audit: "admin.audit",
|
||||
memories: "admin.memories",
|
||||
settings: "admin.settings",
|
||||
tls: "admin.settings",
|
||||
mcp: "admin.mcp",
|
||||
};
|
||||
if (perms) {
|
||||
@@ -193,6 +194,7 @@ function switchAdminTab(tab) {
|
||||
"audit",
|
||||
"memories",
|
||||
"settings",
|
||||
"tls",
|
||||
"mcp",
|
||||
];
|
||||
for (var p = 0; p < panels.length; p++) {
|
||||
@@ -215,6 +217,7 @@ function switchAdminTab(tab) {
|
||||
}
|
||||
if (tab === "memories") loadAdminMemories();
|
||||
if (tab === "settings") loadSettings();
|
||||
if (tab === "tls") loadTlsCerts();
|
||||
if (tab === "mcp") loadAdminMcp();
|
||||
|
||||
// Update breadcrumb with active tab label
|
||||
@@ -2083,6 +2086,180 @@ function _settingsSectionLabel(section) {
|
||||
return labels[section] || section;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadTlsCerts() {
|
||||
var statusEl = document.getElementById("tls-ca-status");
|
||||
var listEl = document.getElementById("tls-cert-list");
|
||||
if (!statusEl || !listEl) return;
|
||||
|
||||
// Fetch CA status and cert list in parallel
|
||||
Promise.all([
|
||||
authFetch("/v1/api/admin/tls/ca").then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
}),
|
||||
authFetch("/v1/api/admin/tls/certs").then(function (r) {
|
||||
if (!r.ok) return { certs: [] };
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(function (results) {
|
||||
var data = results[0];
|
||||
var certData = results[1];
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||||
|
||||
if (!data.enabled) {
|
||||
var msg = document.createElement("div");
|
||||
msg.className = "dashboard-empty";
|
||||
msg.textContent =
|
||||
"TLS is not enabled. Set tls.enabled = true in Settings.";
|
||||
statusEl.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// CA status bar
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "tls-ca-bar";
|
||||
var caLabel = document.createElement("span");
|
||||
caLabel.textContent = "CA: " + data.ca_cn;
|
||||
var countLabel = document.createElement("span");
|
||||
countLabel.textContent = "Certificates: " + data.cert_count;
|
||||
bar.appendChild(caLabel);
|
||||
bar.appendChild(countLabel);
|
||||
statusEl.appendChild(bar);
|
||||
|
||||
var certs = certData.certs || [];
|
||||
if (certs.length === 0) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "No certificates issued yet.";
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cert rows
|
||||
certs.forEach(function (c) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "admin-row";
|
||||
row.setAttribute("role", "listitem");
|
||||
|
||||
var colDomain = document.createElement("span");
|
||||
colDomain.className = "admin-col";
|
||||
colDomain.textContent = c.domain;
|
||||
|
||||
var colSans = document.createElement("span");
|
||||
colSans.className = "admin-col";
|
||||
colSans.textContent = (c.domains || [c.domain]).join(", ");
|
||||
|
||||
var colIssued = document.createElement("span");
|
||||
colIssued.className = "admin-col";
|
||||
colIssued.textContent = (c.issued_at || "")
|
||||
.slice(0, 16)
|
||||
.replace("T", " ");
|
||||
|
||||
var colExpires = document.createElement("span");
|
||||
colExpires.className = "admin-col";
|
||||
var expires = new Date(c.expires_at);
|
||||
var isExpired = expires < new Date();
|
||||
colExpires.textContent =
|
||||
(isExpired ? "EXPIRED " : "") +
|
||||
(c.expires_at || "").slice(0, 16).replace("T", " ");
|
||||
if (isExpired) colExpires.style.color = "var(--red)";
|
||||
|
||||
var colActions = document.createElement("span");
|
||||
colActions.className = "admin-col admin-col-actions";
|
||||
var renewBtn = document.createElement("button");
|
||||
renewBtn.className = "admin-btn-action";
|
||||
renewBtn.textContent = "Renew";
|
||||
renewBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Renew certificate for " + c.domain,
|
||||
);
|
||||
renewBtn.onclick = function () {
|
||||
tlsRenewCert(c.domain);
|
||||
};
|
||||
var deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "admin-btn-danger";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Delete certificate for " + c.domain,
|
||||
);
|
||||
deleteBtn.onclick = function () {
|
||||
tlsDeleteCert(c.domain);
|
||||
};
|
||||
colActions.appendChild(renewBtn);
|
||||
colActions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(colDomain);
|
||||
row.appendChild(colSans);
|
||||
row.appendChild(colIssued);
|
||||
row.appendChild(colExpires);
|
||||
row.appendChild(colActions);
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||||
var errMsg = document.createElement("div");
|
||||
errMsg.className = "dashboard-empty";
|
||||
errMsg.textContent = "Failed to load TLS status";
|
||||
statusEl.appendChild(errMsg);
|
||||
});
|
||||
}
|
||||
|
||||
function tlsRenewCert(domain) {
|
||||
showConfirmModal(
|
||||
"Renew Certificate",
|
||||
"Force renew certificate for \u2018" + domain + "\u2019?",
|
||||
"Renew",
|
||||
function () {
|
||||
authFetch(
|
||||
"/v1/api/admin/tls/certs/" + encodeURIComponent(domain) + "/renew",
|
||||
{ method: "POST" },
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Renew failed");
|
||||
showToast("Certificate renewed for " + domain);
|
||||
loadTlsCerts();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to renew certificate", "error");
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function tlsDeleteCert(domain) {
|
||||
showConfirmModal(
|
||||
"Delete Certificate",
|
||||
"Delete certificate for \u2018" + domain + "\u2019? This cannot be undone.",
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/tls/certs/" + encodeURIComponent(domain), {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Delete failed");
|
||||
showToast("Certificate deleted for " + domain);
|
||||
loadTlsCerts();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to delete certificate", "error");
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadSettings() {
|
||||
var el = document.getElementById("admin-settings-content");
|
||||
if (!el) return;
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
|
||||
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
|
||||
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
|
||||
@@ -404,6 +405,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Tab -->
|
||||
<div id="admin-tls" class="admin-panel" role="tabpanel" aria-labelledby="tab-tls" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">TLS / CERTIFICATES</span>
|
||||
</div>
|
||||
<div id="tls-ca-status"></div>
|
||||
<div class="admin-colheaders admin-colheaders-tls" aria-hidden="true">
|
||||
<span class="admin-col">DOMAIN</span>
|
||||
<span class="admin-col">SANS</span>
|
||||
<span class="admin-col">ISSUED</span>
|
||||
<span class="admin-col">EXPIRES</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="tls-cert-list" role="list" aria-label="TLS certificates" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-mcp" class="admin-panel" role="tabpanel" aria-labelledby="tab-mcp" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header">MCP</span>
|
||||
|
||||
@@ -948,6 +948,19 @@
|
||||
grid-template-columns: 100px 1fr 100px 80px;
|
||||
}
|
||||
|
||||
/* TLS grid: DOMAIN | SANS | ISSUED | EXPIRES | ACTIONS */
|
||||
#admin-tls .admin-colheaders,
|
||||
#admin-tls .admin-row {
|
||||
grid-template-columns: 160px 1fr 130px 150px 120px;
|
||||
}
|
||||
.tls-ca-bar {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Scope badges */
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""TLS Manager — Certificate Authority and ACME server for the console.
|
||||
|
||||
Owns the lacme CertificateAuthority, ACMEResponder, and RenewalManager
|
||||
lifecycle. When TLS is enabled, the console acts as the cluster's internal
|
||||
CA and ACME server, issuing short-lived mTLS certificates to all services.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ssl
|
||||
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# Hardcoded defaults — no operator config needed
|
||||
_CA_CN = "Turnstone CA"
|
||||
_CA_NAME = "turnstone" # Store key for save_ca/load_ca
|
||||
_CA_VALIDITY_DAYS = 3650 # 10 years
|
||||
_CERT_VALIDITY_HOURS = 48
|
||||
_RENEW_INTERVAL_HOURS = 24
|
||||
_RENEW_BEFORE_EXPIRY_DAYS = 1
|
||||
|
||||
|
||||
def _require_lacme() -> Any:
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class TLSManager:
|
||||
"""Manages the internal CA, ACME responder, and certificate lifecycle.
|
||||
|
||||
Typical usage::
|
||||
|
||||
mgr = TLSManager(storage, config_store)
|
||||
await mgr.init_ca()
|
||||
responder = mgr.get_responder() # Mount at /acme
|
||||
await mgr.issue_console_certs() # Self-issue for this node
|
||||
mgr.start_renewal() # Background auto-renewal
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
config_store: ConfigStore | None = None,
|
||||
) -> None:
|
||||
lacme = _require_lacme()
|
||||
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
self._store = StorageStore(storage)
|
||||
self._config_store = config_store
|
||||
self._event_dispatcher = lacme.EventDispatcher()
|
||||
self._ca: Any | None = None
|
||||
self._responder: Any | None = None
|
||||
self._renewal_task: Any | None = None
|
||||
self._renewal_manager: Any | None = None
|
||||
self._internal_bundle: Any | None = None
|
||||
self._frontend_bundle: Any | None = None
|
||||
|
||||
# Wire structlog to lacme events
|
||||
self._subscribe_events()
|
||||
|
||||
# Wire Prometheus metrics (if prometheus_client available)
|
||||
try:
|
||||
from lacme.metrics import setup_metrics
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass # prometheus_client or lacme.metrics missing
|
||||
except ValueError as exc:
|
||||
if "Duplicated timeseries" in str(exc):
|
||||
log.debug("tls_metrics_already_registered")
|
||||
else:
|
||||
raise
|
||||
|
||||
def _subscribe_events(self) -> None:
|
||||
"""Subscribe structlog handlers to lacme lifecycle events."""
|
||||
_require_lacme()
|
||||
from lacme.events import (
|
||||
CertificateExpiring,
|
||||
CertificateIssued,
|
||||
CertificateRenewed,
|
||||
ChallengeFailed,
|
||||
)
|
||||
|
||||
def _on_issued(event: Any) -> None:
|
||||
if isinstance(event, CertificateIssued):
|
||||
log.info("tls.cert.issued", domain=event.domain)
|
||||
|
||||
def _on_renewed(event: Any) -> None:
|
||||
if isinstance(event, CertificateRenewed):
|
||||
log.info("tls.cert.renewed", domain=event.domain)
|
||||
|
||||
def _on_expiring(event: Any) -> None:
|
||||
if isinstance(event, CertificateExpiring):
|
||||
log.warning("tls.cert.expiring", domain=event.domain)
|
||||
|
||||
def _on_failed(event: Any) -> None:
|
||||
if isinstance(event, ChallengeFailed):
|
||||
log.error("tls.challenge.failed", domain=getattr(event, "domain", "unknown"))
|
||||
|
||||
self._event_dispatcher.subscribe(_on_issued, event_type=CertificateIssued)
|
||||
self._event_dispatcher.subscribe(_on_renewed, event_type=CertificateRenewed)
|
||||
self._event_dispatcher.subscribe(_on_expiring, event_type=CertificateExpiring)
|
||||
self._event_dispatcher.subscribe(_on_failed, event_type=ChallengeFailed)
|
||||
|
||||
# -- CA lifecycle ----------------------------------------------------------
|
||||
|
||||
async def init_ca(self) -> None:
|
||||
"""Initialize the internal Certificate Authority.
|
||||
|
||||
If a bootstrap CA exists on disk (from tls-bootstrap), imports it
|
||||
into the database store first so the console uses the same CA that
|
||||
signed the infrastructure certs.
|
||||
"""
|
||||
lacme = _require_lacme()
|
||||
|
||||
# Import bootstrap CA from well-known volume path if not already in DB
|
||||
self._import_bootstrap_ca()
|
||||
|
||||
self._ca = lacme.CertificateAuthority(
|
||||
self._store,
|
||||
name=_CA_NAME,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._ca.init(cn=_CA_CN, validity_days=_CA_VALIDITY_DAYS)
|
||||
log.info("tls.ca.initialized", cn=_CA_CN)
|
||||
|
||||
def _import_bootstrap_ca(self) -> None:
|
||||
"""Import a bootstrap CA from /certs into the database store.
|
||||
|
||||
The tls-bootstrap CLI writes the CA to a FileStore at /certs.
|
||||
On first boot, the console imports it so all services share
|
||||
the same trust root.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Check if bootstrap CA exists and DB CA doesn't
|
||||
bootstrap_dir = Path(os.environ.get("TURNSTONE_TLS_BOOTSTRAP_DIR", "/certs"))
|
||||
ca_dir = bootstrap_dir / "ca" / _CA_NAME
|
||||
ca_cert_file = ca_dir / "cert.pem"
|
||||
ca_key_file = ca_dir / "key.pem"
|
||||
|
||||
if not ca_cert_file.exists() or not ca_key_file.exists():
|
||||
return # No bootstrap CA found
|
||||
|
||||
existing = self._store.load_ca(_CA_NAME)
|
||||
if existing is not None:
|
||||
return # Already imported
|
||||
|
||||
cert_pem = ca_cert_file.read_bytes()
|
||||
key_pem = ca_key_file.read_bytes()
|
||||
self._store.save_ca(_CA_NAME, cert_pem, key_pem)
|
||||
log.info("tls.ca.imported_from_bootstrap", path=str(ca_dir))
|
||||
|
||||
def get_responder(self) -> ASGIApp:
|
||||
"""Return the ACME responder ASGI app for mounting."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized — call init_ca() first")
|
||||
lacme = _require_lacme()
|
||||
if self._responder is None:
|
||||
self._responder = lacme.ACMEResponder(
|
||||
ca=self._ca,
|
||||
auto_approve=True,
|
||||
)
|
||||
return self._responder # type: ignore[no-any-return]
|
||||
|
||||
def get_root_cert_pem(self) -> bytes:
|
||||
"""Return the CA root certificate in PEM format."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized — call init_ca() first")
|
||||
return self._ca.root_cert_pem # type: ignore[no-any-return]
|
||||
|
||||
# -- Cert issuance ---------------------------------------------------------
|
||||
|
||||
async def issue_console_certs(self, hostnames: list[str]) -> None:
|
||||
"""Issue certificates for the console node.
|
||||
|
||||
Raises ValueError if hostnames is empty.
|
||||
|
||||
Issues two certificates:
|
||||
- Internal cert: always from the internal CA (for mTLS with cluster)
|
||||
- Frontend cert: from external ACME CA if configured, else internal CA
|
||||
"""
|
||||
if not hostnames:
|
||||
raise ValueError("issue_console_certs requires at least one hostname")
|
||||
|
||||
# Internal cert — always from our own CA
|
||||
await self._issue_internal_cert(hostnames)
|
||||
|
||||
# Frontend cert — external CA if configured
|
||||
acme_directory = ""
|
||||
if self._config_store:
|
||||
acme_directory = self._config_store.get("tls.acme_directory") or ""
|
||||
|
||||
if acme_directory:
|
||||
await self._issue_frontend_cert(hostnames, acme_directory)
|
||||
else:
|
||||
# Self-issue from internal CA (behind reverse proxy or internal only)
|
||||
self._frontend_bundle = self._internal_bundle
|
||||
log.info("tls.frontend.self_issued", hostnames=hostnames)
|
||||
|
||||
async def _issue_internal_cert(self, hostnames: list[str]) -> None:
|
||||
"""Issue an internal mTLS cert from the internal CA."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
|
||||
# Check for existing cert in store (skip if expired)
|
||||
existing = self._store.load_cert(hostnames[0])
|
||||
if existing is not None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
if existing.expires_at > datetime.now(UTC):
|
||||
self._internal_bundle = existing
|
||||
log.info("tls.internal.loaded", domain=hostnames[0])
|
||||
return
|
||||
log.info("tls.internal.expired", domain=hostnames[0])
|
||||
self._store.delete_cert(hostnames[0])
|
||||
|
||||
# Issue new cert
|
||||
bundle = self._ca.issue(
|
||||
hostnames,
|
||||
validity_hours=_CERT_VALIDITY_HOURS,
|
||||
)
|
||||
self._store.save_cert(bundle)
|
||||
self._internal_bundle = bundle
|
||||
log.info("tls.internal.issued", domain=hostnames[0])
|
||||
|
||||
async def _issue_frontend_cert(
|
||||
self,
|
||||
hostnames: list[str],
|
||||
acme_directory: str,
|
||||
) -> None:
|
||||
"""Issue a frontend cert from an external ACME CA."""
|
||||
lacme = _require_lacme()
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
handler = HTTP01Handler()
|
||||
|
||||
async with lacme.Client(
|
||||
directory_url=acme_directory,
|
||||
store=self._store,
|
||||
challenge_handler=handler,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
) as client:
|
||||
self._frontend_bundle = await client.issue(hostnames)
|
||||
self._store.save_cert(self._frontend_bundle)
|
||||
log.info(
|
||||
"tls.frontend.issued",
|
||||
domain=hostnames[0],
|
||||
ca=acme_directory,
|
||||
)
|
||||
|
||||
# -- Auto-renewal ----------------------------------------------------------
|
||||
|
||||
async def start_renewal(self) -> None:
|
||||
"""Start background auto-renewal for all stored certificates.
|
||||
|
||||
Uses CA-direct mode (lacme 1.0.2+) — signs directly via the CA
|
||||
without going through ACME. No loopback client, no network,
|
||||
no startup ordering dependency.
|
||||
"""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
lacme = _require_lacme()
|
||||
|
||||
def _on_renewed(bundle: Any) -> None:
|
||||
# Update our cached bundles if the renewed domain matches
|
||||
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
|
||||
self._internal_bundle = bundle
|
||||
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
|
||||
self._frontend_bundle = bundle
|
||||
|
||||
self._renewal_manager = lacme.RenewalManager(
|
||||
ca=self._ca,
|
||||
store=self._store,
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
days_before_expiry=_RENEW_BEFORE_EXPIRY_DAYS,
|
||||
on_renewed=_on_renewed,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._renewal_task = self._renewal_manager.start()
|
||||
log.info(
|
||||
"tls.renewal.started",
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
)
|
||||
|
||||
async def stop_renewal(self) -> None:
|
||||
"""Stop the background renewal task."""
|
||||
if self._renewal_task is not None:
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
self._renewal_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._renewal_task
|
||||
except Exception:
|
||||
log.exception("tls.renewal.stop_error")
|
||||
self._renewal_task = None
|
||||
|
||||
# -- SSL contexts ----------------------------------------------------------
|
||||
|
||||
def get_server_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build an SSL context for the uvicorn HTTPS listener.
|
||||
|
||||
Uses the frontend cert (external CA or self-issued).
|
||||
Returns None if no certs are available.
|
||||
"""
|
||||
if self._frontend_bundle is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._frontend_bundle.fullchain_pem,
|
||||
key_pem=self._frontend_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
)
|
||||
|
||||
def get_client_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build an mTLS client context for connecting to cluster services.
|
||||
|
||||
Uses the internal cert for mutual authentication.
|
||||
Returns None if no certs are available.
|
||||
"""
|
||||
if self._internal_bundle is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._internal_bundle.cert_pem,
|
||||
key_pem=self._internal_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
)
|
||||
|
||||
# -- Properties ------------------------------------------------------------
|
||||
|
||||
def list_certs(self) -> list[Any]:
|
||||
"""List all stored certificate bundles."""
|
||||
return self._store.list_certs()
|
||||
|
||||
def renew_cert(self, domain: str) -> Any:
|
||||
"""Force-renew a certificate by domain. Returns the new bundle."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
existing = self._store.load_cert(domain)
|
||||
if existing is None:
|
||||
raise ValueError(f"No certificate for {domain}")
|
||||
# Issue new cert first, then delete old (safe if issuance fails)
|
||||
bundle = self._ca.issue(list(existing.domains))
|
||||
self._store.delete_cert(domain)
|
||||
self._store.save_cert(bundle)
|
||||
# Update in-memory bundles if this is the console's own cert
|
||||
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
|
||||
self._internal_bundle = bundle
|
||||
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
|
||||
self._frontend_bundle = bundle
|
||||
return bundle
|
||||
|
||||
def delete_cert(self, domain: str) -> bool:
|
||||
"""Delete a certificate by domain."""
|
||||
return self._store.delete_cert(domain)
|
||||
|
||||
@property
|
||||
def ca_initialized(self) -> bool:
|
||||
return self._ca is not None
|
||||
|
||||
@property
|
||||
def internal_bundle(self) -> Any | None:
|
||||
return self._internal_bundle
|
||||
|
||||
@property
|
||||
def frontend_bundle(self) -> Any | None:
|
||||
return self._frontend_bundle
|
||||
@@ -157,7 +157,7 @@ PUBLIC_PATHS: frozenset[str] = frozenset(
|
||||
"/api/auth/oidc/callback",
|
||||
}
|
||||
)
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/", "/acme/")
|
||||
|
||||
WRITE_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
|
||||
@@ -125,6 +125,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"port": "redis_port",
|
||||
"password": "redis_password",
|
||||
"db": "redis_db",
|
||||
"tls": "redis_tls",
|
||||
"tls_ca": "redis_tls_ca",
|
||||
"tls_cert": "redis_tls_cert",
|
||||
"tls_key": "redis_tls_key",
|
||||
},
|
||||
"console": {
|
||||
"host": "host",
|
||||
@@ -157,6 +161,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"url": "db_url",
|
||||
"path": "db_path",
|
||||
"pool_size": "db_pool_size",
|
||||
"sslmode": "db_sslmode",
|
||||
"sslrootcert": "db_sslrootcert",
|
||||
"sslcert": "db_sslcert",
|
||||
"sslkey": "db_sslkey",
|
||||
},
|
||||
"judge": {
|
||||
"enabled": "judge_enabled",
|
||||
|
||||
+61
-30
@@ -853,6 +853,10 @@ If you used read_file to check a target, cite what you found."""
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ExecutorPoisonedError(Exception):
|
||||
"""Raised when a timeout leaves the executor's worker thread stuck."""
|
||||
|
||||
|
||||
class IntentJudge:
|
||||
"""Session-scoped LLM judge for intent validation.
|
||||
|
||||
@@ -912,18 +916,12 @@ class IntentJudge:
|
||||
self._model = session_model
|
||||
self._judge_context_window = context_window
|
||||
|
||||
# Executor for timeout-guarded API calls (1 thread — judge is serial)
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Release the executor thread pool."""
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
messages: list[dict[str, Any]],
|
||||
callback: Callable[[IntentVerdict], None],
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> list[IntentVerdict]:
|
||||
"""Evaluate tool calls. Returns heuristic verdicts immediately.
|
||||
|
||||
@@ -936,6 +934,10 @@ class IntentJudge:
|
||||
``func_args``, ``approval_label``, ``call_id``).
|
||||
messages: Conversation history (OpenAI message format).
|
||||
callback: Called with each LLM verdict (or timeout/error fallback).
|
||||
cancel_event: When set, the daemon judge thread abandons
|
||||
remaining work. Callers should set this after the user
|
||||
has already made an approval decision so the judge does
|
||||
not keep consuming inference resources.
|
||||
|
||||
Returns:
|
||||
List of heuristic verdicts (one per item), available immediately.
|
||||
@@ -958,7 +960,7 @@ class IntentJudge:
|
||||
# Spawn daemon thread for LLM judge
|
||||
thread = threading.Thread(
|
||||
target=self._run_judge,
|
||||
args=(items, messages, heuristic_verdicts, callback),
|
||||
args=(items, messages, heuristic_verdicts, callback, cancel_event),
|
||||
daemon=True,
|
||||
name="intent-judge",
|
||||
)
|
||||
@@ -972,25 +974,44 @@ class IntentJudge:
|
||||
messages: list[dict[str, Any]],
|
||||
heuristic_verdicts: list[IntentVerdict],
|
||||
callback: Callable[[IntentVerdict], None],
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
"""Daemon thread: run LLM judge for each item and invoke callback."""
|
||||
for item, h_verdict in zip(items, heuristic_verdicts, strict=True):
|
||||
try:
|
||||
llm_verdict = self._evaluate_single(item, messages)
|
||||
# Arbitrate: only callback when LLM upgrades the heuristic
|
||||
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
|
||||
callback(llm_verdict)
|
||||
# else: heuristic already delivered, no duplicate callback
|
||||
except Exception:
|
||||
log.exception(
|
||||
"Judge evaluation failed for %s",
|
||||
item.get("func_name", "?"),
|
||||
)
|
||||
# Evaluation-scoped executor — avoids sharing mutable state with
|
||||
# other daemon threads from concurrent evaluate() calls.
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
try:
|
||||
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
|
||||
if cancel_event and cancel_event.is_set():
|
||||
log.debug("judge.cancelled", remaining=len(items) - idx)
|
||||
return
|
||||
try:
|
||||
llm_verdict = self._evaluate_single(item, messages, cancel_event, executor)
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return
|
||||
# Arbitrate: only callback when LLM upgrades the heuristic
|
||||
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
|
||||
callback(llm_verdict)
|
||||
# else: heuristic already delivered, no duplicate callback
|
||||
except _ExecutorPoisonedError:
|
||||
# Timeout left the worker stuck — replace the executor
|
||||
# so subsequent items don't queue behind it.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
except Exception:
|
||||
log.exception(
|
||||
"Judge evaluation failed for %s",
|
||||
item.get("func_name", "?"),
|
||||
)
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _evaluate_single(
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
cancel_event: threading.Event | None,
|
||||
executor: ThreadPoolExecutor,
|
||||
) -> IntentVerdict | None:
|
||||
"""Run LLM judge for a single tool call. Returns verdict or None."""
|
||||
start = time.monotonic()
|
||||
@@ -1020,6 +1041,9 @@ class IntentJudge:
|
||||
result = None # will hold the last CompletionResult
|
||||
|
||||
for turn in range(_JUDGE_MAX_TURNS):
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return None
|
||||
|
||||
turn_start = time.monotonic()
|
||||
|
||||
is_last_turn = turn == _JUDGE_MAX_TURNS - 1
|
||||
@@ -1043,7 +1067,7 @@ class IntentJudge:
|
||||
# 10 minutes — far too long for an advisory judge on local models.
|
||||
per_call_timeout = max(timeout_budget, 5.0) # at least 5s
|
||||
try:
|
||||
future = self._executor.submit(
|
||||
future = executor.submit(
|
||||
self._provider.create_completion,
|
||||
client=self._client,
|
||||
model=self._model,
|
||||
@@ -1053,17 +1077,24 @@ class IntentJudge:
|
||||
temperature=0.0,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
result = future.result(timeout=per_call_timeout)
|
||||
# Poll in 1s increments so we notice cancellation promptly
|
||||
# instead of blocking for the full per_call_timeout.
|
||||
deadline = time.monotonic() + per_call_timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if cancel_event and cancel_event.is_set():
|
||||
future.cancel()
|
||||
return None
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
try:
|
||||
result = future.result(timeout=min(remaining, 1.0))
|
||||
break
|
||||
except TimeoutError:
|
||||
pass # loop back to check remaining/cancel
|
||||
except TimeoutError:
|
||||
log.warning("Judge LLM call timed out on turn %d (%.0fs)", turn, per_call_timeout)
|
||||
# Abandon the lingering API call and replace the executor so
|
||||
# subsequent items in the batch don't queue behind it.
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="judge-api",
|
||||
)
|
||||
return None
|
||||
raise _ExecutorPoisonedError from None
|
||||
except Exception:
|
||||
log.exception("Judge LLM call failed on turn %d", turn)
|
||||
return None
|
||||
|
||||
@@ -176,6 +176,8 @@ class MCPClientManager:
|
||||
for name, cfg in self._server_configs.items():
|
||||
try:
|
||||
await self._connect_one(name, cfg)
|
||||
except asyncio.CancelledError:
|
||||
raise # propagate so the background task can be cleanly stopped
|
||||
except Exception as exc:
|
||||
log.warning("Failed to connect MCP server '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"{type(exc).__name__}: {exc}")
|
||||
@@ -199,6 +201,49 @@ class MCPClientManager:
|
||||
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
|
||||
|
||||
_CONNECT_TIMEOUT = 30 # seconds — prevents hung connections on broken remotes
|
||||
_TCP_PROBE_TIMEOUT = 5 # seconds — fast TCP pre-flight for HTTP transports
|
||||
|
||||
async def _tcp_probe(self, name: str, url: str) -> None:
|
||||
"""Fast TCP connect check before entering the MCP transport context.
|
||||
|
||||
Fails fast when the server is unreachable, avoiding the anyio
|
||||
cancel-scope orphan bug that causes 100% CPU spin.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ConnectionError(f"MCP server '{name}' has invalid URL (no hostname): {url}")
|
||||
try:
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
except ValueError:
|
||||
raise ConnectionError(f"MCP server '{name}' has invalid port in URL: {url}") from None
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=self._TCP_PROBE_TIMEOUT,
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except (TimeoutError, OSError) as exc:
|
||||
raise ConnectionError(
|
||||
f"MCP server '{name}' unreachable at {host}:{port}: {exc}"
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
async def _safe_close_stack(stack: AsyncExitStack) -> None:
|
||||
"""Close an AsyncExitStack, suppressing errors from broken anyio scopes.
|
||||
|
||||
Called from exception handlers — must not raise, otherwise cleanup
|
||||
errors could mask the original exception. CancelledError is caught
|
||||
explicitly because it is the primary failure mode (stray cancel from
|
||||
broken anyio scope) and is BaseException, not Exception.
|
||||
"""
|
||||
try:
|
||||
await asyncio.wait_for(stack.aclose(), timeout=5)
|
||||
except (Exception, asyncio.CancelledError):
|
||||
log.debug("Error closing AsyncExitStack; ignoring", exc_info=True)
|
||||
|
||||
async def _connect_one(self, name: str, cfg: dict[str, Any]) -> None:
|
||||
"""Connect to a single MCP server and discover its tools."""
|
||||
@@ -213,6 +258,13 @@ class MCPClientManager:
|
||||
transport = cfg.get("type", "stdio")
|
||||
try:
|
||||
if transport in ("http", "streamable-http") or "url" in cfg:
|
||||
# Pre-flight TCP check: fail fast before entering the anyio
|
||||
# task group in streamablehttp_client. An immediate connect
|
||||
# failure (ECONNREFUSED) inside the anyio context causes a
|
||||
# CancelledError that escapes asyncio.wait_for and leaves
|
||||
# orphaned cancel-scope tasks spinning at 100% CPU.
|
||||
await self._tcp_probe(name, cfg["url"])
|
||||
|
||||
read, write, _ = await asyncio.wait_for(
|
||||
stack.enter_async_context(
|
||||
streamablehttp_client(url=cfg["url"], headers=cfg.get("headers"))
|
||||
@@ -235,15 +287,25 @@ class MCPClientManager:
|
||||
env=env,
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
except asyncio.CancelledError:
|
||||
# Stray CancelledError from broken anyio cancel scope — treat as
|
||||
# connection failure. But if the task is genuinely being cancelled
|
||||
# (shutdown), re-raise so we don't block teardown.
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling():
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
log.warning("MCP server '%s' connection failed (anyio cancel)", name)
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"Connection failed for '{name}'") from None
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"MCP server '%s' connection timed out after %ds", name, self._CONNECT_TIMEOUT
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"Connection timed out after {self._CONNECT_TIMEOUT}s") from None
|
||||
except Exception:
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
|
||||
# Register notification handler — dispatches tool, resource, and
|
||||
@@ -274,21 +336,27 @@ class MCPClientManager:
|
||||
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
|
||||
)
|
||||
except Exception:
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
|
||||
self._per_server_stacks[name] = stack
|
||||
try:
|
||||
await asyncio.wait_for(session.initialize(), timeout=self._CONNECT_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling():
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"MCP handshake failed for '{name}'") from None
|
||||
except TimeoutError:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"MCP handshake timed out after {self._CONNECT_TIMEOUT}s") from None
|
||||
except Exception:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
self._sessions[name] = session
|
||||
|
||||
@@ -873,8 +941,7 @@ class MCPClientManager:
|
||||
|
||||
async def _close_all_stacks() -> None:
|
||||
for stack in self._per_server_stacks.values():
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(_close_all_stacks(), self._loop)
|
||||
try:
|
||||
@@ -984,10 +1051,7 @@ class MCPClientManager:
|
||||
self._sessions.pop(name, None)
|
||||
stack = self._per_server_stacks.pop(name, None)
|
||||
if stack is not None:
|
||||
try:
|
||||
await asyncio.wait_for(stack.aclose(), timeout=10)
|
||||
except (TimeoutError, Exception):
|
||||
log.warning("Timed out closing MCP server '%s', forcing cleanup", name)
|
||||
await self._safe_close_stack(stack)
|
||||
# Clean up per-server state (on the event loop thread)
|
||||
self._per_server_tools.pop(name, None)
|
||||
self._per_server_resources.pop(name, None)
|
||||
|
||||
@@ -50,6 +50,13 @@ NUDGE_TOOL_ERROR = (
|
||||
"previous session. Use memory(action='search') to find relevant guidance."
|
||||
)
|
||||
|
||||
NUDGE_REPEAT = (
|
||||
"You just called the same tool with the same arguments as a previous "
|
||||
"call in this conversation. Repeating the exact same action will produce "
|
||||
"the same result. Stop and reconsider your approach — try a different "
|
||||
"tool, different arguments, or ask the user for clarification."
|
||||
)
|
||||
|
||||
_NUDGE_MAP: dict[str, str] = {
|
||||
"correction": NUDGE_CORRECTION,
|
||||
"denial": NUDGE_DENIAL,
|
||||
@@ -57,6 +64,7 @@ _NUDGE_MAP: dict[str, str] = {
|
||||
"completion": NUDGE_COMPLETION,
|
||||
"start": NUDGE_START,
|
||||
"tool_error": NUDGE_TOOL_ERROR,
|
||||
"repeat": NUDGE_REPEAT,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ The ``anthropic`` SDK is imported lazily so it remains an optional dependency.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.providers._protocol import (
|
||||
@@ -459,6 +460,7 @@ class AnthropicProvider:
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
_ensure_anthropic()
|
||||
caps = self.get_capabilities(model)
|
||||
@@ -476,8 +478,25 @@ class AnthropicProvider:
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
with client.messages.stream(**kwargs) as stream:
|
||||
manager = client.messages.stream(**kwargs)
|
||||
try:
|
||||
stream = manager.__enter__()
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
if cancel_ref is not None:
|
||||
cancel_ref.append(stream)
|
||||
return self._iter_with_cleanup(stream, manager)
|
||||
|
||||
def _iter_with_cleanup(self, stream: Any, manager: Any) -> Iterator[StreamChunk]:
|
||||
"""Iterate the Anthropic stream, ensuring the context manager exits."""
|
||||
try:
|
||||
yield from self._iter_anthropic_stream(stream)
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
else:
|
||||
manager.__exit__(None, None, None)
|
||||
|
||||
def _iter_anthropic_stream(self, stream: Any) -> Iterator[StreamChunk]:
|
||||
"""Convert Anthropic streaming events to normalized StreamChunks."""
|
||||
|
||||
@@ -273,6 +273,29 @@ class OpenAIProvider:
|
||||
result.append(tool)
|
||||
return result
|
||||
|
||||
# -- message sanitisation ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ensure assistant messages always have ``content`` or ``tool_calls``.
|
||||
|
||||
OpenAI-compatible APIs reject assistant messages that have neither.
|
||||
This is a defensive catch-all; the upstream layers should already
|
||||
guarantee well-formed messages.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if (
|
||||
msg.get("role") == "assistant"
|
||||
and msg.get("content") is None
|
||||
and not msg.get("tool_calls")
|
||||
):
|
||||
msg = {**msg, "content": ""}
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def create_streaming(
|
||||
@@ -287,8 +310,10 @@ class OpenAIProvider:
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = self._sanitize_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -306,7 +331,9 @@ class OpenAIProvider:
|
||||
kwargs["extra_body"] = extra_params
|
||||
|
||||
stream = client.chat.completions.create(**kwargs)
|
||||
yield from self._iter_stream(stream)
|
||||
if cancel_ref is not None:
|
||||
cancel_ref.append(stream)
|
||||
return self._iter_stream(stream)
|
||||
|
||||
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
|
||||
"""Convert OpenAI stream chunks to normalized StreamChunks."""
|
||||
@@ -402,6 +429,7 @@ class OpenAIProvider:
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = self._sanitize_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
|
||||
@@ -125,8 +125,15 @@ class LLMProvider(Protocol):
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
"""Create a streaming request, yielding normalized StreamChunks."""
|
||||
"""Create a streaming request, yielding normalized StreamChunks.
|
||||
|
||||
If *cancel_ref* is provided the provider appends the underlying SDK
|
||||
stream object (which has a ``.close()`` method) before yielding the
|
||||
first chunk. The caller can then close it from another thread to
|
||||
abort a blocked HTTP read immediately.
|
||||
"""
|
||||
...
|
||||
|
||||
def create_completion(
|
||||
|
||||
+363
-78
@@ -12,6 +12,7 @@ import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -116,6 +117,34 @@ class GenerationCancelled(BaseException):
|
||||
"""
|
||||
|
||||
|
||||
class _CancelRef(list[Any]):
|
||||
"""List proxy used for ``ChatSession._cancel_ref``.
|
||||
|
||||
Providers call ``cancel_ref.append(stream_handle)`` eagerly — the HTTP
|
||||
call and registration happen before the iterator is returned to the
|
||||
caller. By overriding ``append`` we update ``ChatSession._cancel_stream``
|
||||
immediately. If cancellation was already requested before the stream
|
||||
was created (e.g. cancel during retry backoff), the stream is closed
|
||||
on arrival so the blocked iteration is unblocked.
|
||||
"""
|
||||
|
||||
__slots__ = ("_session",)
|
||||
|
||||
def __init__(self, session: ChatSession) -> None:
|
||||
super().__init__()
|
||||
self._session = session
|
||||
|
||||
def append(self, stream: Any) -> None:
|
||||
super().append(stream)
|
||||
self._session._cancel_stream = stream
|
||||
# If cancel was requested before the first chunk arrived (the worker
|
||||
# thread is blocked inside the provider generator waiting for the HTTP
|
||||
# response), close the stream immediately to unblock it.
|
||||
if self._session._cancel_event.is_set():
|
||||
with contextlib.suppress(Exception):
|
||||
stream.close()
|
||||
|
||||
|
||||
# Image extensions handled as vision content (SVG excluded — it's XML text)
|
||||
_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"}
|
||||
@@ -312,13 +341,21 @@ class ChatSession:
|
||||
self._watch_dispatch_depth = 0
|
||||
# Metacognitive nudges: ephemeral prompts for proactive memory use
|
||||
self._metacog_state: dict[str, float] = {}
|
||||
self._pending_nudge: list[str] = []
|
||||
self._pending_nudge: list[tuple[str, str]] = [] # (type, text)
|
||||
# Repeat detection: track recent tool call signatures
|
||||
self._recent_tool_sigs: set[str] = set()
|
||||
# Cooperative cancellation: set from outside to stop generation
|
||||
self._cancel_event = threading.Event()
|
||||
self._cancel_ref: _CancelRef = _CancelRef(self) # provider appends SDK stream here
|
||||
self._cancel_stream: Any = None # closeable SDK stream handle
|
||||
self._generation: int = 0 # monotonic counter; orphaned threads skip cleanup
|
||||
self._active_procs: set[subprocess.Popen[str]] = set() # for force-kill
|
||||
self._procs_lock = threading.Lock()
|
||||
self._cancelled_partial_msg: dict[str, Any] | None = None
|
||||
# Intent validation judge (lazy-initialized)
|
||||
self._judge_config: JudgeConfig | None = judge_config
|
||||
self._judge: IntentJudge | None = None
|
||||
self._judge_cancel_event: threading.Event | None = None
|
||||
# MCP tool integration: merge external tools with built-in
|
||||
self._mcp_client = mcp_client
|
||||
self._mcp_refresh_cb: Any = None # Callable | None (avoid import)
|
||||
@@ -639,8 +676,8 @@ class ChatSession:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release resources (listener registrations, etc.)."""
|
||||
if self._judge is not None:
|
||||
self._judge.shutdown()
|
||||
if self._judge_cancel_event is not None:
|
||||
self._judge_cancel_event.set()
|
||||
if self._mcp_client and self._mcp_refresh_cb:
|
||||
self._mcp_client.remove_listener(self._mcp_refresh_cb)
|
||||
self._mcp_refresh_cb = None
|
||||
@@ -779,6 +816,7 @@ class ChatSession:
|
||||
self._ws_id = ws_id
|
||||
self.messages = messages
|
||||
self._read_files.clear()
|
||||
self._recent_tool_sigs.clear()
|
||||
self._last_usage = None
|
||||
self._title_generated = True # don't re-title resumed workstreams
|
||||
self._msg_tokens = [
|
||||
@@ -820,7 +858,7 @@ class ChatSession:
|
||||
memory_count=self._visible_memory_count(),
|
||||
cooldown_secs=self._mem_cfg.nudge_cooldown,
|
||||
):
|
||||
self._pending_nudge.append(format_nudge("resume"))
|
||||
self._pending_nudge.append(("resume", format_nudge("resume")))
|
||||
self._init_system_messages()
|
||||
return True
|
||||
|
||||
@@ -891,7 +929,10 @@ class ChatSession:
|
||||
" search(query='MAX_RETRIES') → "
|
||||
"read_file(path='found.py') → "
|
||||
"edit_file(path='found.py')\n\n"
|
||||
"Plan, think through, or strategize → plan_agent:\n"
|
||||
"Plan, design, or architect something → "
|
||||
"explore codebase then plan_agent:\n"
|
||||
" bash(command='ls') → read_file(path='app.py') → "
|
||||
"plan_agent(goal='add caching to the application')\n"
|
||||
" plan_agent(goal='refactor database layer "
|
||||
"from monolith to service')\n"
|
||||
" plan_agent(goal='restructure auth module')\n\n"
|
||||
@@ -1019,9 +1060,14 @@ class ChatSession:
|
||||
"Use memory(action='search') or memory(action='list') for more."
|
||||
)
|
||||
if self._pending_nudge:
|
||||
for nudge in self._pending_nudge:
|
||||
types = []
|
||||
for nudge_type, nudge_text in self._pending_nudge:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(nudge)
|
||||
dev_parts.append(nudge_text)
|
||||
types.append(nudge_type)
|
||||
# Surface nudge activity to the user so they can see
|
||||
# the metacognitive prompts being applied.
|
||||
self.ui.on_info(f"{GRAY}[metacognition: nudge injected — {', '.join(types)}]{RESET}")
|
||||
self._pending_nudge.clear()
|
||||
new_system_messages.append({"role": "system", "content": "\n".join(dev_parts)})
|
||||
# Atomic swap — readers see either old or new, never partial
|
||||
@@ -1066,6 +1112,9 @@ class ChatSession:
|
||||
Web search gating: ``web_search`` is removed when the model has
|
||||
no native search support and no search backend is available
|
||||
(Tavily, DDG, or MCP — see ``_resolve_search_client``).
|
||||
|
||||
MCP tool gating: ``read_resource`` is removed when no MCP servers
|
||||
expose resources; ``use_prompt`` is removed when none expose prompts.
|
||||
"""
|
||||
if self.creative_mode:
|
||||
return None
|
||||
@@ -1085,6 +1134,12 @@ class ChatSession:
|
||||
if not caps.supports_web_search and not self._resolve_search_client():
|
||||
tools = _without_tool(tools, "web_search")
|
||||
|
||||
# Gate MCP tools: only include when relevant MCP servers are connected
|
||||
if not self._mcp_client or not self._mcp_client.resource_count:
|
||||
tools = _without_tool(tools, "read_resource")
|
||||
if not self._mcp_client or not self._mcp_client.prompt_count:
|
||||
tools = _without_tool(tools, "use_prompt")
|
||||
|
||||
return tools
|
||||
|
||||
def _get_deferred_names(self) -> frozenset[str] | None:
|
||||
@@ -1151,6 +1206,8 @@ class ChatSession:
|
||||
prov = provider or self._provider
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
self._check_cancelled()
|
||||
self._cancel_ref.clear() # discard stale handle from prior attempt
|
||||
try:
|
||||
return prov.create_streaming(
|
||||
client=client,
|
||||
@@ -1162,6 +1219,7 @@ class ChatSession:
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
extra_params=self._provider_extra_params(provider=prov),
|
||||
deferred_names=self._get_deferred_names(),
|
||||
cancel_ref=self._cancel_ref,
|
||||
)
|
||||
except Exception as e:
|
||||
ename = type(e).__name__
|
||||
@@ -1183,11 +1241,36 @@ class ChatSession:
|
||||
while the worker thread is inside ``send()``.
|
||||
"""
|
||||
self._cancel_event.set()
|
||||
# Close the underlying SDK stream to unblock the iteration
|
||||
# immediately. Without this the worker thread stays blocked in
|
||||
# ``for chunk in stream`` until the next SSE chunk arrives from
|
||||
# the LLM provider (can be seconds during extended thinking).
|
||||
s = self._cancel_stream
|
||||
if s is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
s.close()
|
||||
# Kill all tracked subprocesses (bash tool). This is the
|
||||
# last line of defense — ensures destructive commands are
|
||||
# stopped even if the worker thread is stuck.
|
||||
with self._procs_lock:
|
||||
procs = list(self._active_procs)
|
||||
for proc in procs:
|
||||
if proc.poll() is not None:
|
||||
continue # already exited
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (OSError, ProcessLookupError):
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
proc.kill()
|
||||
|
||||
def _check_cancelled(self) -> None:
|
||||
"""Raise ``GenerationCancelled`` if cancellation has been requested."""
|
||||
def _check_cancelled(self, my_generation: int = 0) -> None:
|
||||
"""Raise ``GenerationCancelled`` if cancellation has been requested
|
||||
or if this thread belongs to an orphaned generation (force cancel).
|
||||
"""
|
||||
if self._cancel_event.is_set():
|
||||
raise GenerationCancelled()
|
||||
if my_generation and my_generation != self._generation:
|
||||
raise GenerationCancelled()
|
||||
|
||||
# -- Main generation loop ------------------------------------------------
|
||||
|
||||
@@ -1212,7 +1295,12 @@ class ChatSession:
|
||||
self._budget_exhausted = False
|
||||
self._budget_warned = False
|
||||
self._notify_count = 0
|
||||
self._cancel_event.clear()
|
||||
self._generation += 1
|
||||
my_generation = self._generation
|
||||
# Fresh cancel event per generation. The old event object stays
|
||||
# set for any abandoned thread — _exec_bash captures a local
|
||||
# reference so subprocesses from old generations are still killed.
|
||||
self._cancel_event = threading.Event()
|
||||
self._cancelled_partial_msg = None
|
||||
self.messages.append({"role": "user", "content": user_input})
|
||||
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
|
||||
@@ -1226,7 +1314,7 @@ class ChatSession:
|
||||
|
||||
try:
|
||||
while True:
|
||||
self._check_cancelled()
|
||||
self._check_cancelled(my_generation)
|
||||
msgs = self._full_messages()
|
||||
|
||||
if self.debug:
|
||||
@@ -1236,10 +1324,19 @@ class ChatSession:
|
||||
self.ui.on_thinking_start()
|
||||
try:
|
||||
stream = self._create_stream_with_retry(msgs)
|
||||
assistant_msg = self._stream_response(stream)
|
||||
assistant_msg = self._stream_response(stream, my_generation)
|
||||
finally:
|
||||
# Only clear if this generation is still active —
|
||||
# an orphaned thread must not clobber a newer stream.
|
||||
if self._generation == my_generation:
|
||||
self._cancel_stream = None
|
||||
self._cancel_ref.clear()
|
||||
self.ui.on_thinking_stop()
|
||||
|
||||
# Bail if this generation was superseded (force cancel).
|
||||
if self._generation != my_generation:
|
||||
return
|
||||
|
||||
self._update_token_table(assistant_msg)
|
||||
self.messages.append(assistant_msg)
|
||||
self._msg_tokens.append(
|
||||
@@ -1292,6 +1389,8 @@ class ChatSession:
|
||||
f"\n[Auto-compacting: prompt exceeds {pct_display}% of context window]"
|
||||
)
|
||||
self._compact_messages(auto=True)
|
||||
# Update status bar with post-compaction token counts
|
||||
self._print_status_line()
|
||||
# Auto-title session after first exchange
|
||||
if not self._title_generated:
|
||||
self._title_generated = True
|
||||
@@ -1305,6 +1404,51 @@ class ChatSession:
|
||||
# Execute tool calls (potentially in parallel)
|
||||
self._emit_state("running")
|
||||
results, user_feedback = self._execute_tools(tool_calls)
|
||||
|
||||
# Bail if generation was superseded during tool execution.
|
||||
if self._generation != my_generation:
|
||||
return
|
||||
|
||||
# Repeat detection: warn when a tool is called with identical args.
|
||||
# Skip error outputs — retrying a failed tool is valid.
|
||||
# Skip JSON outputs (MCP structured results) — appending
|
||||
# text would corrupt the payload.
|
||||
_tc_by_id = {c["id"]: c for c in tool_calls}
|
||||
_repeat_detected = False
|
||||
_error_prefixes = ("Error", "JSON parse error", "Unknown tool", "Command timed out")
|
||||
for i, (tc_id, output) in enumerate(results):
|
||||
tc = _tc_by_id.get(tc_id)
|
||||
if tc and isinstance(output, str) and not output.startswith(_error_prefixes):
|
||||
raw = tc["function"]["name"] + ":" + tc["function"]["arguments"]
|
||||
sig = hashlib.sha256(raw.encode()).hexdigest()
|
||||
is_json = output.lstrip().startswith(("{", "["))
|
||||
if sig in self._recent_tool_sigs:
|
||||
_repeat_detected = True
|
||||
if not is_json:
|
||||
output += (
|
||||
"\n\n⚠ Warning: this is an identical repeat of a "
|
||||
"previous tool call. The result is the same. "
|
||||
"Try a different approach."
|
||||
)
|
||||
results[i] = (tc_id, output)
|
||||
self.ui.on_info(
|
||||
f"{GRAY}[repeat: {tc['function']['name']}() "
|
||||
f"called with same arguments]{RESET}"
|
||||
)
|
||||
self._recent_tool_sigs.add(sig)
|
||||
if _repeat_detected:
|
||||
# Reset so the model gets a clean slate after the warning.
|
||||
# If it repeats again, a new warning fires.
|
||||
self._recent_tool_sigs.clear()
|
||||
if self._mem_cfg.nudges and should_nudge(
|
||||
"repeat",
|
||||
self._metacog_state,
|
||||
message_count=len(self.messages),
|
||||
cooldown_secs=self._mem_cfg.nudge_cooldown,
|
||||
):
|
||||
self._pending_nudge.append(("repeat", format_nudge("repeat")))
|
||||
self._init_system_messages()
|
||||
|
||||
# Map tool_call_id → tool name for logging
|
||||
_tc_names = {c["id"]: c.get("function", {}).get("name", "") for c in tool_calls}
|
||||
for tc_id, output in results:
|
||||
@@ -1387,13 +1531,17 @@ class ChatSession:
|
||||
cooldown_secs=self._mem_cfg.nudge_cooldown,
|
||||
)
|
||||
):
|
||||
self._pending_nudge.append(format_nudge("tool_error"))
|
||||
self._pending_nudge.append(("tool_error", format_nudge("tool_error")))
|
||||
self._init_system_messages()
|
||||
# Inject user feedback from approval prompt (e.g. "y, use full path")
|
||||
if user_feedback:
|
||||
self.messages.append({"role": "user", "content": user_feedback})
|
||||
self._msg_tokens.append(max(1, int(len(user_feedback) / self._chars_per_token)))
|
||||
except GenerationCancelled:
|
||||
# If a newer send() has started (force cancel), this thread is
|
||||
# orphaned — skip all message mutations and state changes.
|
||||
if self._generation != my_generation:
|
||||
return
|
||||
# Cooperative cancellation — preserve partial content if available.
|
||||
if self._cancelled_partial_msg:
|
||||
# _stream_response was interrupted — save partial assistant msg
|
||||
@@ -1422,7 +1570,8 @@ class ChatSession:
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
self._cancel_event.clear()
|
||||
# No need to clear _cancel_event — it's replaced per-generation
|
||||
# in send(), so this generation's event is simply discarded.
|
||||
self.ui.on_info("[Generation cancelled]")
|
||||
self._emit_state("idle")
|
||||
# Do NOT re-raise — return normally so server worker thread
|
||||
@@ -1467,7 +1616,9 @@ class ChatSession:
|
||||
_THINK_CLOSE_TAGS = ("</think>", "</reasoning>")
|
||||
_MAX_TAG_LEN = max(len(t) for t in _THINK_OPEN_TAGS + _THINK_CLOSE_TAGS)
|
||||
|
||||
def _stream_response(self, stream: Iterator[StreamChunk]) -> dict[str, Any]:
|
||||
def _stream_response(
|
||||
self, stream: Iterator[StreamChunk], my_generation: int = 0
|
||||
) -> dict[str, Any]:
|
||||
"""Stream response, dispatching tokens to the UI as they arrive.
|
||||
|
||||
Handles two reasoning delivery mechanisms:
|
||||
@@ -1558,7 +1709,13 @@ class ChatSession:
|
||||
finish_reason = None
|
||||
try:
|
||||
for chunk in stream:
|
||||
self._check_cancelled()
|
||||
# _cancel_stream is set eagerly by _CancelRef.append() when the
|
||||
# provider creates the SDK stream handle (before the first chunk
|
||||
# is returned). This fallback handles providers that use a
|
||||
# plain list for cancel_ref (e.g. some test fakes).
|
||||
if self._cancel_ref and self._cancel_stream is None:
|
||||
self._cancel_stream = self._cancel_ref[0]
|
||||
self._check_cancelled(my_generation)
|
||||
# Track finish_reason (e.g. "stop", "length", "tool_calls")
|
||||
if chunk.finish_reason:
|
||||
finish_reason = chunk.finish_reason
|
||||
@@ -1666,12 +1823,28 @@ class ChatSession:
|
||||
self.ui.on_stream_end()
|
||||
partial: dict[str, Any] = {"role": "assistant"}
|
||||
partial_content = "".join(content_parts)
|
||||
partial["content"] = partial_content or None
|
||||
partial["content"] = partial_content or ""
|
||||
# Deliberately omit tool_calls — they are incomplete
|
||||
if provider_blocks:
|
||||
partial["_provider_content"] = provider_blocks
|
||||
self._cancelled_partial_msg = partial
|
||||
raise
|
||||
except Exception:
|
||||
# cancel() closed the underlying SDK stream, aborting the HTTP
|
||||
# connection. The blocked next() call on the iterator raises a
|
||||
# transport-level error (httpx, httpcore, etc.). Convert to
|
||||
# GenerationCancelled if a cancel was requested.
|
||||
if self._cancel_event.is_set():
|
||||
if pending:
|
||||
_flush_text(pending, in_think)
|
||||
self.ui.on_stream_end()
|
||||
partial = {"role": "assistant"}
|
||||
partial["content"] = "".join(content_parts) or ""
|
||||
if provider_blocks:
|
||||
partial["_provider_content"] = provider_blocks
|
||||
self._cancelled_partial_msg = partial
|
||||
raise GenerationCancelled() from None
|
||||
raise
|
||||
|
||||
# Flush any remaining buffered text
|
||||
if pending:
|
||||
@@ -1697,12 +1870,10 @@ class ChatSession:
|
||||
msg: dict[str, Any] = {"role": "assistant"}
|
||||
|
||||
content = "".join(content_parts)
|
||||
if content:
|
||||
msg["content"] = content
|
||||
else:
|
||||
msg["content"] = None
|
||||
msg["content"] = content or ""
|
||||
|
||||
if tool_calls_acc:
|
||||
self._ensure_tool_call_ids(tool_calls_acc)
|
||||
msg["tool_calls"] = [tool_calls_acc[i] for i in sorted(tool_calls_acc)]
|
||||
|
||||
# Store raw provider content blocks for multi-turn preservation
|
||||
@@ -2020,6 +2191,7 @@ class ChatSession:
|
||||
self.messages = [summary_user, summary_asst]
|
||||
# File contents are gone after compaction — force re-read before edit_file
|
||||
self._read_files.clear()
|
||||
self._recent_tool_sigs.clear()
|
||||
|
||||
# Rebuild token table
|
||||
su_tok = max(1, int(self._msg_char_count(summary_user) / self._chars_per_token))
|
||||
@@ -2027,6 +2199,14 @@ class ChatSession:
|
||||
self._msg_tokens = [su_tok, sa_tok]
|
||||
after_tokens = self._system_tokens + sum(self._msg_tokens)
|
||||
|
||||
# Update usage estimate so the status bar reflects post-compaction state
|
||||
if self._last_usage:
|
||||
self._last_usage = {
|
||||
**self._last_usage,
|
||||
"prompt_tokens": after_tokens,
|
||||
"total_tokens": after_tokens,
|
||||
}
|
||||
|
||||
self.ui.on_info(f"[compacted: ~{before_tokens:,} -> ~{after_tokens:,} tokens]")
|
||||
separator = "\u2500" * 60
|
||||
lines = [separator]
|
||||
@@ -2071,20 +2251,24 @@ class ChatSession:
|
||||
def _evaluate_intent(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
) -> threading.Event | None:
|
||||
"""Run intent validation on pending approval items.
|
||||
|
||||
Attaches heuristic verdicts to items immediately. Spawns the
|
||||
async LLM judge that delivers final verdicts via UI callback.
|
||||
|
||||
Returns a cancel event that, when set, tells the daemon judge
|
||||
thread to abandon remaining work. Callers should set this
|
||||
after the user has made an approval decision.
|
||||
"""
|
||||
judge = self._ensure_judge()
|
||||
if not judge:
|
||||
return
|
||||
return None
|
||||
|
||||
# Only evaluate items that need approval and aren't errors
|
||||
pending = [it for it in items if it.get("needs_approval") and not it.get("error")]
|
||||
if not pending:
|
||||
return
|
||||
return None
|
||||
|
||||
# Build func_args from tool-specific item keys so the heuristic
|
||||
# engine can pattern-match on argument content.
|
||||
@@ -2122,16 +2306,20 @@ class ChatSession:
|
||||
except Exception:
|
||||
log.debug("judge.verdict_delivery_failed", exc_info=True)
|
||||
|
||||
cancel_event = threading.Event()
|
||||
heuristic_verdicts = judge.evaluate(
|
||||
pending,
|
||||
list(self.messages), # snapshot — daemon thread must not see mutations
|
||||
callback=_on_verdict,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
|
||||
# Attach heuristic verdicts to items for the approval UI
|
||||
for item, verdict in zip(pending, heuristic_verdicts, strict=True):
|
||||
item["_heuristic_verdict"] = verdict.to_dict()
|
||||
|
||||
return cancel_event
|
||||
|
||||
def _evaluate_output(self, call_id: str, output: str, func_name: str) -> str:
|
||||
"""Run the output guard on tool result text.
|
||||
|
||||
@@ -2181,12 +2369,20 @@ class ChatSession:
|
||||
# Phase 1: prepare all tool calls
|
||||
items = [self._prepare_tool(tc) for tc in tool_calls]
|
||||
|
||||
# Intent validation (advisory, non-blocking)
|
||||
self._evaluate_intent(items)
|
||||
# Intent validation (advisory, non-blocking).
|
||||
# Cancel any prior judge thread before spawning a new one.
|
||||
if self._judge_cancel_event is not None:
|
||||
self._judge_cancel_event.set()
|
||||
judge_cancel = self._evaluate_intent(items)
|
||||
self._judge_cancel_event = judge_cancel # track for close()
|
||||
|
||||
# Phase 2: approve via UI
|
||||
self._emit_state("attention")
|
||||
approved, user_feedback = self.ui.approve_tools(items)
|
||||
try:
|
||||
approved, user_feedback = self.ui.approve_tools(items)
|
||||
finally:
|
||||
if judge_cancel:
|
||||
judge_cancel.set() # user decided (or disconnected) — stop judge
|
||||
self._emit_state("running")
|
||||
if not approved:
|
||||
# Mark all pending items as denied
|
||||
@@ -2204,7 +2400,7 @@ class ChatSession:
|
||||
memory_count=self._visible_memory_count(),
|
||||
cooldown_secs=self._mem_cfg.nudge_cooldown,
|
||||
):
|
||||
self._pending_nudge.append(format_nudge("denial"))
|
||||
self._pending_nudge.append(("denial", format_nudge("denial")))
|
||||
self._init_system_messages()
|
||||
|
||||
# Phase 3: execute (check cancellation before starting)
|
||||
@@ -2213,7 +2409,11 @@ class ChatSession:
|
||||
def run_one(
|
||||
item: dict[str, Any],
|
||||
) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
self._check_cancelled()
|
||||
if item.get("error"):
|
||||
self.ui.on_tool_result(
|
||||
item["call_id"], item.get("func_name", "unknown"), item["error"]
|
||||
)
|
||||
return item["call_id"], item["error"]
|
||||
if item.get("denied"):
|
||||
return item["call_id"], item.get("denial_msg", "Denied by user")
|
||||
@@ -2226,7 +2426,7 @@ class ChatSession:
|
||||
func = item.get("func_name", "unknown")
|
||||
msg = f"Error executing {func}: {e}"
|
||||
log.warning("tool_exec.failed", tool=func, error=str(e), exc_info=True)
|
||||
self.ui.on_error(msg)
|
||||
self.ui.on_tool_result(item["call_id"], func, msg)
|
||||
return item["call_id"], msg
|
||||
|
||||
if len(items) == 1:
|
||||
@@ -2305,10 +2505,23 @@ class ChatSession:
|
||||
|
||||
return results, user_feedback
|
||||
|
||||
@staticmethod
|
||||
def _ensure_tool_call_ids(tool_calls: list[dict[str, Any]] | dict[int, dict[str, Any]]) -> None:
|
||||
"""Fill in missing tool call IDs with synthetic UUIDs.
|
||||
|
||||
Some local servers (llama.cpp, older vLLM) omit or leave the id
|
||||
blank; an empty tool_call_id corrupts subsequent turns because
|
||||
the matching tool-result message can't reference the call.
|
||||
"""
|
||||
items = tool_calls.values() if isinstance(tool_calls, dict) else tool_calls
|
||||
for tc in items:
|
||||
if not tc.get("id"):
|
||||
tc["id"] = f"call_{uuid.uuid4().hex}"
|
||||
|
||||
def _prepare_tool(self, tc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse a tool call and prepare preview info for display."""
|
||||
call_id = tc["id"]
|
||||
func_name = tc["function"]["name"]
|
||||
func_name = tc["function"]["name"].strip()
|
||||
raw_args = tc["function"]["arguments"]
|
||||
|
||||
try:
|
||||
@@ -2344,13 +2557,32 @@ class ChatSession:
|
||||
args = {pk: raw_args}
|
||||
if args is None:
|
||||
preview = raw_args[:4000] + ("..." if len(raw_args) > 4000 else "")
|
||||
# Surface to user so they can see what the model produced
|
||||
self.ui.on_error(
|
||||
f"Malformed tool call from model: {func_name}() — "
|
||||
f"could not parse arguments as JSON.\n"
|
||||
f" Raw: {raw_args[:200]}"
|
||||
)
|
||||
# Build a hint for the model including expected parameter
|
||||
# names so it can self-correct on retry.
|
||||
expected = PRIMARY_KEY_MAP.get(func_name, "")
|
||||
hint = (
|
||||
f' Expected valid JSON, e.g. {{"{expected}": "..."}}'
|
||||
if expected
|
||||
else " Arguments must be valid JSON."
|
||||
)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": func_name,
|
||||
"header": f"\u2717 {func_name}: {exc}",
|
||||
"preview": f" {RED}{preview}{RESET}",
|
||||
"needs_approval": False,
|
||||
"error": f"JSON parse error: {exc}\nRaw arguments: {raw_args[:500]}",
|
||||
"error": (
|
||||
f"JSON parse error for tool '{func_name}': {exc}\n"
|
||||
f"Raw arguments: {raw_args[:500]}\n"
|
||||
f"{hint}\n"
|
||||
f"Please retry with correctly formatted JSON arguments."
|
||||
),
|
||||
}
|
||||
|
||||
preparers = {
|
||||
@@ -2379,13 +2611,21 @@ class ChatSession:
|
||||
# Check if this is an MCP tool
|
||||
if self._mcp_client and self._mcp_client.is_mcp_tool(func_name):
|
||||
return self._prepare_mcp_tool(call_id, func_name, args)
|
||||
self.ui.on_error(f"Model called unknown tool: {func_name!r}")
|
||||
available = list(preparers)
|
||||
if self._mcp_client:
|
||||
available.extend(sorted(self._mcp_client._tool_map))
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": func_name,
|
||||
"header": f"\u2717 Unknown tool: {func_name}",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": f"Unknown tool: {func_name}",
|
||||
"error": (
|
||||
f"Unknown tool: {func_name!r}. "
|
||||
f"Available tools: {', '.join(available)}. "
|
||||
f"Use one of the listed tool names exactly."
|
||||
),
|
||||
}
|
||||
assert args is not None # guaranteed by the early return on args is None above
|
||||
return preparer(call_id, args)
|
||||
@@ -2979,8 +3219,11 @@ class ChatSession:
|
||||
n += count_structured_memories(scope="user", scope_id=self._user_id)
|
||||
return n
|
||||
|
||||
def _check_metacognitive_nudge(self, user_message: str) -> str | None:
|
||||
"""Check if a metacognitive nudge should be injected."""
|
||||
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
|
||||
"""Check if a metacognitive nudge should be injected.
|
||||
|
||||
Returns (nudge_type, nudge_text) or None.
|
||||
"""
|
||||
if not self._mem_cfg.nudges:
|
||||
return None
|
||||
mem_count = self._visible_memory_count()
|
||||
@@ -2994,7 +3237,7 @@ class ChatSession:
|
||||
memory_count=mem_count,
|
||||
cooldown_secs=cd,
|
||||
):
|
||||
return format_nudge("start")
|
||||
return ("start", format_nudge("start"))
|
||||
|
||||
if detect_correction(user_message) and should_nudge(
|
||||
"correction",
|
||||
@@ -3003,7 +3246,7 @@ class ChatSession:
|
||||
memory_count=mem_count,
|
||||
cooldown_secs=cd,
|
||||
):
|
||||
return format_nudge("correction")
|
||||
return ("correction", format_nudge("correction"))
|
||||
|
||||
if detect_completion(user_message) and should_nudge(
|
||||
"completion",
|
||||
@@ -3012,7 +3255,7 @@ class ChatSession:
|
||||
memory_count=mem_count,
|
||||
cooldown_secs=cd,
|
||||
):
|
||||
return format_nudge("completion")
|
||||
return ("completion", format_nudge("completion"))
|
||||
|
||||
return None
|
||||
|
||||
@@ -3386,6 +3629,7 @@ class ChatSession:
|
||||
|
||||
def _exec_mcp_tool(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute an MCP tool call via the MCPClientManager."""
|
||||
self._check_cancelled()
|
||||
call_id: str = item["call_id"]
|
||||
func_name: str = item["mcp_func_name"]
|
||||
args: dict[str, Any] = item["mcp_args"]
|
||||
@@ -3459,6 +3703,7 @@ class ChatSession:
|
||||
|
||||
def _exec_read_resource(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Read an MCP resource by URI."""
|
||||
self._check_cancelled()
|
||||
call_id: str = item["call_id"]
|
||||
uri: str = item["resource_uri"]
|
||||
|
||||
@@ -3536,6 +3781,7 @@ class ChatSession:
|
||||
|
||||
def _exec_use_prompt(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Invoke an MCP prompt and return expanded messages."""
|
||||
self._check_cancelled()
|
||||
call_id: str = item["call_id"]
|
||||
name: str = item["prompt_name"]
|
||||
arguments: dict[str, str] = item["prompt_arguments"]
|
||||
@@ -3562,6 +3808,10 @@ class ChatSession:
|
||||
|
||||
def _exec_bash(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a bash command via temp script, streaming stdout."""
|
||||
self._check_cancelled()
|
||||
# Capture cancel event locally so force-cancel (which replaces
|
||||
# _cancel_event with a fresh instance) doesn't disarm this check.
|
||||
cancel = self._cancel_event
|
||||
call_id, command = item["call_id"], item["command"]
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
||||
@@ -3578,6 +3828,8 @@ class ChatSession:
|
||||
start_new_session=True,
|
||||
env=scrubbed_env(),
|
||||
)
|
||||
with self._procs_lock:
|
||||
self._active_procs.add(proc)
|
||||
# Drain stderr in background thread to avoid pipe deadlock
|
||||
stderr_lines: list[str] = []
|
||||
|
||||
@@ -3615,7 +3867,7 @@ class ChatSession:
|
||||
except Exception:
|
||||
log.debug("UI callback error during tool output", exc_info=True)
|
||||
# Check cancellation during long-running commands
|
||||
if self._cancel_event.is_set():
|
||||
if cancel.is_set():
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
@@ -3632,6 +3884,8 @@ class ChatSession:
|
||||
log.warning("Process did not exit after SIGKILL, pid=%d", proc.pid)
|
||||
stderr_thread.join(timeout=5)
|
||||
finally:
|
||||
with self._procs_lock:
|
||||
self._active_procs.discard(proc)
|
||||
os.unlink(script_path)
|
||||
|
||||
if timed_out.is_set():
|
||||
@@ -3652,11 +3906,11 @@ class ChatSession:
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"Command timed out after {self.tool_timeout}s"
|
||||
self.ui.on_error(msg)
|
||||
self.ui.on_tool_result(call_id, "bash", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
msg = f"Error executing command: {e}"
|
||||
self.ui.on_error(msg)
|
||||
self.ui.on_tool_result(call_id, "bash", msg)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
@@ -3676,10 +3930,14 @@ class ChatSession:
|
||||
all_lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error: {path} not found"
|
||||
msg = f"Error: {path} not found"
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error reading {path}: {e}"
|
||||
msg = f"Error reading {path}: {e}"
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
self._read_files.add(resolved)
|
||||
total_lines = len(all_lines)
|
||||
@@ -3715,7 +3973,9 @@ class ChatSession:
|
||||
size = os.path.getsize(path)
|
||||
except OSError as e:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error: {path}: {e}"
|
||||
msg = f"Error: {path}: {e}"
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
self._read_files.add(resolved)
|
||||
desc = f"image (no vision, {size:,} bytes)"
|
||||
self.ui.on_tool_result(call_id, "read_file", desc)
|
||||
@@ -3729,19 +3989,25 @@ class ChatSession:
|
||||
raw = f.read()
|
||||
except FileNotFoundError:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error: {path} not found"
|
||||
msg = f"Error: {path} not found"
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
self._read_files.discard(resolved)
|
||||
return call_id, f"Error reading {path}: {e}"
|
||||
msg = f"Error reading {path}: {e}"
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
if len(raw) > _IMAGE_SIZE_CAP:
|
||||
self._read_files.discard(resolved)
|
||||
size_mb = len(raw) / (1024 * 1024)
|
||||
cap_mb = _IMAGE_SIZE_CAP / (1024 * 1024)
|
||||
return call_id, (
|
||||
msg = (
|
||||
f"Error: image {path} is {size_mb:.1f} MB, "
|
||||
f"exceeds {cap_mb:.0f} MB limit for vision."
|
||||
)
|
||||
self.ui.on_tool_result(call_id, "read_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
self._read_files.add(resolved)
|
||||
b64data = base64.b64encode(raw).decode("ascii")
|
||||
@@ -3806,11 +4072,11 @@ class ChatSession:
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"Search timed out after {self.tool_timeout}s"
|
||||
self.ui.on_error(msg)
|
||||
self.ui.on_tool_result(call_id, "search", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
msg = f"Search error: {e}"
|
||||
self.ui.on_error(msg)
|
||||
msg = f"Error: search failed: {e}"
|
||||
self.ui.on_tool_result(call_id, "search", msg)
|
||||
return call_id, msg
|
||||
|
||||
def _run_agent(
|
||||
@@ -3924,6 +4190,7 @@ class ChatSession:
|
||||
"content": result.content or "",
|
||||
}
|
||||
if result.tool_calls:
|
||||
self._ensure_tool_call_ids(result.tool_calls)
|
||||
msg_dict["tool_calls"] = result.tool_calls
|
||||
agent_messages.append(msg_dict)
|
||||
|
||||
@@ -3937,7 +4204,7 @@ class ChatSession:
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
for tc_dict in result.tool_calls:
|
||||
self._check_cancelled()
|
||||
tool_name = tc_dict["function"]["name"]
|
||||
tool_name = tc_dict["function"]["name"].strip()
|
||||
|
||||
# Guard 1: block recursive agent calls.
|
||||
if tool_name in ("task_agent", "plan_agent"):
|
||||
@@ -4231,7 +4498,7 @@ class ChatSession:
|
||||
{"role": "system", "content": self._plan_system_content()},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc_id,
|
||||
@@ -4358,9 +4625,13 @@ class ChatSession:
|
||||
return call_id, msg
|
||||
|
||||
except Exception as e:
|
||||
return call_id, f"Error: {e}"
|
||||
msg = f"Error: {e}"
|
||||
self.ui.on_tool_result(call_id, "memory", msg)
|
||||
return call_id, msg
|
||||
|
||||
return call_id, "Error: unexpected action"
|
||||
msg = "Error: unexpected action"
|
||||
self.ui.on_tool_result(call_id, "memory", msg)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Search conversation history."""
|
||||
@@ -4474,6 +4745,7 @@ class ChatSession:
|
||||
|
||||
def _exec_notify(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Send a notification directly to the channel gateway via HTTP."""
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
|
||||
if self._notify_count >= 5:
|
||||
@@ -4858,6 +5130,7 @@ class ChatSession:
|
||||
|
||||
def _exec_write_file(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Write content to a file, creating parent directories as needed."""
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path, content, resolved = item["path"], item["content"], item["resolved"]
|
||||
try:
|
||||
@@ -4869,7 +5142,9 @@ class ChatSession:
|
||||
self.ui.on_tool_result(call_id, "write_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
return call_id, f"Error writing {path}: {e}"
|
||||
msg = f"Error writing {path}: {e}"
|
||||
self.ui.on_tool_result(call_id, "write_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_edit_file(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Replace an exact string in a file (re-reads to avoid TOCTOU).
|
||||
@@ -4877,6 +5152,7 @@ class ChatSession:
|
||||
When near_line is set, picks the occurrence nearest that line
|
||||
instead of requiring uniqueness.
|
||||
"""
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path, old_string, new_string = (
|
||||
item["path"],
|
||||
@@ -4889,17 +5165,17 @@ class ChatSession:
|
||||
content = f.read()
|
||||
occurrences = find_occurrences(content, old_string)
|
||||
if len(occurrences) == 0:
|
||||
return (
|
||||
call_id,
|
||||
f"Error: old_string no longer found in {path} (file changed)",
|
||||
)
|
||||
msg = f"Error: old_string no longer found in {path} (file changed)"
|
||||
self.ui.on_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
if len(occurrences) > 1 and near_line is None:
|
||||
line_list = ", ".join(str(ln) for ln in occurrences)
|
||||
return (
|
||||
call_id,
|
||||
msg = (
|
||||
f"Error: old_string found {len(occurrences)} times "
|
||||
f"at lines {line_list} (file changed)",
|
||||
f"at lines {line_list} (file changed)"
|
||||
)
|
||||
self.ui.on_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
if near_line is not None and len(occurrences) > 1:
|
||||
# Replace only the occurrence nearest to near_line
|
||||
idx = pick_nearest(content, old_string, near_line)
|
||||
@@ -4912,7 +5188,9 @@ class ChatSession:
|
||||
self.ui.on_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
return call_id, f"Error writing {path}: {e}"
|
||||
msg = f"Error writing {path}: {e}"
|
||||
self.ui.on_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_math(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute Python code in sandboxed subprocess."""
|
||||
@@ -4920,14 +5198,13 @@ class ChatSession:
|
||||
output, is_error = execute_math_sandboxed(code, timeout=self.tool_timeout)
|
||||
output = self._truncate_output(output)
|
||||
|
||||
self.ui.on_tool_result(call_id, "math", output)
|
||||
|
||||
if is_error:
|
||||
return call_id, f"Error:\n{output}"
|
||||
return call_id, output if output else "(no output)"
|
||||
result_msg = f"Error:\n{output}" if is_error else output if output else "(no output)"
|
||||
self.ui.on_tool_result(call_id, "math", result_msg)
|
||||
return call_id, result_msg
|
||||
|
||||
def _exec_man(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Look up a man or info page."""
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
page = item["page"]
|
||||
section = item.get("section", "")
|
||||
@@ -4969,9 +5246,13 @@ class ChatSession:
|
||||
self.ui.on_tool_result(call_id, "man", msg)
|
||||
return call_id, msg
|
||||
except FileNotFoundError:
|
||||
return call_id, "Error: man command not available"
|
||||
msg = "Error: man command not available"
|
||||
self.ui.on_tool_result(call_id, "man", msg)
|
||||
return call_id, msg
|
||||
except subprocess.TimeoutExpired:
|
||||
return call_id, "Error: man page lookup timed out"
|
||||
msg = "Error: man page lookup timed out"
|
||||
self.ui.on_tool_result(call_id, "man", msg)
|
||||
return call_id, msg
|
||||
|
||||
text = self._truncate_output(text)
|
||||
|
||||
@@ -4981,6 +5262,7 @@ class ChatSession:
|
||||
|
||||
def _exec_web_fetch(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Fetch a URL, then summarize/extract using an API call."""
|
||||
self._check_cancelled()
|
||||
call_id, url = item["call_id"], item["url"]
|
||||
question = item.get("question", "Summarize the key content of this page.")
|
||||
|
||||
@@ -5002,16 +5284,16 @@ class ChatSession:
|
||||
text = text[: 10 * 1024 * 1024]
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
msg = f"Fetch failed: HTTP {e.response.status_code}"
|
||||
self.ui.on_error(msg)
|
||||
msg = f"Error: fetch failed: HTTP {e.response.status_code}"
|
||||
self.ui.on_tool_result(call_id, "web_fetch", msg)
|
||||
return call_id, msg
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
msg = f"Fetch failed: {e}"
|
||||
self.ui.on_error(msg)
|
||||
msg = f"Error: fetch failed: {e}"
|
||||
self.ui.on_tool_result(call_id, "web_fetch", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
msg = f"Error fetching URL: {e}"
|
||||
self.ui.on_error(msg)
|
||||
self.ui.on_tool_result(call_id, "web_fetch", msg)
|
||||
return call_id, msg
|
||||
|
||||
if not text.strip():
|
||||
@@ -5068,6 +5350,7 @@ class ChatSession:
|
||||
|
||||
def _exec_web_search(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Search the web via the configured backend (Tavily, DDG, or MCP)."""
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
query = item["query"]
|
||||
max_results = item.get("max_results", 5)
|
||||
@@ -5075,15 +5358,15 @@ class ChatSession:
|
||||
|
||||
client = self._resolve_search_client()
|
||||
if not client:
|
||||
msg = "Web search backend not available"
|
||||
self.ui.on_error(msg)
|
||||
msg = "Error: web search backend not available"
|
||||
self.ui.on_tool_result(call_id, "web_search", msg)
|
||||
return call_id, msg
|
||||
|
||||
try:
|
||||
output = client.search(query, max_results=max_results, topic=topic)
|
||||
except Exception as e:
|
||||
msg = f"Web search failed: {e}"
|
||||
self.ui.on_error(msg)
|
||||
msg = f"Error: web search failed: {e}"
|
||||
self.ui.on_tool_result(call_id, "web_search", msg)
|
||||
return call_id, msg
|
||||
|
||||
self.ui.on_tool_result(call_id, "web_search", output)
|
||||
@@ -5130,6 +5413,7 @@ class ChatSession:
|
||||
elif cmd == "/clear":
|
||||
self.messages.clear()
|
||||
self._read_files.clear()
|
||||
self._recent_tool_sigs.clear()
|
||||
self._last_usage = None
|
||||
self._msg_tokens = []
|
||||
self.ui.on_info("Context cleared (messages preserved in database).")
|
||||
@@ -5139,6 +5423,7 @@ class ChatSession:
|
||||
|
||||
self.messages.clear()
|
||||
self._read_files.clear()
|
||||
self._recent_tool_sigs.clear()
|
||||
self._last_usage = None
|
||||
self._msg_tokens = []
|
||||
self._ws_id = uuid.uuid4().hex
|
||||
|
||||
@@ -528,6 +528,29 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
"information from conversations into long-term memory. This helps the AI "
|
||||
"remember context across separate conversations.",
|
||||
),
|
||||
# -- tls ----------------------------------------------------------------
|
||||
SettingDef(
|
||||
"tls.enabled",
|
||||
"bool",
|
||||
False,
|
||||
"Enable mTLS for inter-service communication",
|
||||
"tls",
|
||||
restart_required=True,
|
||||
help="When enabled, the console runs an internal Certificate Authority and "
|
||||
"ACME server. All cluster services (servers, bridge, channels) auto-provision "
|
||||
"short-lived certificates for mutual TLS. Requires lacme: pip install turnstone[tls]",
|
||||
),
|
||||
SettingDef(
|
||||
"tls.acme_directory",
|
||||
"str",
|
||||
"",
|
||||
"External ACME CA URL for the console's frontend HTTPS cert",
|
||||
"tls",
|
||||
restart_required=True,
|
||||
help="Set to a public ACME directory URL (e.g. https://acme-v02.api.letsencrypt.org/"
|
||||
"directory) to get a publicly trusted certificate for the console's HTTPS endpoint. "
|
||||
"Leave empty to self-issue from the internal CA (use when behind a reverse proxy).",
|
||||
),
|
||||
]
|
||||
return {d.key: d for d in defs}
|
||||
|
||||
@@ -543,7 +566,7 @@ BOOTSTRAP_SECTIONS: frozenset[str] = frozenset(
|
||||
"auth",
|
||||
"bridge",
|
||||
"console",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ from turnstone.core.storage._schema import (
|
||||
skill_versions,
|
||||
structured_memories,
|
||||
system_settings,
|
||||
tls_account_keys,
|
||||
tls_ca,
|
||||
tls_certificates,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
@@ -2805,6 +2808,108 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = pg_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["id"],
|
||||
set_={"key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = pg_insert(tls_ca).values(name=name, cert_pem=cert_pem, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["name"],
|
||||
set_={"cert_pem": cert_pem, "key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = pg_insert(tls_certificates).values(
|
||||
domain=domain,
|
||||
cert_pem=cert_pem,
|
||||
fullchain_pem=fullchain_pem,
|
||||
key_pem=key_pem,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
meta=meta,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["domain"],
|
||||
set_={
|
||||
"cert_pem": cert_pem,
|
||||
"fullchain_pem": fullchain_pem,
|
||||
"key_pem": key_pem,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -952,6 +952,49 @@ class StorageBackend(Protocol):
|
||||
"""Delete an MCP server definition. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- TLS / ACME (lacme Store) ----------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
"""Persist an ACME account private key."""
|
||||
...
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
"""Load an ACME account key PEM by ID. Returns None if not found."""
|
||||
...
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
"""Persist a CA root certificate and key."""
|
||||
...
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
"""Load CA cert+key by name. Returns dict with cert_pem, key_pem or None."""
|
||||
...
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
"""Persist an issued certificate (upsert by domain)."""
|
||||
...
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
"""Load certificate by domain. Returns dict or None."""
|
||||
...
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
"""List all stored certificates."""
|
||||
...
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
"""Delete a certificate by domain. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -22,6 +22,10 @@ def init_storage(
|
||||
url: str = "",
|
||||
pool_size: int = 2,
|
||||
run_migrations: bool = True,
|
||||
sslmode: str = "",
|
||||
sslrootcert: str = "",
|
||||
sslcert: str = "",
|
||||
sslkey: str = "",
|
||||
) -> StorageBackend:
|
||||
"""Initialize the storage backend singleton.
|
||||
|
||||
@@ -52,6 +56,26 @@ def init_storage(
|
||||
if not url:
|
||||
msg = "PostgreSQL backend requires a connection URL (db_url)"
|
||||
raise ValueError(msg)
|
||||
# Append SSL params to URL if provided (validated + encoded)
|
||||
valid_sslmodes = {"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||
if sslmode and sslmode not in valid_sslmodes:
|
||||
msg = f"Invalid sslmode: {sslmode!r} (expected one of {sorted(valid_sslmodes)})"
|
||||
raise ValueError(msg)
|
||||
ssl_params = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"sslmode": sslmode,
|
||||
"sslrootcert": sslrootcert,
|
||||
"sslcert": sslcert,
|
||||
"sslkey": sslkey,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if ssl_params:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
sep = "&" if "?" in url else "?"
|
||||
url += sep + urlencode(ssl_params)
|
||||
_storage = PostgreSQLBackend(url, pool_size=pool_size, create_tables=create_tables)
|
||||
log.info("Storage initialized: PostgreSQL")
|
||||
|
||||
|
||||
@@ -547,3 +547,34 @@ oidc_pending_states = sa.Table(
|
||||
sa.Column("audience", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# ── TLS / ACME (lacme integration) ──────────────────────────────────────────
|
||||
|
||||
tls_account_keys = sa.Table(
|
||||
"tls_account_keys",
|
||||
metadata,
|
||||
sa.Column("id", sa.Text, primary_key=True),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
tls_ca = sa.Table(
|
||||
"tls_ca",
|
||||
metadata,
|
||||
sa.Column("name", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
tls_certificates = sa.Table(
|
||||
"tls_certificates",
|
||||
metadata,
|
||||
sa.Column("domain", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("fullchain_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("issued_at", sa.Text, nullable=False),
|
||||
sa.Column("expires_at", sa.Text, nullable=False),
|
||||
sa.Column("meta", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,9 @@ from turnstone.core.storage._schema import (
|
||||
skill_versions,
|
||||
structured_memories,
|
||||
system_settings,
|
||||
tls_account_keys,
|
||||
tls_ca,
|
||||
tls_certificates,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
@@ -2854,6 +2857,110 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = sqlite_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["id"],
|
||||
set_={"key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = sqlite_insert(tls_ca).values(
|
||||
name=name, cert_pem=cert_pem, key_pem=key_pem, created=now
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["name"],
|
||||
set_={"cert_pem": cert_pem, "key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
stmt = sqlite_insert(tls_certificates).values(
|
||||
domain=domain,
|
||||
cert_pem=cert_pem,
|
||||
fullchain_pem=fullchain_pem,
|
||||
key_pem=key_pem,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
meta=meta,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["domain"],
|
||||
set_={
|
||||
"cert_pem": cert_pem,
|
||||
"fullchain_pem": fullchain_pem,
|
||||
"key_pem": key_pem,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -152,7 +152,7 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
messages.append({"role": "user", "content": content or ""})
|
||||
|
||||
elif role == "assistant":
|
||||
msg: dict[str, Any] = {"role": "assistant", "content": content}
|
||||
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
|
||||
if provider_data:
|
||||
with contextlib.suppress(json.JSONDecodeError, TypeError):
|
||||
msg["_provider_content"] = json.loads(provider_data)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""TLS certificate storage for lacme ACME integration.
|
||||
|
||||
Three tables for the lacme Store protocol:
|
||||
- tls_account_keys: ACME account private keys
|
||||
- tls_ca: CA root certificate and key
|
||||
- tls_certificates: Issued service certificates
|
||||
|
||||
Revision ID: 026
|
||||
Revises: 025
|
||||
Create Date: 2026-03-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "026"
|
||||
down_revision = "025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tls_account_keys",
|
||||
sa.Column("id", sa.Text, primary_key=True),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tls_ca",
|
||||
sa.Column("name", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tls_certificates",
|
||||
sa.Column("domain", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("fullchain_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("issued_at", sa.Text, nullable=False),
|
||||
sa.Column("expires_at", sa.Text, nullable=False),
|
||||
sa.Column("meta", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("tls_certificates")
|
||||
op.drop_table("tls_ca")
|
||||
op.drop_table("tls_account_keys")
|
||||
@@ -0,0 +1,259 @@
|
||||
"""TLS Client — certificate provisioning for service nodes.
|
||||
|
||||
Non-console services (server, bridge, channel gateway) use this to
|
||||
request certificates from the console's ACME endpoint and build
|
||||
SSL contexts for mTLS communication.
|
||||
|
||||
Flow:
|
||||
1. Fetch CA root cert from console (plain HTTP, first boot)
|
||||
2. Request service cert via ACME (plain HTTP, first boot)
|
||||
3. Build SSL contexts for uvicorn (server) and httpx (client)
|
||||
4. Start auto-renewal (uses existing cert for mTLS to console)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ssl
|
||||
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_RENEW_INTERVAL_HOURS = 24
|
||||
_RENEW_BEFORE_EXPIRY_DAYS = 1
|
||||
|
||||
|
||||
def _require_lacme() -> Any:
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class TLSClient:
|
||||
"""TLS client for service nodes.
|
||||
|
||||
Requests certificates from the console's ACME endpoint and provides
|
||||
SSL contexts for server (uvicorn) and client (httpx) use.
|
||||
|
||||
Typical usage::
|
||||
|
||||
client = TLSClient(storage, console_url="http://console:8080")
|
||||
await client.init() # Fetch CA, request cert
|
||||
server_ctx = client.get_server_ssl_context() # For uvicorn
|
||||
client_ctx = client.get_client_ssl_context() # For httpx
|
||||
await client.start_renewal() # Background auto-renewal
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
console_url: str = "",
|
||||
hostnames: list[str] | None = None,
|
||||
) -> None:
|
||||
lacme = _require_lacme()
|
||||
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
self._storage = storage
|
||||
self._store = StorageStore(storage)
|
||||
self._console_url = console_url.rstrip("/") if console_url else ""
|
||||
self._hostnames = hostnames or []
|
||||
self._event_dispatcher = lacme.EventDispatcher()
|
||||
self._ca_pem: bytes | None = None
|
||||
self._bundle: Any | None = None
|
||||
self._renewal_task: Any | None = None
|
||||
self._renewal_client: Any | None = None
|
||||
|
||||
# Wire Prometheus metrics
|
||||
try:
|
||||
from lacme.metrics import setup_metrics
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass # prometheus_client or lacme.metrics missing
|
||||
except ValueError as exc:
|
||||
if "Duplicated timeseries" in str(exc):
|
||||
log.debug("tls_metrics_already_registered")
|
||||
else:
|
||||
raise
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Fetch CA root cert and request a service certificate.
|
||||
|
||||
If no console_url was provided, discovers it from the services
|
||||
table. Performs initial cert provisioning over plain HTTP (ACME
|
||||
protocol provides integrity via JWS).
|
||||
"""
|
||||
if not self._console_url:
|
||||
self._console_url = self._discover_console_url()
|
||||
await self._fetch_ca_cert()
|
||||
await self._request_cert()
|
||||
|
||||
def _discover_console_url(self) -> str:
|
||||
"""Look up the console URL from the services table."""
|
||||
consoles = self._storage.list_services("console", max_age_seconds=3600)
|
||||
if not consoles:
|
||||
raise RuntimeError(
|
||||
"No console service found in services table. "
|
||||
"Ensure the console is running and has registered, "
|
||||
"or provide console_url explicitly."
|
||||
)
|
||||
url = consoles[0]["url"]
|
||||
log.info("tls.console.discovered", url=url)
|
||||
return url
|
||||
|
||||
async def _fetch_ca_cert(self) -> None:
|
||||
"""Fetch the CA root cert from the console.
|
||||
|
||||
Always uses plain HTTP for bootstrapping — the node doesn't have
|
||||
the CA cert yet, so it can't verify HTTPS.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# Force HTTP for bootstrap (can't verify HTTPS without CA cert)
|
||||
base = self._console_url.replace("https://", "http://")
|
||||
url = f"{base}/acme/ca.pem"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
self._ca_pem = resp.content
|
||||
log.info("tls.ca.fetched", url=url)
|
||||
except Exception:
|
||||
log.error("tls.ca.fetch_failed", url=url, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _request_cert(self) -> None:
|
||||
"""Request a certificate from the console's ACME endpoint."""
|
||||
if not self._hostnames:
|
||||
raise ValueError("No hostnames configured for TLS cert request")
|
||||
|
||||
# Check for existing valid cert
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing = self._store.load_cert(self._hostnames[0])
|
||||
if existing is not None and existing.expires_at > datetime.now(UTC):
|
||||
self._bundle = existing
|
||||
log.info("tls.cert.loaded", domain=self._hostnames[0])
|
||||
return
|
||||
|
||||
# Request new cert via ACME (plain HTTP for initial request)
|
||||
lacme = _require_lacme()
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
directory_url = f"{self._console_url}/acme/directory"
|
||||
|
||||
async with lacme.Client(
|
||||
directory_url=directory_url,
|
||||
store=self._store,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
challenge_handler=HTTP01Handler(),
|
||||
allow_insecure=True,
|
||||
) as client:
|
||||
self._bundle = await client.issue(self._hostnames)
|
||||
self._store.save_cert(self._bundle)
|
||||
log.info("tls.cert.issued", domain=self._hostnames[0])
|
||||
|
||||
# -- Auto-renewal ----------------------------------------------------------
|
||||
|
||||
async def start_renewal(self) -> None:
|
||||
"""Start background auto-renewal via the console's ACME endpoint."""
|
||||
lacme = _require_lacme()
|
||||
|
||||
def _on_renewed(bundle: Any) -> None:
|
||||
self._bundle = bundle
|
||||
log.info("tls.cert.renewed", domain=bundle.domain)
|
||||
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
directory_url = f"{self._console_url}/acme/directory"
|
||||
client = lacme.Client(
|
||||
directory_url=directory_url,
|
||||
store=self._store,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
challenge_handler=HTTP01Handler(),
|
||||
allow_insecure=True,
|
||||
)
|
||||
await client.__aenter__()
|
||||
|
||||
manager = lacme.RenewalManager(
|
||||
client=client,
|
||||
store=self._store,
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
days_before_expiry=_RENEW_BEFORE_EXPIRY_DAYS,
|
||||
on_renewed=_on_renewed,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._renewal_task = manager.start()
|
||||
self._renewal_client = client
|
||||
log.info("tls.renewal.started", directory=directory_url)
|
||||
|
||||
async def stop_renewal(self) -> None:
|
||||
"""Stop background renewal and close the ACME client."""
|
||||
import contextlib
|
||||
|
||||
if self._renewal_task is not None:
|
||||
import asyncio
|
||||
|
||||
self._renewal_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._renewal_task
|
||||
except Exception:
|
||||
log.exception("tls.renewal.stop_error")
|
||||
self._renewal_task = None
|
||||
if self._renewal_client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._renewal_client.__aexit__(None, None, None)
|
||||
self._renewal_client = None
|
||||
|
||||
# -- SSL contexts ----------------------------------------------------------
|
||||
|
||||
def get_server_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build SSL context for uvicorn HTTPS listener."""
|
||||
if self._bundle is None or self._ca_pem is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._bundle.fullchain_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
)
|
||||
|
||||
def get_client_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build mTLS client context for httpx connections."""
|
||||
if self._bundle is None or self._ca_pem is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._bundle.cert_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
)
|
||||
|
||||
# -- Properties ------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ca_pem(self) -> bytes | None:
|
||||
return self._ca_pem
|
||||
|
||||
@property
|
||||
def bundle(self) -> Any | None:
|
||||
return self._bundle
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
return self._bundle is not None and self._ca_pem is not None
|
||||
@@ -0,0 +1,127 @@
|
||||
"""lacme Store adapter backed by turnstone's storage backend.
|
||||
|
||||
Bridges lacme's Store protocol to turnstone's StorageBackend, keeping
|
||||
all TLS state (account keys, CA, certificates) in the shared database
|
||||
rather than the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
|
||||
def _parse_utc(iso: str) -> datetime:
|
||||
"""Parse an ISO timestamp, assuming UTC if naive."""
|
||||
dt = datetime.fromisoformat(iso)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def _ensure_lacme() -> Any:
|
||||
"""Import lacme, raising a clear error if not installed."""
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class StorageStore:
|
||||
"""lacme Store implementation backed by turnstone's database.
|
||||
|
||||
Implements the 7-method Store protocol that lacme's CertificateAuthority
|
||||
and Client use for persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, storage: StorageBackend) -> None:
|
||||
self._storage = storage
|
||||
|
||||
# -- Account key -----------------------------------------------------------
|
||||
|
||||
def save_account_key(self, key: Any) -> None:
|
||||
"""Persist the ACME account private key."""
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
)
|
||||
|
||||
key_pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
|
||||
self._storage.save_tls_account_key("default", key_pem)
|
||||
|
||||
def load_account_key(self) -> Any | None:
|
||||
"""Load the ACME account private key, or None."""
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
pem = self._storage.load_tls_account_key("default")
|
||||
if pem is None:
|
||||
return None
|
||||
return load_pem_private_key(pem.encode(), password=None)
|
||||
|
||||
# -- CA --------------------------------------------------------------------
|
||||
|
||||
def save_ca(self, name: str, cert_pem: bytes, key_pem: bytes) -> None:
|
||||
"""Persist a CA root certificate and key."""
|
||||
self._storage.save_tls_ca(name, cert_pem.decode(), key_pem.decode())
|
||||
|
||||
def load_ca(self, name: str) -> tuple[bytes, bytes] | None:
|
||||
"""Load CA cert+key by name. Returns (cert_pem, key_pem) or None."""
|
||||
row = self._storage.load_tls_ca(name)
|
||||
if row is None:
|
||||
return None
|
||||
return row["cert_pem"].encode(), row["key_pem"].encode()
|
||||
|
||||
# -- Certificates ----------------------------------------------------------
|
||||
|
||||
def save_cert(self, bundle: Any) -> Any:
|
||||
"""Persist an issued certificate bundle."""
|
||||
meta = json.dumps({"domains": list(bundle.domains)})
|
||||
self._storage.save_tls_cert(
|
||||
domain=bundle.domain,
|
||||
cert_pem=bundle.cert_pem.decode(),
|
||||
fullchain_pem=bundle.fullchain_pem.decode(),
|
||||
key_pem=bundle.key_pem.decode(),
|
||||
issued_at=bundle.issued_at.isoformat(),
|
||||
expires_at=bundle.expires_at.isoformat(),
|
||||
meta=meta,
|
||||
)
|
||||
return bundle
|
||||
|
||||
def load_cert(self, domain: str) -> Any | None:
|
||||
"""Load a certificate bundle by domain."""
|
||||
row = self._storage.load_tls_cert(domain)
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_bundle(row)
|
||||
|
||||
def list_certs(self) -> list[Any]:
|
||||
"""List all stored certificate bundles."""
|
||||
rows = self._storage.list_tls_certs()
|
||||
return [self._row_to_bundle(r) for r in rows]
|
||||
|
||||
def delete_cert(self, domain: str) -> bool:
|
||||
"""Delete a stored certificate bundle by domain."""
|
||||
return self._storage.delete_tls_cert(domain)
|
||||
|
||||
def _row_to_bundle(self, row: dict[str, Any]) -> Any:
|
||||
"""Convert a storage row dict to a lacme CertBundle."""
|
||||
lacme = _ensure_lacme()
|
||||
meta = json.loads(row.get("meta") or "{}")
|
||||
domains = tuple(meta.get("domains", [row["domain"]]))
|
||||
return lacme.CertBundle(
|
||||
domain=row["domain"],
|
||||
domains=domains,
|
||||
cert_pem=row["cert_pem"].encode(),
|
||||
fullchain_pem=row["fullchain_pem"].encode(),
|
||||
key_pem=row["key_pem"].encode(),
|
||||
issued_at=_parse_utc(row["issued_at"]),
|
||||
expires_at=_parse_utc(row["expires_at"]),
|
||||
)
|
||||
@@ -47,6 +47,10 @@ class AsyncRedisBroker:
|
||||
prefix: str = "turnstone",
|
||||
password: str | None = None,
|
||||
response_ttl: int = 600,
|
||||
ssl: bool = False,
|
||||
ssl_ca_certs: str | None = None,
|
||||
ssl_certfile: str | None = None,
|
||||
ssl_keyfile: str | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
@@ -54,6 +58,15 @@ class AsyncRedisBroker:
|
||||
self._password = password
|
||||
self._prefix = prefix
|
||||
self._response_ttl = response_ttl
|
||||
self._ssl_kwargs: dict[str, Any] = {}
|
||||
if ssl:
|
||||
self._ssl_kwargs["ssl"] = True
|
||||
if ssl_ca_certs:
|
||||
self._ssl_kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
if ssl_certfile:
|
||||
self._ssl_kwargs["ssl_certfile"] = ssl_certfile
|
||||
if ssl_keyfile:
|
||||
self._ssl_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
self._redis: _aredis_t.Redis[str] | None = None
|
||||
self._pubsub: _aredis_t.client.PubSub | None = None
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
@@ -82,6 +95,7 @@ class AsyncRedisBroker:
|
||||
password=self._password,
|
||||
decode_responses=True,
|
||||
retry_on_timeout=True,
|
||||
**self._ssl_kwargs,
|
||||
max_connections=200,
|
||||
)
|
||||
self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True)
|
||||
|
||||
@@ -78,6 +78,8 @@ class Bridge:
|
||||
heartbeat_ttl: int = 60,
|
||||
auth_token: str = "",
|
||||
token_manager: Any = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
) -> None:
|
||||
self._server_url = server_url.rstrip("/")
|
||||
self._broker = broker or RedisBroker()
|
||||
@@ -89,6 +91,8 @@ class Bridge:
|
||||
self._started_at = time.time()
|
||||
self._auth_token = auth_token
|
||||
self._token_manager = token_manager # ServiceTokenManager (auto-rotating)
|
||||
self._tls_verify = tls_verify # CA cert path or ssl.SSLContext or True
|
||||
self._tls_cert = tls_cert # (cert_path, key_path) for mTLS
|
||||
|
||||
# Shared httpx client for short-lived POST requests (main thread only).
|
||||
# Auth headers refreshed per-request via event hook so auto-rotating
|
||||
@@ -97,6 +101,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=30,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
)
|
||||
|
||||
# Protected by _lock — accessed from main, global SSE, and per-ws SSE threads
|
||||
@@ -592,6 +598,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=None,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
) as sse_client:
|
||||
while self._running:
|
||||
# Stop if workstream was closed (thread removed from registry)
|
||||
@@ -903,6 +911,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=None,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
) as sse_client:
|
||||
while self._running:
|
||||
try:
|
||||
@@ -1125,6 +1135,45 @@ def main() -> None:
|
||||
)
|
||||
log.info("bridge.jwt_minted")
|
||||
|
||||
# TLS: request cert from console ACME if enabled
|
||||
tls_verify: Any = True
|
||||
tls_cert: tuple[str, str] | None = None
|
||||
if os.environ.get("TURNSTONE_TLS_ENABLED", "").lower() in ("true", "1", "yes"):
|
||||
try:
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
from turnstone.core.storage import init_storage
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
db_url = os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
storage = init_storage(db_backend, path=db_path, url=db_url)
|
||||
|
||||
hostname = socket.getfqdn()
|
||||
hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
tls_client = TLSClient(
|
||||
storage=storage,
|
||||
hostnames=hostnames,
|
||||
)
|
||||
asyncio.run(tls_client.init())
|
||||
ssl_ctx = tls_client.get_client_ssl_context()
|
||||
if ssl_ctx:
|
||||
# SSLContext has both CA (verify server) and client cert
|
||||
# (present to server) loaded — full mTLS in one object
|
||||
tls_verify = ssl_ctx
|
||||
if args.server_url.startswith("http://"):
|
||||
args.server_url = args.server_url.replace("http://", "https://")
|
||||
log.info("bridge.tls.enabled: %s", args.server_url)
|
||||
except ImportError:
|
||||
log.warning("TLS enabled but lacme not installed")
|
||||
except Exception:
|
||||
log.warning("bridge.tls.init_failed", exc_info=True)
|
||||
|
||||
bridge = Bridge(
|
||||
server_url=args.server_url,
|
||||
broker=broker,
|
||||
@@ -1133,6 +1182,8 @@ def main() -> None:
|
||||
heartbeat_ttl=args.heartbeat_ttl,
|
||||
auth_token=auth_token,
|
||||
token_manager=token_manager,
|
||||
tls_verify=tls_verify,
|
||||
tls_cert=tls_cert,
|
||||
)
|
||||
bridge.run()
|
||||
|
||||
|
||||
+40
-3
@@ -122,11 +122,24 @@ class RedisBroker:
|
||||
prefix: str = "turnstone",
|
||||
password: str | None = None,
|
||||
response_ttl: int = 600,
|
||||
ssl: bool = False,
|
||||
ssl_ca_certs: str | None = None,
|
||||
ssl_certfile: str | None = None,
|
||||
ssl_keyfile: str | None = None,
|
||||
) -> None:
|
||||
import redis
|
||||
|
||||
self._prefix = prefix
|
||||
self._response_ttl = response_ttl
|
||||
pool_kwargs: dict[str, Any] = {}
|
||||
if ssl:
|
||||
pool_kwargs["connection_class"] = redis.SSLConnection
|
||||
if ssl_ca_certs:
|
||||
pool_kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
if ssl_certfile:
|
||||
pool_kwargs["ssl_certfile"] = ssl_certfile
|
||||
if ssl_keyfile:
|
||||
pool_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
self._pool: _redis_t.ConnectionPool = redis.ConnectionPool(
|
||||
host=host,
|
||||
port=port,
|
||||
@@ -135,6 +148,7 @@ class RedisBroker:
|
||||
decode_responses=True,
|
||||
retry_on_timeout=True,
|
||||
max_connections=200,
|
||||
**pool_kwargs,
|
||||
)
|
||||
self._redis: _redis_t.Redis[str] = cast(
|
||||
"_redis_t.Redis[str]",
|
||||
@@ -256,7 +270,7 @@ class RedisBroker:
|
||||
|
||||
|
||||
def add_redis_args(parser: Any) -> None:
|
||||
"""Add ``--redis-host``, ``--redis-port``, ``--redis-password``, ``--redis-db``."""
|
||||
"""Add Redis CLI arguments including TLS options."""
|
||||
import os
|
||||
|
||||
parser.add_argument(
|
||||
@@ -281,6 +295,27 @@ def add_redis_args(parser: Any) -> None:
|
||||
default=0,
|
||||
help="Redis DB number (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument("--redis-tls", action="store_true", help="Enable Redis TLS")
|
||||
parser.add_argument("--redis-tls-ca", default=None, help="Redis CA cert path")
|
||||
parser.add_argument("--redis-tls-cert", default=None, help="Redis client cert path")
|
||||
parser.add_argument("--redis-tls-key", default=None, help="Redis client key path")
|
||||
|
||||
|
||||
def _redis_tls_kwargs(args: Any) -> dict[str, Any]:
|
||||
"""Extract Redis TLS kwargs from parsed args."""
|
||||
kwargs: dict[str, Any] = {}
|
||||
if getattr(args, "redis_tls", False):
|
||||
kwargs["ssl"] = True
|
||||
ca = getattr(args, "redis_tls_ca", None)
|
||||
if ca:
|
||||
kwargs["ssl_ca_certs"] = ca
|
||||
cert = getattr(args, "redis_tls_cert", None)
|
||||
if cert:
|
||||
kwargs["ssl_certfile"] = cert
|
||||
key = getattr(args, "redis_tls_key", None)
|
||||
if key:
|
||||
kwargs["ssl_keyfile"] = key
|
||||
return kwargs
|
||||
|
||||
|
||||
def broker_from_args(args: Any) -> RedisBroker:
|
||||
@@ -289,7 +324,8 @@ def broker_from_args(args: Any) -> RedisBroker:
|
||||
host=args.redis_host,
|
||||
port=args.redis_port,
|
||||
db=args.redis_db,
|
||||
password=args.redis_password,
|
||||
password=args.redis_password or None,
|
||||
**_redis_tls_kwargs(args),
|
||||
)
|
||||
|
||||
|
||||
@@ -301,5 +337,6 @@ def async_broker_from_args(args: Any) -> Any:
|
||||
host=args.redis_host,
|
||||
port=args.redis_port,
|
||||
db=args.redis_db,
|
||||
password=args.redis_password,
|
||||
password=args.redis_password or None,
|
||||
**_redis_tls_kwargs(args),
|
||||
)
|
||||
|
||||
+21
-4
@@ -25,12 +25,18 @@ class _BaseClient:
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialise the client.
|
||||
|
||||
When *httpx_client* is provided it is used directly and *base_url*,
|
||||
*token*, and *timeout* are ignored — configure headers and base URL
|
||||
on the injected client instead.
|
||||
When *httpx_client* is provided it is used directly and all other
|
||||
params are ignored — configure headers, base URL, and TLS on the
|
||||
injected client instead.
|
||||
|
||||
For mTLS, pass *ca_cert* (CA bundle path), *client_cert* and
|
||||
*client_key* (client certificate + key paths).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if token:
|
||||
@@ -39,8 +45,19 @@ class _BaseClient:
|
||||
self._client = httpx_client
|
||||
self._owns_client = False
|
||||
else:
|
||||
tls_kwargs: dict[str, Any] = {}
|
||||
if ca_cert:
|
||||
tls_kwargs["verify"] = ca_cert
|
||||
if client_cert or client_key:
|
||||
if not (client_cert and client_key):
|
||||
raise ValueError("Both client_cert and client_key must be provided for mTLS")
|
||||
tls_kwargs["cert"] = (client_cert, client_key)
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url, timeout=timeout, headers=headers, follow_redirects=True
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
**tls_kwargs,
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
DeleteSettingResponse,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
ScheduleInfo,
|
||||
@@ -76,8 +77,19 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(base_url=base_url, token=token, timeout=timeout, httpx_client=httpx_client)
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
httpx_client=httpx_client,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- cluster overview ----------------------------------------------------
|
||||
|
||||
@@ -631,13 +643,16 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"PUT", f"/v1/api/admin/settings/{key}", json_body=body, response_model=SettingInfo
|
||||
)
|
||||
|
||||
async def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
|
||||
async def delete_setting(self, key: str, *, node_id: str = "") -> DeleteSettingResponse:
|
||||
"""Reset a setting to its default value."""
|
||||
params: dict[str, Any] = {}
|
||||
if node_id:
|
||||
params["node_id"] = node_id
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/settings/{key}", params=params, response_model=StatusResponse
|
||||
"DELETE",
|
||||
f"/v1/api/admin/settings/{key}",
|
||||
params=params,
|
||||
response_model=DeleteSettingResponse,
|
||||
)
|
||||
|
||||
# -- MCP servers -------------------------------------------------------
|
||||
@@ -847,9 +862,19 @@ class TurnstoneConsole:
|
||||
base_url: str = "http://localhost:8081",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneConsole(base_url=base_url, token=token, timeout=timeout)
|
||||
self._async = AsyncTurnstoneConsole(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- cluster overview ----------------------------------------------------
|
||||
|
||||
@@ -1184,7 +1209,7 @@ class TurnstoneConsole:
|
||||
def update_setting(self, key: str, value: Any, *, node_id: str = "") -> SettingInfo:
|
||||
return self._runner.run(self._async.update_setting(key, value, node_id=node_id))
|
||||
|
||||
def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
|
||||
def delete_setting(self, key: str, *, node_id: str = "") -> DeleteSettingResponse:
|
||||
return self._runner.run(self._async.delete_setting(key, node_id=node_id))
|
||||
|
||||
# -- MCP servers -------------------------------------------------------
|
||||
|
||||
+30
-6
@@ -60,8 +60,19 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(base_url=base_url, token=token, timeout=timeout, httpx_client=httpx_client)
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
httpx_client=httpx_client,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- workstream management -----------------------------------------------
|
||||
|
||||
@@ -151,11 +162,14 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def cancel(self, ws_id: str) -> StatusResponse:
|
||||
async def cancel(self, ws_id: str, *, force: bool = False) -> StatusResponse:
|
||||
body: dict[str, object] = {"ws_id": ws_id}
|
||||
if force:
|
||||
body["force"] = True
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cancel",
|
||||
json_body={"ws_id": ws_id},
|
||||
json_body=body,
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
@@ -395,9 +409,19 @@ class TurnstoneServer:
|
||||
base_url: str = "http://localhost:8080",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneServer(base_url=base_url, token=token, timeout=timeout)
|
||||
self._async = AsyncTurnstoneServer(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- workstream management -----------------------------------------------
|
||||
|
||||
@@ -452,8 +476,8 @@ class TurnstoneServer:
|
||||
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
||||
|
||||
def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id))
|
||||
def cancel(self, ws_id: str, *, force: bool = False) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id, force=force))
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
|
||||
+135
-16
@@ -18,7 +18,6 @@ import functools
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
@@ -390,7 +389,24 @@ class WebUI:
|
||||
self._ws_current_activity = ""
|
||||
self._ws_activity_state = ""
|
||||
self._broadcast_activity()
|
||||
self._enqueue({"type": "tool_result", "call_id": call_id, "name": name, "output": output})
|
||||
is_error = isinstance(output, str) and (
|
||||
output.startswith("Error")
|
||||
or output.startswith("Command timed out")
|
||||
or output.startswith("Search timed out")
|
||||
or output.startswith("Unknown tool:")
|
||||
or output.startswith("JSON parse error:")
|
||||
or output.startswith("MCP prompt timed out")
|
||||
or output.startswith("MCP prompt error")
|
||||
)
|
||||
event: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"output": output,
|
||||
}
|
||||
if is_error:
|
||||
event["is_error"] = True
|
||||
self._enqueue(event)
|
||||
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
self._enqueue({"type": "tool_output_chunk", "call_id": call_id, "chunk": chunk})
|
||||
@@ -625,13 +641,22 @@ def _build_history(
|
||||
}
|
||||
for tc in msg["tool_calls"]
|
||||
]
|
||||
# Detect denied/blocked tool results by their content prefix.
|
||||
# Detect denied/blocked/errored tool results by their content prefix.
|
||||
if msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and (
|
||||
content.startswith("Denied by user") or content.startswith("Blocked")
|
||||
):
|
||||
entry["denied"] = True
|
||||
if isinstance(content, str):
|
||||
if content.startswith("Denied by user") or content.startswith("Blocked"):
|
||||
entry["denied"] = True
|
||||
elif (
|
||||
content.startswith("Error")
|
||||
or content.startswith("Command timed out")
|
||||
or content.startswith("Search timed out")
|
||||
or content.startswith("Unknown tool:")
|
||||
or content.startswith("JSON parse error:")
|
||||
or content.startswith("MCP prompt timed out")
|
||||
or content.startswith("MCP prompt error")
|
||||
):
|
||||
entry["is_error"] = True
|
||||
history.append(entry)
|
||||
|
||||
# Propagate denial from tool results to their parent assistant entry.
|
||||
@@ -1117,6 +1142,15 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
# Atomically check-and-start to prevent two concurrent workers on the
|
||||
# same session (ChatSession.send() is not thread-safe).
|
||||
# If cancel was requested, poll briefly for the worker to exit before
|
||||
# rejecting. Snapshot the thread ref since force-cancel can set it to
|
||||
# None concurrently. Uses async sleep to avoid blocking the event loop.
|
||||
worker = ws.worker_thread
|
||||
if worker and worker.is_alive() and ws.session and ws.session._cancel_event.is_set():
|
||||
for _ in range(30): # up to 3s in 100ms steps
|
||||
await asyncio.sleep(0.1)
|
||||
if not worker.is_alive():
|
||||
break
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui._enqueue(
|
||||
@@ -1131,16 +1165,21 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
|
||||
def run() -> None:
|
||||
assert ui is not None
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(message)
|
||||
except GenerationCancelled:
|
||||
# Safety net — send() normally handles this internally.
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
# If this thread was force-abandoned, ws.worker_thread will
|
||||
# have been set to None — don't emit spurious events.
|
||||
if ws.worker_thread is me:
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
except Exception as e:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("error")
|
||||
if ws.worker_thread is me:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("error")
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
ws.worker_thread = t
|
||||
@@ -1212,6 +1251,7 @@ async def cancel_generation(request: Request) -> JSONResponse:
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return JSONResponse({"error": "No session"}, status_code=400)
|
||||
force = body.get("force", False) is True
|
||||
# Only act if generation is actually in progress
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Set the cooperative cancel flag (worker thread checks at checkpoints)
|
||||
@@ -1219,8 +1259,19 @@ async def cancel_generation(request: Request) -> JSONResponse:
|
||||
# Unblock any pending approval/plan review waits
|
||||
ui.resolve_approval(False, "Cancelled by user")
|
||||
ui.resolve_plan("reject")
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
if force:
|
||||
# Force cancel: abandon the stuck worker thread (daemon, will
|
||||
# die on process exit or stream timeout) and emit stream_end
|
||||
# so the UI and session recover immediately. The per-generation
|
||||
# cancel event stays set so the abandoned thread still kills
|
||||
# subprocesses at its next checkpoint.
|
||||
with ws._lock:
|
||||
ws.worker_thread = None
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
else:
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
@@ -1851,8 +1902,19 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
# TLS: start auto-renewal if client was initialized
|
||||
tls_client = getattr(app.state, "tls_client", None)
|
||||
if tls_client is not None:
|
||||
try:
|
||||
await tls_client.start_renewal()
|
||||
except Exception:
|
||||
log.warning("TLS auto-renewal startup failed", exc_info=True)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
tls_client = getattr(app.state, "tls_client", None)
|
||||
if tls_client is not None:
|
||||
await tls_client.stop_renewal()
|
||||
if app.state.watch_runner:
|
||||
app.state.watch_runner.stop()
|
||||
if app.state.health_monitor:
|
||||
@@ -2075,6 +2137,8 @@ def main() -> None:
|
||||
|
||||
configure_logging_from_args(args, "server")
|
||||
|
||||
import socket
|
||||
|
||||
# Initialize storage backend
|
||||
from turnstone.core.storage import init_storage
|
||||
|
||||
@@ -2086,7 +2150,17 @@ def main() -> None:
|
||||
db_pool_size = int(
|
||||
getattr(args, "db_pool_size", None) or os.environ.get("TURNSTONE_DB_POOL_SIZE", "2")
|
||||
)
|
||||
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
|
||||
init_storage(
|
||||
db_backend,
|
||||
path=db_path,
|
||||
url=db_url,
|
||||
pool_size=db_pool_size,
|
||||
sslmode=getattr(args, "db_sslmode", None) or os.environ.get("TURNSTONE_DB_SSLMODE", ""),
|
||||
sslrootcert=getattr(args, "db_sslrootcert", None)
|
||||
or os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
|
||||
sslcert=getattr(args, "db_sslcert", None) or os.environ.get("TURNSTONE_DB_SSLCERT", ""),
|
||||
sslkey=getattr(args, "db_sslkey", None) or os.environ.get("TURNSTONE_DB_SSLKEY", ""),
|
||||
)
|
||||
|
||||
# Server-owned node identity (needed before ConfigStore for node_id scoping)
|
||||
def _default_node_id() -> str:
|
||||
@@ -2428,11 +2502,56 @@ def main() -> None:
|
||||
)
|
||||
log.info("Max workstreams: %s", config_store.get("server.max_workstreams"))
|
||||
log.info("Node ID: %s", _node_id)
|
||||
|
||||
# TLS: request cert from console ACME if enabled
|
||||
ssl_kwargs: dict[str, Any] = {}
|
||||
if config_store.get("tls.enabled"):
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
hostname = socket.getfqdn()
|
||||
hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
# Only add bind host if it's a concrete address
|
||||
if args.host not in ("0.0.0.0", "::", ""):
|
||||
hostnames.append(args.host)
|
||||
# Additional SANs from env (e.g. Docker service name)
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
tls_client = TLSClient(
|
||||
storage=get_storage(),
|
||||
hostnames=hostnames,
|
||||
)
|
||||
asyncio.run(tls_client.init())
|
||||
bundle = tls_client.bundle
|
||||
if bundle:
|
||||
from lacme.mtls import write_pem_files_persistent
|
||||
|
||||
pem_paths = write_pem_files_persistent(
|
||||
bundle,
|
||||
ca_pem=tls_client.ca_pem,
|
||||
)
|
||||
ssl_kwargs.update(pem_paths.as_uvicorn_kwargs())
|
||||
if tls_client.ca_pem:
|
||||
import ssl as _ssl
|
||||
|
||||
ssl_kwargs["ssl_cert_reqs"] = _ssl.CERT_REQUIRED
|
||||
|
||||
# Store client on app state for lifespan renewal
|
||||
app.state.tls_client = tls_client
|
||||
log.info("TLS enabled — serving HTTPS")
|
||||
else:
|
||||
log.warning("TLS enabled but no cert available")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed — serving plain HTTP", exc_info=True)
|
||||
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", **ssl_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user