mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3934a2d14 | |||
| 3d02cf66b4 | |||
| 7631b88792 | |||
| a07172b0c0 | |||
| f3d33bf44a | |||
| fdb1a189e8 | |||
| 58e2d9348f | |||
| 771d03b8e6 | |||
| d147aaea36 | |||
| 8b747178e0 | |||
| d57280d807 | |||
| b9870f279c | |||
| 71ee340bc6 | |||
| c5d5d0b7cd | |||
| 1f9d03c3e0 | |||
| 24e082df05 | |||
| 4a78d20eea | |||
| e0d17e0f99 | |||
| cd6c49dd01 | |||
| 8454e961ba | |||
| 4ae38bc2ae | |||
| ab1a71c86c | |||
| ce57df6888 | |||
| 275f40eebb | |||
| 2c510f8617 | |||
| 2afb9c7f72 | |||
| 5b8ab94446 | |||
| 30828e9f9c | |||
| 6d0dc6df94 | |||
| 7e680ee883 | |||
| e950219246 | |||
| b3764a8035 | |||
| 756c4d8929 | |||
| 04c50568e9 | |||
| 3bf220c503 | |||
| 4b853e329e | |||
| 29ffdc36d0 | |||
| ada8b80509 | |||
| 1f47ca62de | |||
| 83d9233304 | |||
| bf06102d37 | |||
| e015b4512d | |||
| 0c1afff7fc | |||
| a94051a995 | |||
| 2f906ea1f9 | |||
| b61bfd1aa6 | |||
| 19c3a48b10 | |||
| 414eb52d67 | |||
| 86b404177b | |||
| 10165bb8a1 | |||
| 5d478573cc | |||
| 1b24e4717f | |||
| 9a2db63c07 | |||
| e159837b74 | |||
| ec3454ee2e | |||
| 5cbb832162 | |||
| 4e4ae2a91d | |||
| c7d0bac638 | |||
| e86305c143 | |||
| d0fc42195a | |||
| 760321f7ee | |||
| 693e51f782 | |||
| ba07409724 | |||
| c76a61841e | |||
| 341d2f604f | |||
| 3f7f8495d6 | |||
| dc464ac313 |
@@ -47,11 +47,37 @@ jobs:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
test-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: turnstone_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U postgres"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -60,7 +86,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
|
||||
+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.10.10 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.12 /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 mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
|
||||
# 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 mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
[](https://pypi.org/project/turnstone/)
|
||||
[](LICENSE)
|
||||
|
||||
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces.
|
||||
Experimental multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers, driven by message queues or interactive interfaces.
|
||||
|
||||
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) — a bird that flips rocks to expose what's hiding underneath.
|
||||
> **Beta — Use at your own risk.** Turnstone is under active development and has not reached a stable release. APIs, configuration formats, and database schemas may change between versions without migration paths. We make no guarantees of determinism, reliability, or backward compatibility. Evaluate thoroughly before deploying to any environment where these properties matter.
|
||||
|
||||
Named after the [Ruddy Turnstone](https://en.wikipedia.org/wiki/Ruddy_turnstone) (*Arenaria interpres*) — a shorebird that flips stones to discover what's hiding underneath.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -145,7 +147,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `load_skill` tool for model-driven skill activation
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `skill` tool for model-driven skill activation
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
|
||||
|
||||
@@ -308,7 +310,7 @@ search_max_results = 5 # max tools returned per search query
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
max_workstreams = 10 # auto-evicts oldest idle when full
|
||||
max_workstreams = 50 # auto-evicts oldest idle when full
|
||||
|
||||
[redis]
|
||||
host = "localhost"
|
||||
@@ -340,7 +342,7 @@ burst = 20
|
||||
backend = "sqlite" # "sqlite" (default) or "postgresql"
|
||||
path = ".turnstone.db" # SQLite file path (relative to working directory)
|
||||
# url = "postgresql+psycopg://user:pass@host:5432/turnstone" # PostgreSQL
|
||||
# pool_size = 5 # PostgreSQL connection pool size
|
||||
# pool_size = 2 # PostgreSQL connection pool size (per process)
|
||||
|
||||
[judge]
|
||||
enabled = true # intent validation for tool approvals (--no-judge to disable)
|
||||
@@ -394,7 +396,7 @@ Idle workstreams are automatically cleaned up after 2 hours (configurable). In m
|
||||
- `turnstone_judge_llm_latency_seconds` — LLM judge evaluation latency histogram
|
||||
- `turnstone_judge_enabled` — whether the intent validation judge is active (0/1)
|
||||
|
||||
Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
|
||||
Per-workstream metrics are labeled by `ws_id` (bounded by `[server].max_workstreams`).
|
||||
|
||||
### Health & Rate Limiting
|
||||
|
||||
@@ -404,7 +406,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded to 10 max workstreams).
|
||||
|
||||
**Per-IP rate limiting.** When `[ratelimit].enabled` is true, each client IP is tracked with a token-bucket limiter (`requests_per_second` / `burst`). Rate limiting is applied in `do_GET`/`do_POST` after authentication but before route dispatch. `/health` and `/metrics` are exempt. Requests that exceed the limit receive HTTP 429 with a `Retry-After` header.
|
||||
|
||||
**Workstream eviction.** When `WorkstreamManager.create()` would exceed `max_workstreams`, the oldest IDLE workstream is automatically evicted and the `turnstone_workstreams_evicted_total` counter is incremented. Configure via `[server].max_workstreams` (default 10).
|
||||
**Workstream eviction.** When `WorkstreamManager.create()` would exceed `max_workstreams`, the oldest IDLE workstream is automatically evicted and the `turnstone_workstreams_evicted_total` counter is incremented. Configure via `[server].max_workstreams` (default 50).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
+2354
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
# OpenShell inference routing for Turnstone.
|
||||
#
|
||||
# When using inference routing, the sandbox process connects to
|
||||
# https://inference.local instead of the real LLM API. The OpenShell
|
||||
# proxy intercepts, rewrites credentials, and forwards to the backend.
|
||||
#
|
||||
# This keeps real API keys out of the sandbox entirely — the process
|
||||
# only sees opaque placeholder tokens in its environment.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# ...
|
||||
#
|
||||
# Then start turnstone with:
|
||||
# python3 -m turnstone.server --base-url https://inference.local
|
||||
#
|
||||
# CUSTOMIZE: uncomment one of the provider blocks below.
|
||||
|
||||
routes:
|
||||
|
||||
# --- OpenAI ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.openai.com/v1
|
||||
# model: gpt-5
|
||||
# provider_type: openai
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
# api_key_env: OPENAI_API_KEY
|
||||
|
||||
# --- Anthropic ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.anthropic.com
|
||||
# model: claude-sonnet-4-6
|
||||
# provider_type: anthropic
|
||||
# protocols:
|
||||
# - anthropic_messages
|
||||
# api_key_env: ANTHROPIC_API_KEY
|
||||
|
||||
# --- Local model server (vLLM / llama.cpp) ---
|
||||
# No secret resolution needed — local servers typically have no auth.
|
||||
# Omit both api_key and api_key_env to skip credential injection.
|
||||
# - name: inference.local
|
||||
# endpoint: http://localhost:8000/v1
|
||||
# model: meta-llama/Llama-3.1-70B-Instruct
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
@@ -0,0 +1,333 @@
|
||||
# OpenShell sandbox policy for Turnstone AI orchestration platform.
|
||||
#
|
||||
# This policy wraps a turnstone-server process (the primary sandbox target).
|
||||
# The bridge, console, and channel gateway are separate processes that would
|
||||
# each need their own sandbox with a tailored policy variant.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
#
|
||||
# For inference routing (keeps real API keys out of the sandbox):
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url https://inference.local
|
||||
#
|
||||
# Note: inference.local is intercepted by the OpenShell proxy before
|
||||
# network policy evaluation — no network_policies entry is needed for it.
|
||||
#
|
||||
# Customization points (search for "CUSTOMIZE"):
|
||||
# - OIDC issuer endpoint
|
||||
# - Redis host/port (if not localhost)
|
||||
# - MCP HTTP server endpoints
|
||||
# - Additional tool binaries
|
||||
# - web_fetch domain allowlist
|
||||
|
||||
version: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem: Landlock kernel enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static — cannot be changed after sandbox creation.
|
||||
# include_workdir adds the --workdir path to read_write automatically.
|
||||
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
|
||||
read_only:
|
||||
# Python runtime + installed packages (includes turnstone package)
|
||||
- /usr
|
||||
- /lib
|
||||
- /lib64
|
||||
# System essentials
|
||||
- /etc
|
||||
- /proc
|
||||
- /dev/urandom
|
||||
# Turnstone config (read-only — writes go to database)
|
||||
# CUSTOMIZE: adjust if config lives elsewhere
|
||||
- /home/sandbox/.config/turnstone
|
||||
|
||||
read_write:
|
||||
# Working directory is added via include_workdir
|
||||
# Temp files (bash tool scripts, eval workdirs)
|
||||
- /tmp
|
||||
# Shell redirections (2>/dev/null)
|
||||
- /dev/null
|
||||
# SQLite database (default location is workdir, covered by include_workdir)
|
||||
# Logs
|
||||
- /var/log
|
||||
|
||||
landlock:
|
||||
# best_effort: degrade gracefully on kernels without Landlock (< 5.13)
|
||||
# Change to hard_requirement for production hardened deployments
|
||||
compatibility: best_effort
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process: privilege separation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
process:
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: per-endpoint, per-binary allowlisting
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default-deny. Only listed host:port pairs are reachable.
|
||||
# Child processes (MCP servers, bash subcommands) inherit the network
|
||||
# namespace — they cannot bypass the proxy.
|
||||
|
||||
network_policies:
|
||||
|
||||
# --- LLM API providers ---
|
||||
|
||||
openai_api:
|
||||
name: openai-api
|
||||
endpoints:
|
||||
- host: api.openai.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
anthropic_api:
|
||||
name: anthropic-api
|
||||
endpoints:
|
||||
- host: api.anthropic.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Web search fallback (Tavily) ---
|
||||
|
||||
tavily_api:
|
||||
name: tavily-search
|
||||
endpoints:
|
||||
- host: api.tavily.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Skill discovery ---
|
||||
|
||||
skills_registry:
|
||||
name: skills-registry
|
||||
endpoints:
|
||||
- host: skills.sh
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
github_api:
|
||||
name: github-api
|
||||
endpoints:
|
||||
- host: api.github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
- host: raw.githubusercontent.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
mcp_registry:
|
||||
name: mcp-registry
|
||||
endpoints:
|
||||
- host: registry.modelcontextprotocol.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- OIDC SSO ---
|
||||
# CUSTOMIZE: replace with your identity provider's hostname
|
||||
|
||||
# oidc_provider:
|
||||
# name: oidc-provider
|
||||
# endpoints:
|
||||
# - host: login.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Redis (MQ) ---
|
||||
# CUSTOMIZE: if Redis is not on localhost, add host + allowed_ips.
|
||||
# localhost is blocked by default SSRF protection, so we need allowed_ips.
|
||||
|
||||
redis:
|
||||
name: redis-mq
|
||||
endpoints:
|
||||
- port: 6379
|
||||
allowed_ips:
|
||||
- "127.0.0.1"
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Discord (channel integration) ---
|
||||
# Uncomment if using turnstone-channel with Discord adapter.
|
||||
|
||||
# discord:
|
||||
# name: discord
|
||||
# endpoints:
|
||||
# - host: discord.com
|
||||
# port: 443
|
||||
# - host: gateway.discord.gg
|
||||
# port: 443
|
||||
# - host: cdn.discordapp.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- web_fetch tool: curated domain allowlist ---
|
||||
#
|
||||
# This is the hard tradeoff. Turnstone's web_fetch tool lets the LLM
|
||||
# fetch arbitrary public URLs. OpenShell cannot allow "all HTTPS" —
|
||||
# every domain must be enumerated.
|
||||
#
|
||||
# Strategy: allowlist the domains your workloads actually need.
|
||||
# The web_fetch tool will return a connection error for unlisted domains,
|
||||
# which the LLM handles gracefully (it tells the user it can't reach
|
||||
# that site).
|
||||
#
|
||||
# CUSTOMIZE: add domains your workstreams need to fetch from.
|
||||
|
||||
web_fetch_common:
|
||||
name: web-fetch-common
|
||||
endpoints:
|
||||
# Documentation sites
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
# Package registries (metadata lookups)
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
# Stack Overflow / reference
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
# Wikipedia
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- MCP HTTP servers ---
|
||||
# CUSTOMIZE: add endpoints for any MCP servers using streamable-http
|
||||
# transport. stdio-transport MCP servers need no network entry (they
|
||||
# communicate via stdin/stdout pipes within the sandbox).
|
||||
|
||||
# mcp_http_servers:
|
||||
# name: mcp-http
|
||||
# endpoints:
|
||||
# - host: mcp.internal.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Bash tool: curl/wget ---
|
||||
# The bash tool can run curl/wget. These inherit the network namespace
|
||||
# so they can only reach allowed endpoints. But they need binary entries
|
||||
# to pass the proxy's identity check.
|
||||
|
||||
bash_network_tools:
|
||||
name: bash-network-tools
|
||||
endpoints:
|
||||
# Mirrors web_fetch_common — curl/wget should have the same reach.
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/curl
|
||||
- path: /usr/bin/wget
|
||||
|
||||
# --- Package installation ---
|
||||
# pip install / uv add from the bash tool.
|
||||
|
||||
package_registries:
|
||||
name: package-install
|
||||
endpoints:
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: files.pythonhosted.org
|
||||
port: 443
|
||||
- host: "**.pypi.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/pip*
|
||||
- path: /usr/local/bin/pip*
|
||||
- path: /usr/bin/uv
|
||||
- path: /usr/local/bin/uv
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Git operations ---
|
||||
# read-only: clone, fetch, pull. No push (L7 enforcement).
|
||||
|
||||
git_operations:
|
||||
name: git-read-only
|
||||
endpoints:
|
||||
- host: github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
- host: gitlab.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
binaries:
|
||||
- path: /usr/bin/git
|
||||
@@ -554,7 +554,7 @@ Possible `state` values:
|
||||
| `error` | An error occurred |
|
||||
|
||||
**Fan-out pattern:** Each connected client receives its own bounded queue
|
||||
(`maxsize=500`). A dedicated fan-out thread reads from the shared global queue
|
||||
(`maxsize=1000`). A dedicated fan-out thread reads from the shared global queue
|
||||
and copies each event to every client queue. If a client queue is full, the
|
||||
event is silently dropped for that client.
|
||||
|
||||
|
||||
+25
-9
@@ -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.38/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.40/ 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)
|
||||
@@ -100,7 +100,7 @@ turnstone/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
|
||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||
tools/
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
@@ -353,7 +353,7 @@ remove the tab immediately. Controlled by `--workstream-idle-timeout` (default:
|
||||
|
||||
**Workstream eviction at capacity:** When `WorkstreamManager.create()` would
|
||||
exceed `max_workstreams` (configurable via `[server].max_workstreams`, default
|
||||
10), the oldest IDLE workstream is automatically evicted to make room. The
|
||||
50), the oldest IDLE workstream is automatically evicted to make room. The
|
||||
`turnstone_workstreams_evicted_total` counter is incremented on each eviction.
|
||||
If no IDLE workstream is available the create request fails as before.
|
||||
|
||||
@@ -375,12 +375,19 @@ non-idle background workstreams above the input prompt.
|
||||
### Web Workstreams
|
||||
|
||||
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
|
||||
(CSS `@keyframes pulse` animation per state).
|
||||
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
|
||||
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
|
||||
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
|
||||
focused pane's workstream (or focuses an existing pane showing that ws).
|
||||
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
|
||||
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
|
||||
SSE connection, message area, input, and state (busy, approval, streaming).
|
||||
Split via right-click context menu, pane header buttons, or keyboard
|
||||
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
|
||||
Layout persisted to `localStorage`.
|
||||
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
|
||||
`/v1/api/events?ws_id=<id>` for each pane's event stream independently.
|
||||
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
|
||||
receives `ws_state` broadcasts from all workstreams, used to update tab
|
||||
indicators without switching.
|
||||
indicators and pane headers without switching.
|
||||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
|
||||
|
||||
### Thread Safety
|
||||
@@ -828,10 +835,16 @@ and are the single source of truth for both backends and Alembic migrations.
|
||||
backend = "sqlite" # "sqlite" | "postgresql"
|
||||
path = ".turnstone.db" # SQLite file path
|
||||
url = "" # PostgreSQL connection URL
|
||||
pool_size = 5 # PostgreSQL connection pool size
|
||||
pool_size = 2 # PostgreSQL connection pool size (per process)
|
||||
```
|
||||
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`.
|
||||
Environment variables: `TURNSTONE_DB_BACKEND`, `TURNSTONE_DB_URL`, `TURNSTONE_DB_PATH`,
|
||||
`TURNSTONE_DB_POOL_SIZE`.
|
||||
|
||||
The default pool is intentionally small (2 base + 3 overflow = 5 per process)
|
||||
because all database operations are short-burst queries that hold connections for
|
||||
milliseconds. For clusters with many nodes sharing a PostgreSQL instance, use
|
||||
[PgBouncer](pgbouncer.md) in transaction pooling mode.
|
||||
|
||||
### Persistence and Resume
|
||||
|
||||
@@ -953,6 +966,9 @@ warns if the summary was truncated.
|
||||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||||
`popstate` listener restores the correct tab or shows the dashboard,
|
||||
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
|
||||
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
|
||||
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
|
||||
`Ctrl+Alt+Arrow` cycles focus between panes.
|
||||
|
||||
### Eval Resilience
|
||||
|
||||
|
||||
+4
-3
@@ -69,10 +69,11 @@ All reads and writes to the node/workstream map are protected by a single `threa
|
||||
|
||||
### Scale Considerations
|
||||
|
||||
- **10,000 workstreams** at ~500 bytes each = ~5 MB in memory
|
||||
- **1,000 nodes** polled in parallel with 50 threads at ~100ms each = ~2 second poll cycle
|
||||
- **50,000 workstreams** (1,000 nodes × 50 per node) at ~500 bytes each = ~25 MB in memory
|
||||
- **1,000 nodes** polled in parallel — fan-out concurrency is configurable via `cluster.node_fan_out_limit` (default 200), yielding 5 batches at ~100ms each = ~0.5 second poll cycle
|
||||
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
|
||||
- **SSE fan-out** uses the same per-client queue pattern as the per-node server — backed-up clients get events dropped, not blocking
|
||||
- **SSE fan-out** uses per-client queues (2,000 events) — backed-up clients get events dropped, not blocking
|
||||
- **Database** — for clusters sharing PostgreSQL, use [PgBouncer](pgbouncer.md) in transaction pooling mode
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -250,13 +250,11 @@ class "MCPClientManager" as MCPMgr {
|
||||
|
||||
' ToolSearchManager
|
||||
class "ToolSearchManager" as ToolSearchMgr {
|
||||
- _all_tools: list[dict]
|
||||
- _always_on: list[dict]
|
||||
- _deferred: list[dict]
|
||||
- _expanded: dict[str, None]
|
||||
- _index: BM25Index
|
||||
--
|
||||
+ should_activate() → bool
|
||||
+ get_visible_tools() → list[dict]
|
||||
+ get_deferred_tools() → list[dict]
|
||||
+ get_expanded_names() → list[str]
|
||||
|
||||
@@ -105,7 +105,7 @@ Server -> CC : get_snapshot()
|
||||
CC --> Server : ClusterSnapshot\n(full current state)
|
||||
|
||||
Server -> CC : register_listener(queue)
|
||||
note right : Per-client queue.Queue(maxsize=500)\nSSE via EventSourceResponse + run_in_executor()
|
||||
note right : Per-client queue.Queue(maxsize=2000)\nSSE via EventSourceResponse + run_in_executor()
|
||||
|
||||
Server -> Browser : data: {"type":"snapshot",...}\n(full state as first SSE event)
|
||||
|
||||
|
||||
@@ -56,6 +56,26 @@ node "Docker Host" as host {
|
||||
end note
|
||||
}
|
||||
|
||||
node "postgres (profile: production)" <<pgautoupgrade>> as pg_node {
|
||||
component [PostgreSQL\nport 5432] as postgres
|
||||
note bottom of postgres
|
||||
Healthcheck: pg_isready
|
||||
Volume: postgres-data
|
||||
Required for cluster
|
||||
and production profiles
|
||||
end note
|
||||
}
|
||||
|
||||
node "pgbouncer (optional)" <<bitnami/pgbouncer>> as pgb_node {
|
||||
component [PgBouncer\nport 6432] as pgbouncer
|
||||
note bottom of pgbouncer
|
||||
pool_mode: transaction
|
||||
Recommended for clusters
|
||||
> 50 nodes
|
||||
See docs/pgbouncer.md
|
||||
end note
|
||||
}
|
||||
|
||||
node "sim (profile: sim)" <<turnstone image>> as sim_node {
|
||||
component [turnstone-sim] as sim
|
||||
note bottom of sim
|
||||
@@ -92,6 +112,11 @@ console --> server : HTTP polling + proxy\n(GET /v1/api/dashboard,\nproxy /node/
|
||||
|
||||
sim --> redis : Redis protocol\n(queues + pubsub + keys)
|
||||
|
||||
' Database connections (production/cluster profiles)
|
||||
server ..> pgbouncer : PostgreSQL\n(pool_size=2)
|
||||
console ..> pgbouncer : PostgreSQL\n(auth/admin)
|
||||
pgbouncer --> postgres : transaction\npooling
|
||||
|
||||
' Environment variables
|
||||
note right of host
|
||||
**Environment Variables:**
|
||||
@@ -99,13 +124,17 @@ note right of host
|
||||
• OPENAI_API_KEY — API key
|
||||
• REDIS_PASSWORD — Redis auth
|
||||
• TURNSTONE_AUTH_TOKEN — API auth
|
||||
• TURNSTONE_DB_URL — PostgreSQL URL
|
||||
• POSTGRES_PASSWORD — DB password
|
||||
end note
|
||||
|
||||
' Volumes
|
||||
database "redis-data" as rv
|
||||
database "turnstone-data" as tv
|
||||
database "postgres-data" as pv
|
||||
|
||||
redis_node --> rv
|
||||
server_node --> tv
|
||||
pg_node --> pv
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -53,10 +53,10 @@ class "SQLiteBackend" as SQLite <<sqlite>> {
|
||||
|
||||
class "PostgreSQLBackend" as PG <<postgres>> {
|
||||
-_engine: sa.Engine
|
||||
+__init__(url: str, pool_size: int)
|
||||
+__init__(url: str, pool_size: int = 2,\n max_overflow: int = 3)
|
||||
--
|
||||
tsvector + ILIKE search
|
||||
Connection pooling
|
||||
Connection pooling (5 max per process)
|
||||
}
|
||||
|
||||
' -- Schema --
|
||||
@@ -151,7 +151,7 @@ note right of Registry
|
||||
backend = "sqlite" | "postgresql"
|
||||
url = "postgresql+psycopg://..."
|
||||
path = ".turnstone.db"
|
||||
pool_size = 5
|
||||
pool_size = 2 (+ 3 overflow)
|
||||
end note
|
||||
|
||||
note bottom of SQLite
|
||||
@@ -162,8 +162,9 @@ end note
|
||||
|
||||
note bottom of PG
|
||||
Production backend.
|
||||
Multi-node / Docker
|
||||
default.
|
||||
Multi-node / Docker default.
|
||||
Use PgBouncer (transaction mode)
|
||||
for clusters > 50 nodes.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -33,7 +33,7 @@ package "Core Modules" as core #181825 {
|
||||
}
|
||||
|
||||
package "Session Runtime" as runtime #181825 {
|
||||
rectangle "load_skill tool\nsession.py" as loadtool
|
||||
rectangle "skill tool\nsession.py" as loadtool
|
||||
rectangle "set_skill()\nsession.py" as setskill
|
||||
rectangle "_load_skills()\nsession.py" as loadskills
|
||||
}
|
||||
@@ -80,6 +80,7 @@ importui --> install : POST (github source)
|
||||
' Annotations
|
||||
note right of parser
|
||||
YAML frontmatter -> ParsedSkill
|
||||
allowed-tools (standard) -> allowed_tools (internal)
|
||||
Anthropic + Hermes tag formats
|
||||
Name validation (lowercase+hyphens)
|
||||
end note
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a74b4b8b5dbfb1a51a01100b731477968942b01218bad9451a3d5a9cb3003294
|
||||
size 411665
|
||||
oid sha256:e3f1ad0fcd55eaca3b8ad9c5abc07432803641c54ede9fc93c79df144cf77d1c
|
||||
size 407761
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:84524f4bc900708ac8adf081591d336f862830188eb8505e71a0f071b339d923
|
||||
size 252599
|
||||
oid sha256:09065fef028d05e6df425fd8abefaf5a2ca04b66802f2e3975f289fa597f63ed
|
||||
size 309656
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c94556889abb382cd5b818639fc0a4706beef3d9c7a0b4cbedc763943d657dd0
|
||||
size 244998
|
||||
oid sha256:b047cdc318c505f0f0895a65e14c5cc7552716053055cca57fa0a77db150e618
|
||||
size 255458
|
||||
|
||||
@@ -109,9 +109,12 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
|
||||
|----------|---------|-------------|
|
||||
| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` |
|
||||
| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql://user:pass@db:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` |
|
||||
| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) |
|
||||
|
||||
The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage.
|
||||
|
||||
> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL.
|
||||
|
||||
> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container:
|
||||
>
|
||||
> ```bash
|
||||
@@ -151,6 +154,8 @@ POSTGRES_PASSWORD=secret docker compose --profile cluster up
|
||||
|
||||
The default `server` and `bridge` also run alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`.
|
||||
|
||||
For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Mount | Purpose |
|
||||
|
||||
+20
-2
@@ -70,7 +70,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
|
||||
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
|
||||
to defaults, `/template` to show current. Persisted across resume.
|
||||
- **Model-driven loading**: The `load_skill` built-in tool lets the model
|
||||
- **Model-driven loading**: The `skill` built-in tool lets the model
|
||||
discover and activate skills mid-conversation. `search` action finds skills
|
||||
by query (auto-approved); `load` action activates by name (requires user
|
||||
approval since it changes session behavior). Main session only.
|
||||
@@ -83,11 +83,15 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
precedence on name collision. MCP-synced content updates reset `is_default` to
|
||||
prevent compromised servers from injecting defaults. Admin UI shows origin badge
|
||||
and disables edit/delete for MCP-sourced skills.
|
||||
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
|
||||
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
|
||||
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
|
||||
and editable in the admin UI. See https://agentskills.io/specification.
|
||||
- **Security scanning**: Skills are automatically scanned at creation and update
|
||||
time. The scanner evaluates four risk axes: content risk (command execution,
|
||||
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
|
||||
vulnerability risk (prompt injection, insecure credentials), and declared
|
||||
capability risk (from `allowed_tools`). Results populate the `scan_status`
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
|
||||
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
|
||||
These fields are system-managed and cannot be overwritten via the admin API.
|
||||
- **Discovery**: External skills can be discovered and installed from registries:
|
||||
@@ -100,6 +104,20 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
Discovery view has search bar, result cards, and "Import from GitHub" modal.
|
||||
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
|
||||
on both Python and TypeScript console clients.
|
||||
- **Runtime config on installed skills**: Installed (readonly) skills can have
|
||||
their runtime configuration edited — model, temperature, reasoning effort,
|
||||
token budget, max tokens, agent max turns, auto-approve, allowed tools,
|
||||
and enabled flag. The server restricts updates to these fields only via
|
||||
`_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name,
|
||||
description, tags, license, compatibility, content, activation) remain
|
||||
immutable. The admin UI shows "Save Config" instead of "Save" for these
|
||||
skills. Audit action: `skill.update.config`.
|
||||
- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout
|
||||
(left: Identity / Manifest / Deployment; right: Skill Content editor with
|
||||
monospace font). Runtime Config is a collapsible 3-column grid below.
|
||||
License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.).
|
||||
Installed skills show a cyan origin badge with source URL, spec fields are
|
||||
disabled, and all collapsible sections auto-expand in view mode.
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ four independent risk axes:
|
||||
obfuscation, download-execute chains, executable URLs from untrusted domains
|
||||
3. **Vulnerability risk** — prompt injection patterns, insecure credential
|
||||
handling, third-party content exposure (indirect prompt injection surface)
|
||||
4. **Declared capability risk** — parsed from the skill's `allowed_tools` field.
|
||||
4. **Declared capability risk** — parsed from `allowed-tools` in the skill's SKILL.md.
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# OpenShell Sandbox Integration
|
||||
|
||||
Turnstone can run inside an [OpenShell](https://github.com/NVIDIA/OpenShell)
|
||||
sandbox for kernel-enforced security boundaries around tool execution. OpenShell
|
||||
provides four layers of defense that Turnstone's application-level safety model
|
||||
does not cover:
|
||||
|
||||
| Layer | Mechanism | What it prevents |
|
||||
|-------|-----------|------------------|
|
||||
| Filesystem | Landlock | Writes to `/etc`, `~/.ssh`, system paths |
|
||||
| Network | Network namespace + seccomp + HTTP CONNECT proxy | Connections to unlisted hosts |
|
||||
| Process | `setuid` drop + verification | Privilege escalation to root |
|
||||
| Credentials | Proxy-level secret resolution | API keys in sandbox memory |
|
||||
|
||||
Turnstone's own safety layers (human approval, intent judge, tool policies,
|
||||
output guard) remain active inside the sandbox and handle threats at the semantic
|
||||
level -- what the LLM *means* to do with its legitimate access.
|
||||
|
||||
> See also: [Security and Authentication](security.md),
|
||||
> [Intent Validation](judge.md), [Governance](governance.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run turnstone-server in an OpenShell sandbox
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
With inference routing (API keys never enter the sandbox):
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
--base-url https://inference.local
|
||||
```
|
||||
|
||||
The `inference.local` hostname is intercepted by the OpenShell proxy before
|
||||
network policy evaluation -- no network policy entry is needed for it.
|
||||
|
||||
---
|
||||
|
||||
## Policy Files
|
||||
|
||||
### `deploy/openshell/turnstone-policy.yaml`
|
||||
|
||||
The main sandbox policy. Covers filesystem, process, and network rules.
|
||||
|
||||
### `deploy/openshell/routes.yaml`
|
||||
|
||||
Inference routing configuration. Maps `inference.local` to real LLM API
|
||||
backends. Uncomment and configure the provider(s) you use.
|
||||
|
||||
---
|
||||
|
||||
## Filesystem Policy
|
||||
|
||||
The policy uses Landlock (Linux 5.13+) for kernel-enforced filesystem access
|
||||
control. Paths are locked at sandbox creation and cannot be changed at runtime.
|
||||
|
||||
| Path | Access | Purpose |
|
||||
|------|--------|---------|
|
||||
| `--workdir` | read-write | Project files (auto-added via `include_workdir`) |
|
||||
| `/tmp` | read-write | Bash tool temp scripts, eval workdirs |
|
||||
| `/dev/null` | read-write | Shell redirections (`2>/dev/null`) |
|
||||
| `/var/log` | read-write | Log files |
|
||||
| `/usr`, `/lib`, `/lib64` | read-only | Python runtime, installed packages |
|
||||
| `/etc` | read-only | System config, SSL certificates |
|
||||
| `/proc`, `/dev/urandom` | read-only | Process info, entropy |
|
||||
| `~/.config/turnstone` | read-only | Config file (writes go to database) |
|
||||
|
||||
Landlock runs in `best_effort` mode by default -- degrades gracefully on kernels
|
||||
without Landlock support. Set `compatibility: hard_requirement` for production
|
||||
hardened deployments.
|
||||
|
||||
---
|
||||
|
||||
## Network Policy
|
||||
|
||||
Default-deny. Only explicitly listed host:port pairs are reachable. All child
|
||||
processes (MCP servers, bash commands, grep) inherit the network namespace and
|
||||
cannot bypass the proxy.
|
||||
|
||||
### Included endpoints
|
||||
|
||||
| Policy | Hosts | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `openai_api` | `api.openai.com` | OpenAI LLM API |
|
||||
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
|
||||
| `tavily_api` | `api.tavily.com` | Web search fallback |
|
||||
| `skills_registry` | `skills.sh` | Skill discovery |
|
||||
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
|
||||
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
|
||||
| `redis` | `127.0.0.1:6379` | Message queue |
|
||||
| `web_fetch_common` | readthedocs, python docs, GitHub Pages, PyPI, npm, Stack Overflow, Wikipedia | Curated web_fetch domains |
|
||||
| `bash_network_tools` | Same as `web_fetch_common` | curl/wget from bash tool |
|
||||
| `package_registries` | `pypi.org`, `files.pythonhosted.org` | pip/uv package installs |
|
||||
| `git_operations` | `github.com`, `gitlab.com` (L7: clone/fetch only, no push) | Git read-only operations |
|
||||
|
||||
### L7 enforcement
|
||||
|
||||
Endpoints marked with `protocol: rest` and `tls: terminate` get HTTP-level
|
||||
inspection. The proxy TLS-terminates using an ephemeral per-sandbox CA, parses
|
||||
each request, and evaluates method + path against the rules.
|
||||
|
||||
The `github_api`, `mcp_registry`, and `git_operations` policies use L7
|
||||
enforcement:
|
||||
|
||||
- **GitHub API / MCP Registry**: `access: read-only` -- only GET, HEAD, OPTIONS
|
||||
allowed
|
||||
- **Git operations**: explicit rules allowing only `info/refs` (GET) and
|
||||
`git-upload-pack` (POST) -- clone and fetch work, push is blocked
|
||||
|
||||
### Commented-out sections
|
||||
|
||||
The policy includes commented blocks for optional integrations. Uncomment and
|
||||
configure as needed:
|
||||
|
||||
- **OIDC** -- add your identity provider's hostname
|
||||
- **Discord** -- `discord.com`, `gateway.discord.gg`, `cdn.discordapp.com`
|
||||
- **MCP HTTP servers** -- any MCP servers using streamable-http transport
|
||||
|
||||
---
|
||||
|
||||
## Customizing the Domain Allowlist
|
||||
|
||||
The `web_fetch` tool lets the LLM fetch arbitrary public URLs, but OpenShell
|
||||
cannot allow "all HTTPS" -- bare wildcard hosts are rejected by policy
|
||||
validation. Instead, the policy ships with a curated set of common reference
|
||||
domains.
|
||||
|
||||
To add domains your workloads need:
|
||||
|
||||
```yaml
|
||||
# In turnstone-policy.yaml, under web_fetch_common.endpoints:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
|
||||
# Also add to bash_network_tools.endpoints if curl/wget should reach it:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
```
|
||||
|
||||
Wildcard patterns are supported:
|
||||
|
||||
- `*.example.com` -- matches one subdomain level (e.g. `api.example.com`)
|
||||
- `**.example.com` -- matches any depth (e.g. `deep.sub.example.com`)
|
||||
|
||||
Unlisted domains return connection errors, which the LLM handles gracefully by
|
||||
telling the user it cannot reach that site.
|
||||
|
||||
---
|
||||
|
||||
## Inference Routing
|
||||
|
||||
Inference routing keeps real API keys completely outside the sandbox. The
|
||||
sandbox process only sees opaque placeholder tokens in its environment
|
||||
(`openshell:resolve:env:ANTHROPIC_API_KEY`). The proxy rewrites these to real
|
||||
credentials on the wire before forwarding to the upstream API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Edit `deploy/openshell/routes.yaml` -- uncomment your provider:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
# OpenAI
|
||||
- name: inference.local
|
||||
endpoint: https://api.openai.com/v1
|
||||
model: gpt-5
|
||||
provider_type: openai
|
||||
protocols:
|
||||
- openai_chat_completions
|
||||
- model_discovery
|
||||
api_key_env: OPENAI_API_KEY
|
||||
|
||||
# Or Anthropic
|
||||
- name: inference.local
|
||||
endpoint: https://api.anthropic.com
|
||||
model: claude-sonnet-4-6
|
||||
provider_type: anthropic
|
||||
protocols:
|
||||
- anthropic_messages
|
||||
api_key_env: ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
2. Start with `--inference-routes` and point turnstone at `inference.local`:
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--base-url https://inference.local \
|
||||
...
|
||||
```
|
||||
|
||||
3. When inference routing is active, the `openai_api` and `anthropic_api`
|
||||
network policies can be removed from the sandbox policy -- the proxy handles
|
||||
LLM traffic on a separate code path that bypasses OPA entirely.
|
||||
|
||||
### Local model servers
|
||||
|
||||
For local servers (vLLM, llama.cpp) with no authentication, omit both
|
||||
`api_key` and `api_key_env` from the route config. No credential resolution
|
||||
is needed.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Subprocesses
|
||||
|
||||
MCP servers using stdio transport are spawned as child processes of turnstone.
|
||||
They automatically inherit all sandbox constraints:
|
||||
|
||||
- **Network namespace** -- kernel-level, cannot be bypassed
|
||||
- **Landlock filesystem** -- kernel-level, cannot be relaxed
|
||||
- **Seccomp socket filter** -- kernel-level, inherited on fork
|
||||
|
||||
No per-subprocess policy entries are needed for these constraints. However, if
|
||||
an MCP server makes outbound network requests (through the proxy), its binary
|
||||
must appear in a `binaries[]` entry for the relevant network policy. The proxy
|
||||
identifies the requesting process via `/proc/<pid>/exe` (not `argv[0]`, which
|
||||
is spoofable).
|
||||
|
||||
Example for a Python-based MCP server that calls an external API:
|
||||
|
||||
```yaml
|
||||
mcp_external_api:
|
||||
name: mcp-external
|
||||
endpoints:
|
||||
- host: api.example.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
```
|
||||
|
||||
MCP servers using streamable-http transport are remote -- they need a network
|
||||
policy entry for their host:port but no binary entry (the Python process making
|
||||
the HTTP call is already covered by the standard `python3*` binary entries).
|
||||
|
||||
---
|
||||
|
||||
## Security Model: Which Layer Enforces What
|
||||
|
||||
```
|
||||
OpenShell (infrastructure) Turnstone (application)
|
||||
───────────────────────────── ──────────────────────────────
|
||||
Filesystem access Landlock kernel enforcement (no enforcement)
|
||||
Network egress Netns + seccomp + proxy + OPA SSRF check on web_fetch
|
||||
Credentials Placeholder injection + proxy Output guard redaction
|
||||
Privilege level setuid drop + verification (no enforcement)
|
||||
Tool semantics (no visibility) Heuristic + LLM judge
|
||||
Tool policies (no visibility) fnmatch admin policies
|
||||
Prompt injection (no visibility) Output guard detection
|
||||
Human approval (no visibility) Approval gate + "always"
|
||||
```
|
||||
|
||||
OpenShell constrains what the process can physically reach. Turnstone constrains
|
||||
what the LLM does with its legitimate access. Neither layer is sufficient alone:
|
||||
|
||||
- Without OpenShell: a bash command can `curl` secrets to any endpoint, write to
|
||||
`/etc/crontab`, or read `~/.ssh/id_rsa` -- all gated only by human approval
|
||||
- Without Turnstone: the LLM can `rm -rf` the entire workdir, run destructive
|
||||
commands, or consume prompt injection payloads -- all within the sandbox's
|
||||
allowed scope
|
||||
|
||||
---
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
For production deployments:
|
||||
|
||||
- [ ] Set `landlock.compatibility: hard_requirement`
|
||||
- [ ] Enable inference routing (removes API keys from sandbox)
|
||||
- [ ] Remove `openai_api`/`anthropic_api` network policies when using inference
|
||||
routing (traffic goes through the router, not direct)
|
||||
- [ ] Review and trim `web_fetch_common` domains to your actual needs
|
||||
- [ ] Remove `package_registries` policy if pip/uv installs are not needed
|
||||
- [ ] Add your OIDC provider endpoint if using SSO
|
||||
- [ ] Set Redis `allowed_ips` to your actual Redis host if not localhost
|
||||
- [ ] Consider removing `bash_network_tools` entirely if bash should not have
|
||||
network access
|
||||
@@ -0,0 +1,200 @@
|
||||
# PgBouncer Connection Pooling
|
||||
|
||||
Turnstone cluster deployments share a single PostgreSQL instance across
|
||||
all server nodes, bridge processes, and the console. Each process
|
||||
maintains a small connection pool (2 base + 3 overflow = 5 max). At
|
||||
scale this adds up — a 100-node cluster opens up to 500 connections,
|
||||
and a 1000-node cluster up to 5,000.
|
||||
|
||||
PostgreSQL's default `max_connections` is 100, and each real connection
|
||||
allocates ~5–10 MB of backend memory. PgBouncer sits between turnstone
|
||||
and PostgreSQL, multiplexing thousands of lightweight client connections
|
||||
down to a small number of real database connections.
|
||||
|
||||
---
|
||||
|
||||
## Why PgBouncer works well with turnstone
|
||||
|
||||
All turnstone database operations are short-burst queries: acquire a
|
||||
connection, execute 1–3 statements, commit, release. No operation holds
|
||||
a connection for more than a few milliseconds. This makes **transaction
|
||||
pooling mode** ideal — PgBouncer assigns a real connection only for the
|
||||
duration of each transaction, then returns it to the pool.
|
||||
|
||||
| Cluster size | Client connections (max) | PgBouncer server connections needed |
|
||||
|--------------|------------------------|-------------------------------------|
|
||||
| 10 nodes | 50 | 10–20 |
|
||||
| 100 nodes | 500 | 20–40 |
|
||||
| 500 nodes | 2,500 | 30–60 |
|
||||
| 1,000 nodes | 5,000 | 40–80 |
|
||||
|
||||
The server connection count stays low because most client connections
|
||||
are idle at any given moment.
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Add PgBouncer between turnstone services and PostgreSQL:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
pgbouncer:
|
||||
image: bitnami/pgbouncer:latest
|
||||
environment:
|
||||
POSTGRESQL_HOST: postgres
|
||||
POSTGRESQL_PORT: "5432"
|
||||
POSTGRESQL_DATABASE: turnstone
|
||||
POSTGRESQL_USERNAME: ${POSTGRES_USER:-turnstone}
|
||||
POSTGRESQL_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||
PGBOUNCER_POOL_MODE: transaction
|
||||
PGBOUNCER_DEFAULT_POOL_SIZE: "40"
|
||||
PGBOUNCER_MAX_CLIENT_CONN: "5000"
|
||||
PGBOUNCER_MAX_DB_CONNECTIONS: "80"
|
||||
PGBOUNCER_SERVER_IDLE_TIMEOUT: "300"
|
||||
ports:
|
||||
- "6432:6432"
|
||||
networks:
|
||||
- turnstone-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "6432"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
```
|
||||
|
||||
Then point turnstone services at PgBouncer instead of PostgreSQL
|
||||
directly by changing the `DATABASE_URL` (or `TURNSTONE_DB_URL`):
|
||||
|
||||
```bash
|
||||
# Before (direct)
|
||||
TURNSTONE_DB_URL=postgresql://turnstone:secret@postgres:5432/turnstone
|
||||
|
||||
# After (via PgBouncer)
|
||||
TURNSTONE_DB_URL=postgresql://turnstone:secret@pgbouncer:6432/turnstone
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Helm / Kubernetes
|
||||
|
||||
Add a PgBouncer deployment or use a Helm chart like
|
||||
[bitnami/pgbouncer](https://github.com/bitnami/charts/tree/main/bitnami/pgbouncer).
|
||||
|
||||
In `values.yaml`, point the database at PgBouncer:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: postgresql
|
||||
external:
|
||||
host: pgbouncer
|
||||
port: 6432
|
||||
database: turnstone
|
||||
username: turnstone
|
||||
existingSecret: turnstone-db-secret
|
||||
```
|
||||
|
||||
PgBouncer configuration:
|
||||
|
||||
```yaml
|
||||
pgbouncer:
|
||||
poolMode: transaction
|
||||
defaultPoolSize: 40
|
||||
maxClientConn: 5000
|
||||
maxDbConnections: 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| PgBouncer setting | Recommended | Notes |
|
||||
|-------------------|-------------|-------|
|
||||
| `pool_mode` | `transaction` | Required — turnstone uses short-burst queries with no session state |
|
||||
| `default_pool_size` | 40 | Real PostgreSQL connections per database. Start here, increase if you see `no more connections allowed` |
|
||||
| `max_client_conn` | 5000 | Upper bound on client connections. Set to `cluster_nodes × 5` |
|
||||
| `max_db_connections` | 80 | Hard cap on real connections to PostgreSQL. Keep below PG `max_connections` minus headroom for admin/monitoring |
|
||||
| `server_idle_timeout` | 300 | Close idle server connections after 5 minutes |
|
||||
| `server_lifetime` | 3600 | Recycle server connections after 1 hour |
|
||||
|
||||
On the PostgreSQL side:
|
||||
|
||||
| PostgreSQL setting | Recommended | Notes |
|
||||
|--------------------|-------------|-------|
|
||||
| `max_connections` | 100 | Default is fine — PgBouncer is the only client. Set higher than `max_db_connections` to leave room for admin connections |
|
||||
| `shared_buffers` | 25% of RAM | Standard PostgreSQL tuning |
|
||||
|
||||
---
|
||||
|
||||
## Turnstone pool settings
|
||||
|
||||
Each turnstone process maintains its own SQLAlchemy connection pool to
|
||||
PgBouncer (which then multiplexes to PostgreSQL):
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `TURNSTONE_DB_POOL_SIZE` | 2 | Base pool size per process |
|
||||
| `TURNSTONE_DB_BACKEND` | sqlite | Set to `postgresql` for cluster deployments |
|
||||
| `TURNSTONE_DB_URL` | — | Connection URL (point at PgBouncer, not PostgreSQL directly) |
|
||||
|
||||
The default pool of 2 + 3 overflow = 5 connections per process is
|
||||
intentionally small to support large clusters. You should not need to
|
||||
increase this — turnstone's database operations are all short-burst
|
||||
context-managed queries that hold connections for milliseconds.
|
||||
|
||||
SQLAlchemy `pool_pre_ping` is enabled, so stale connections (e.g. after
|
||||
PgBouncer restarts) are automatically detected and replaced.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
PgBouncer exposes stats via its admin console (connect to
|
||||
PgBouncer port with user `pgbouncer`):
|
||||
|
||||
```sql
|
||||
-- Active and waiting clients
|
||||
SHOW POOLS;
|
||||
|
||||
-- Per-database stats
|
||||
SHOW STATS;
|
||||
|
||||
-- Current client connections
|
||||
SHOW CLIENTS;
|
||||
```
|
||||
|
||||
Key metrics to watch:
|
||||
|
||||
- **`cl_active`** — clients with a server connection assigned. Should be
|
||||
well below `max_db_connections`.
|
||||
- **`cl_waiting`** — clients waiting for a server connection. Sustained
|
||||
non-zero values mean you need more `default_pool_size`.
|
||||
- **`sv_active`** — active server (PostgreSQL) connections. Should stay
|
||||
below PostgreSQL `max_connections`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"no more connections allowed (max_client_conn)"** — PgBouncer is
|
||||
rejecting new client connections. Increase `max_client_conn` to match
|
||||
your cluster size × 5.
|
||||
|
||||
**"no more connections allowed (max_db_connections)"** — PgBouncer
|
||||
cannot open more connections to PostgreSQL. Increase
|
||||
`max_db_connections` and ensure PostgreSQL `max_connections` is higher.
|
||||
|
||||
**Connections timing out on startup** — If all nodes start
|
||||
simultaneously, the burst of initial connections (migrations, health
|
||||
checks) can temporarily exceed the pool. PgBouncer queues excess
|
||||
clients by default — this resolves itself within seconds.
|
||||
|
||||
**Prepared statements not supported** — PgBouncer in `transaction` mode
|
||||
does not support prepared statements. Turnstone's SQLAlchemy layer does
|
||||
not use server-side prepared statements by default, so this is not an
|
||||
issue.
|
||||
|
||||
See also: [Docker deployment](docker.md) · [Security](security.md)
|
||||
+5
-3
@@ -51,7 +51,7 @@ connection, Redis, auth secrets, server bind address). These stay in
|
||||
| Bridge identity | `[bridge]` | config.toml / env |
|
||||
| Console bind | `[console]` | config.toml / env |
|
||||
|
||||
**ConfigStore settings** (~40 settings) are loaded from the database after
|
||||
**ConfigStore settings** (48 settings) are loaded from the database after
|
||||
storage initialization:
|
||||
|
||||
| Section | Settings |
|
||||
@@ -60,10 +60,12 @@ storage initialization:
|
||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||
| `server` | workstream_idle_timeout, max_workstreams |
|
||||
| `cluster` | node_fan_out_limit, mcp_max_servers |
|
||||
| `mcp` | config_path, refresh_interval, registry_url |
|
||||
| `ratelimit` | enabled, requests_per_second, burst |
|
||||
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
|
||||
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools |
|
||||
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets |
|
||||
| `skills` | discovery_url |
|
||||
| `memory` | relevance_k, fetch_limit, max_content, nudge_cooldown, nudges |
|
||||
|
||||
Settings are addressed by dotted key (e.g. `memory.relevance_k`). Each has a
|
||||
|
||||
+5
-5
@@ -492,7 +492,7 @@ data.get("mergedAt") is not None
|
||||
|
||||
---
|
||||
|
||||
### load_skill
|
||||
### skill
|
||||
|
||||
Discover and activate skills at runtime during a conversation. The model can
|
||||
search for available skills and load one by name, replacing the current active
|
||||
@@ -543,7 +543,7 @@ pre-configure skills at workstream creation.
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `load_skill` | Skills | No (load) | No | No | `name` |
|
||||
| `skill` | Skills | No (load) | No | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
@@ -591,9 +591,9 @@ CLI flags override the config file:
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Threshold check**: At session startup, `ToolSearchManager.should_activate()`
|
||||
counts total tools (built-in + MCP). If the count is below the threshold, tool
|
||||
search stays off and all tools are sent to the model directly.
|
||||
1. **Threshold check**: At session startup, if the total tool count (built-in + MCP)
|
||||
is below the threshold, tool search stays off and all tools are sent to the model
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.8.2"
|
||||
version = "0.8.6"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -51,8 +51,9 @@ console = ["redis>=7.2", "croniter>=3.0"]
|
||||
sim = ["redis>=7.2"]
|
||||
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]"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -77,7 +78,7 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.38/**/*",
|
||||
"turnstone/shared_static/katex-0.16.40/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
|
||||
@@ -28,9 +28,19 @@ usage() {
|
||||
LIB="$1"
|
||||
VERSION="$2"
|
||||
|
||||
# Detect current version from pyproject.toml
|
||||
# Detect current version from the filesystem (not pyproject.toml, which
|
||||
# Renovate may have already updated). Falls back to pyproject.toml if
|
||||
# no directory is found.
|
||||
detect_old_version() {
|
||||
local pattern="$1"
|
||||
# Look for existing directory: e.g. turnstone/shared_static/katex-0.16.38
|
||||
local dir
|
||||
dir=$(find "${STATIC_DIR}" -maxdepth 1 -type d -name "${pattern}-*" | head -1)
|
||||
if [[ -n "$dir" ]]; then
|
||||
basename "$dir" | sed "s/${pattern}-//"
|
||||
return
|
||||
fi
|
||||
# Fallback to pyproject.toml
|
||||
grep -oE "${pattern}-[0-9.]+" pyproject.toml | head -1 | sed "s/${pattern}-//"
|
||||
}
|
||||
|
||||
@@ -51,9 +61,19 @@ update_refs() {
|
||||
done
|
||||
}
|
||||
|
||||
check_same_version() {
|
||||
if [[ "$1" == "$2" ]]; then
|
||||
echo "ERROR: Old version ($1) == new version ($2). Nothing to update."
|
||||
echo "If the old directory was already removed, re-download with:"
|
||||
echo " rm -rf ${STATIC_DIR}/${3}-${1} && $0 $3 $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$LIB" in
|
||||
katex)
|
||||
OLD_VERSION=$(detect_old_version "katex")
|
||||
check_same_version "$OLD_VERSION" "$VERSION" "katex"
|
||||
OLD_DIR="${STATIC_DIR}/katex-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/katex-${VERSION}"
|
||||
|
||||
@@ -87,6 +107,7 @@ case "$LIB" in
|
||||
|
||||
hljs)
|
||||
OLD_VERSION=$(detect_old_version "hljs")
|
||||
check_same_version "$OLD_VERSION" "$VERSION" "hljs"
|
||||
OLD_DIR="${STATIC_DIR}/hljs-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/hljs-${VERSION}"
|
||||
|
||||
@@ -107,6 +128,7 @@ case "$LIB" in
|
||||
|
||||
mermaid)
|
||||
OLD_VERSION=$(detect_old_version "mermaid")
|
||||
check_same_version "$OLD_VERSION" "$VERSION" "mermaid"
|
||||
OLD_DIR="${STATIC_DIR}/mermaid-${OLD_VERSION}"
|
||||
NEW_DIR="${STATIC_DIR}/mermaid-${VERSION}"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.4",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -4378,6 +4378,12 @@
|
||||
"description": "Skill name (replaces default skills)",
|
||||
"title": "Skill",
|
||||
"type": "string"
|
||||
},
|
||||
"resume_ws": {
|
||||
"default": "",
|
||||
"description": "Workstream ID to resume (loads previous conversation)",
|
||||
"title": "Resume Ws",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "ConsoleCreateWsRequest",
|
||||
@@ -6878,11 +6884,26 @@
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"priority": {
|
||||
"default": 0,
|
||||
"title": "Priority",
|
||||
"type": "integer"
|
||||
},
|
||||
"allowed_tools": {
|
||||
"default": "[]",
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
},
|
||||
"scan_status": {
|
||||
"default": "",
|
||||
"title": "Scan Status",
|
||||
@@ -7103,10 +7124,25 @@
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"priority": {
|
||||
"default": 0,
|
||||
"title": "Priority",
|
||||
"type": "integer"
|
||||
},
|
||||
"allowed_tools": {
|
||||
"default": "[]",
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7346,6 +7382,18 @@
|
||||
"default": null,
|
||||
"title": "Enabled"
|
||||
},
|
||||
"priority": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Priority"
|
||||
},
|
||||
"allowed_tools": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -7357,6 +7405,30 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Allowed Tools"
|
||||
},
|
||||
"license": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "License"
|
||||
},
|
||||
"compatibility": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Compatibility"
|
||||
}
|
||||
},
|
||||
"title": "UpdateSkillRequest",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.4",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -1559,6 +1559,11 @@
|
||||
"title": "Version",
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"default": "",
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
},
|
||||
"uptime_seconds": {
|
||||
"default": 0.0,
|
||||
"title": "Uptime Seconds",
|
||||
@@ -1569,6 +1574,12 @@
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"max_ws": {
|
||||
"default": 10,
|
||||
"description": "Maximum concurrent workstreams",
|
||||
"title": "Max Ws",
|
||||
"type": "integer"
|
||||
},
|
||||
"workstreams": {
|
||||
"$ref": "#/components/schemas/WorkstreamCounts",
|
||||
"default": {
|
||||
|
||||
Generated
+131
-142
@@ -9,14 +9,14 @@
|
||||
"version": "0.3.0",
|
||||
"license": "BUSL-1.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -26,9 +26,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -71,20 +71,10 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/runtime": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
|
||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
|
||||
"version": "0.120.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -92,9 +82,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -109,9 +99,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -126,9 +116,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -143,9 +133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -160,9 +150,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -177,9 +167,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -194,9 +184,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -211,9 +201,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -228,9 +218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -245,9 +235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -262,9 +252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -279,9 +269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -296,9 +286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -313,9 +303,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -330,9 +320,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -347,9 +337,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -397,16 +387,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
||||
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -415,13 +405,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
|
||||
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -430,7 +420,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -442,9 +432,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
|
||||
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -455,13 +445,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
|
||||
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -469,14 +459,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
|
||||
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -485,9 +475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
|
||||
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -495,13 +485,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
|
||||
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
@@ -964,14 +954,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.115.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.9"
|
||||
"@oxc-project/types": "=0.120.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -980,21 +970,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1081,9 +1071,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1095,17 +1085,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.115.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.9",
|
||||
"rolldown": "1.0.0-rc.10",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1122,7 +1111,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.0.0-alpha.31",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -1174,19 +1163,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
|
||||
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.0",
|
||||
"@vitest/mocker": "4.1.0",
|
||||
"@vitest/pretty-format": "4.1.0",
|
||||
"@vitest/runner": "4.1.0",
|
||||
"@vitest/snapshot": "4.1.0",
|
||||
"@vitest/spy": "4.1.0",
|
||||
"@vitest/utils": "4.1.0",
|
||||
"@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",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1198,7 +1187,7 @@
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1214,13 +1203,13 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.0",
|
||||
"@vitest/browser-preview": "4.1.0",
|
||||
"@vitest/browser-webdriverio": "4.1.0",
|
||||
"@vitest/ui": "4.1.0",
|
||||
"@vitest/browser-playwright": "4.1.1",
|
||||
"@vitest/browser-preview": "4.1.1",
|
||||
"@vitest/browser-webdriverio": "4.1.1",
|
||||
"@vitest/ui": "4.1.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
],
|
||||
"license": "BUSL-1.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,10 @@ export interface SkillInfo {
|
||||
agent_max_turns: number | null;
|
||||
notify_on_complete: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
allowed_tools: string;
|
||||
license: string;
|
||||
compatibility: string;
|
||||
resource_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
@@ -213,7 +216,10 @@ export interface CreateSkillRequest {
|
||||
agent_max_turns?: number | null;
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
@@ -236,7 +242,10 @@ export interface UpdateSkillRequest {
|
||||
agent_max_turns?: number | null;
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
@@ -396,6 +405,7 @@ export interface ConsoleCreateWsRequest {
|
||||
model?: string;
|
||||
initial_message?: string;
|
||||
skill?: string;
|
||||
resume_ws?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsResponse {
|
||||
|
||||
+75
-1
@@ -1,11 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption(
|
||||
"--storage-backend",
|
||||
default="sqlite",
|
||||
choices=["sqlite", "postgresql"],
|
||||
help="Storage backend for integration tests (default: sqlite)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path):
|
||||
"""Provide a temporary SQLite storage backend."""
|
||||
"""Provide a temporary SQLite storage backend (singleton registry)."""
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
|
||||
db_path = str(tmp_path / "test.db")
|
||||
@@ -15,6 +27,68 @@ def tmp_db(tmp_path):
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage_backend(request, tmp_path):
|
||||
"""Shared storage backend fixture — respects --storage-backend flag.
|
||||
|
||||
Returns a StorageBackend instance (SQLite or PostgreSQL).
|
||||
Tests that use this fixture run against whichever backend CI selects.
|
||||
"""
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
|
||||
backend_type = request.config.getoption("--storage-backend")
|
||||
reset_storage()
|
||||
|
||||
if backend_type == "postgresql":
|
||||
pg_url = os.environ.get(
|
||||
"TURNSTONE_TEST_PG_URL",
|
||||
"postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test",
|
||||
)
|
||||
backend = init_storage("postgresql", url=pg_url, run_migrations=False)
|
||||
yield backend
|
||||
# Truncate all tables between tests — faster than DELETE and resets
|
||||
# autoincrement sequences. CASCADE handles any future FK constraints.
|
||||
# NOTE: accesses backend._engine (SQLAlchemy internal) — both SQLite
|
||||
# and PostgreSQL backends expose this. If a non-SQLAlchemy backend is
|
||||
# ever added, this cleanup will need a protocol-level hook.
|
||||
try:
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import metadata as db_metadata
|
||||
|
||||
with backend._engine.connect() as conn:
|
||||
table_names = ", ".join(t.name for t in reversed(db_metadata.sorted_tables))
|
||||
conn.execute(sa.text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # best-effort cleanup; reset_storage disposes engine
|
||||
finally:
|
||||
reset_storage()
|
||||
else:
|
||||
db_path = str(tmp_path / "test.db")
|
||||
backend = init_storage("sqlite", path=db_path, run_migrations=False)
|
||||
yield backend
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(storage_backend):
|
||||
"""Alias for storage_backend — used by test_storage_sqlite.py etc."""
|
||||
return storage_backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(storage_backend):
|
||||
"""Alias for storage_backend — used by domain-specific storage tests."""
|
||||
return storage_backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(storage_backend):
|
||||
"""Alias for storage_backend — used by services/skill resource tests."""
|
||||
return storage_backend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client():
|
||||
"""Return a minimal mock OpenAI client."""
|
||||
|
||||
@@ -19,6 +19,7 @@ class TestServerVersioning:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = []
|
||||
mock_mgr.max_workstreams = 10
|
||||
app = create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
|
||||
+10
-2
@@ -783,6 +783,7 @@ class TestServerAuth:
|
||||
mock_ws.session = mock_session
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -1001,6 +1002,7 @@ class TestServerLogin:
|
||||
mock_ws.session = mock_session
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -1369,8 +1371,11 @@ class TestCorsConfigurable:
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.list_all.return_value = []
|
||||
mgr.max_workstreams = 10
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
workstreams=mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
@@ -1388,8 +1393,11 @@ class TestCorsConfigurable:
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.list_all.return_value = []
|
||||
mgr.max_workstreams = 10
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
workstreams=mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestParseScopes:
|
||||
|
||||
|
||||
class TestJWT:
|
||||
SECRET = "test-secret-key-for-jwt"
|
||||
SECRET = "test-secret-key-for-jwt-min-32b!"
|
||||
|
||||
def test_round_trip(self):
|
||||
scopes = frozenset({"read", "write"})
|
||||
@@ -155,7 +155,7 @@ class TestJWT:
|
||||
|
||||
def test_invalid_signature(self):
|
||||
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
|
||||
assert validate_jwt(token, "wrong-secret") is None
|
||||
assert validate_jwt(token, "wrong-secret-key-for-jwt-min-32b") is None
|
||||
|
||||
def test_malformed_token(self):
|
||||
assert validate_jwt("not.a.jwt", self.SECRET) is None
|
||||
@@ -217,7 +217,7 @@ class TestAuthenticateToken:
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
@@ -304,7 +304,7 @@ class TestCheckRequestScopes:
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, result = check_request(
|
||||
@@ -319,7 +319,7 @@ class TestCheckRequestScopes:
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, _ = check_request(
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Stress tests for bridge.py threading — race conditions in approval,
|
||||
plan review, and workstream lifecycle.
|
||||
|
||||
Each scenario is run many times (ITERATIONS) with threading.Barrier to
|
||||
maximize timing overlap. Uses mock broker (no Redis) and no HTTP calls.
|
||||
|
||||
Races tested:
|
||||
1. Duplicate approval on SSE reconnect (TOCTOU in _pending_approvals)
|
||||
2. Duplicate plan review on SSE reconnect (TOCTOU in _pending_plan_reviews)
|
||||
3. approve_set stale reference escape during concurrent update
|
||||
4. _running flag visibility across threads on shutdown
|
||||
5. Approval thread exits within bounded time after timeout
|
||||
6. Concurrent approval + workstream close leaves no orphaned state
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.mq.bridge import Bridge
|
||||
|
||||
ITERATIONS = 100
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_bridge(**overrides) -> Bridge:
|
||||
"""Create a Bridge with a mock broker (no Redis or HTTP)."""
|
||||
broker = MagicMock()
|
||||
defaults = dict(
|
||||
server_url="http://localhost:8080",
|
||||
broker=broker,
|
||||
node_id="test-node",
|
||||
approval_timeout=1,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Bridge(**defaults)
|
||||
|
||||
|
||||
def _approval_items(tool_name: str = "bash") -> list[dict]:
|
||||
return [{"func_name": tool_name, "needs_approval": True, "approval_label": tool_name}]
|
||||
|
||||
|
||||
def _wait_pending_resolved(bridge: Bridge, key: str, attr: str, deadline_s: float = 3.0) -> bool:
|
||||
"""Poll until the pending entry is resolved (tombstone) or absent."""
|
||||
deadline = time.monotonic() + deadline_s
|
||||
while time.monotonic() < deadline:
|
||||
with bridge._lock:
|
||||
entries = getattr(bridge, attr)
|
||||
if key not in entries:
|
||||
return True
|
||||
_, resolved_at = entries[key]
|
||||
if resolved_at > 0:
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 1: Duplicate approval on SSE reconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDuplicateApproval:
|
||||
"""Two threads call _handle_approval for the same ws_id simultaneously.
|
||||
Only one should create a pending entry; the other should be skipped."""
|
||||
|
||||
def test_no_duplicate_approvals(self):
|
||||
sent_count = Counter()
|
||||
|
||||
for _ in range(ITERATIONS):
|
||||
bridge = _make_bridge()
|
||||
bridge._broker.pop_response.return_value = '{"type": "approve", "approved": true}'
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
|
||||
def _call_approval(bridge=bridge, barrier=barrier):
|
||||
barrier.wait()
|
||||
bridge._handle_approval("ws-1", {"items": _approval_items()})
|
||||
|
||||
t1 = threading.Thread(target=_call_approval)
|
||||
t2 = threading.Thread(target=_call_approval)
|
||||
with (
|
||||
patch.object(bridge, "_api_approve") as mock_approve,
|
||||
patch.object(bridge, "_publish_ws"),
|
||||
):
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5)
|
||||
t2.join(timeout=5)
|
||||
assert not t1.is_alive(), "Thread 1 hung"
|
||||
assert not t2.is_alive(), "Thread 2 hung"
|
||||
|
||||
# Wait for spawned _wait_approval threads to resolve
|
||||
_wait_pending_resolved(bridge, "ws-1", "_pending_approvals")
|
||||
|
||||
sent_count[mock_approve.call_count] += 1
|
||||
|
||||
# At most 1 approval should be forwarded per iteration
|
||||
assert sent_count.get(2, 0) == 0, (
|
||||
f"Duplicate approvals sent in {sent_count[2]}/{ITERATIONS} iterations"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 2: Duplicate plan review on SSE reconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDuplicatePlanReview:
|
||||
"""Two threads call _handle_plan_review simultaneously.
|
||||
Only one should create a pending entry."""
|
||||
|
||||
def test_no_duplicate_plan_reviews(self):
|
||||
sent_count = Counter()
|
||||
|
||||
for _ in range(ITERATIONS):
|
||||
bridge = _make_bridge()
|
||||
bridge._broker.pop_response.return_value = (
|
||||
'{"type": "plan_feedback", "feedback": "looks good"}'
|
||||
)
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
|
||||
def _call_plan(bridge=bridge, barrier=barrier):
|
||||
barrier.wait()
|
||||
bridge._handle_plan_review("ws-1", {"content": "plan text"})
|
||||
|
||||
t1 = threading.Thread(target=_call_plan)
|
||||
t2 = threading.Thread(target=_call_plan)
|
||||
with patch.object(bridge, "_publish_ws"), patch.object(bridge._http, "post"):
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5)
|
||||
t2.join(timeout=5)
|
||||
assert not t1.is_alive(), "Thread 1 hung"
|
||||
assert not t2.is_alive(), "Thread 2 hung"
|
||||
|
||||
# Wait for spawned _wait_plan threads to resolve
|
||||
_wait_pending_resolved(bridge, "ws-1", "_pending_plan_reviews")
|
||||
|
||||
sent_count[bridge._http.post.call_count] += 1
|
||||
|
||||
assert sent_count.get(2, 0) == 0, (
|
||||
f"Duplicate plan reviews sent in {sent_count[2]}/{ITERATIONS} iterations"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 3: approve_set stale reference during concurrent update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApproveSetConsistency:
|
||||
"""One thread reads approve_set for auto-approve check while another
|
||||
updates it via _wait_approval 'always' path. The auto-approve
|
||||
decision should be consistent (either all-approved or not)."""
|
||||
|
||||
def test_approve_set_never_partially_visible(self):
|
||||
for _ in range(ITERATIONS):
|
||||
bridge = _make_bridge()
|
||||
with bridge._lock:
|
||||
bridge._ws_approve_tools["ws-1"] = {"read_file", "search"}
|
||||
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
results = []
|
||||
|
||||
def _reader(bridge=bridge, barrier=barrier, results=results):
|
||||
barrier.wait()
|
||||
with bridge._lock:
|
||||
snap = bridge._ws_approve_tools.get("ws-1", set()).copy()
|
||||
results.append(snap)
|
||||
|
||||
def _writer(bridge=bridge, barrier=barrier):
|
||||
barrier.wait()
|
||||
with bridge._lock:
|
||||
existing = bridge._ws_approve_tools.get("ws-1", set())
|
||||
bridge._ws_approve_tools["ws-1"] = existing | {"bash", "write_file"}
|
||||
|
||||
t1 = threading.Thread(target=_reader)
|
||||
t2 = threading.Thread(target=_writer)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5)
|
||||
t2.join(timeout=5)
|
||||
assert not t1.is_alive(), "Reader hung"
|
||||
assert not t2.is_alive(), "Writer hung"
|
||||
|
||||
snap = results[0]
|
||||
assert snap in (
|
||||
{"read_file", "search"},
|
||||
{"read_file", "search", "bash", "write_file"},
|
||||
), f"Partial set observed: {snap}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 4: _running flag visibility across threads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunningFlagVisibility:
|
||||
"""All threads reading _running should see False within a bounded time
|
||||
after the main thread sets it."""
|
||||
|
||||
def test_all_threads_observe_shutdown(self):
|
||||
bridge = _make_bridge()
|
||||
observed_false = threading.Event()
|
||||
threads_running = []
|
||||
|
||||
def _spin_checker():
|
||||
while bridge._running:
|
||||
time.sleep(0.001)
|
||||
observed_false.set()
|
||||
|
||||
for _ in range(5):
|
||||
t = threading.Thread(target=_spin_checker, daemon=True)
|
||||
threads_running.append(t)
|
||||
t.start()
|
||||
|
||||
time.sleep(0.01)
|
||||
bridge._running = False
|
||||
|
||||
for t in threads_running:
|
||||
t.join(timeout=1)
|
||||
assert not t.is_alive(), "Thread did not observe _running=False"
|
||||
|
||||
assert observed_false.is_set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 5: Approval thread exits within bounded time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApprovalThreadTimeout:
|
||||
"""An approval thread blocked on pop_response should exit within the
|
||||
configured approval_timeout, not hang indefinitely."""
|
||||
|
||||
def test_approval_thread_exits_within_timeout(self):
|
||||
for _ in range(10):
|
||||
bridge = _make_bridge(approval_timeout=0.5)
|
||||
|
||||
def _slow_pop(queue_name, timeout=300):
|
||||
time.sleep(min(timeout, 0.5))
|
||||
return None
|
||||
|
||||
bridge._broker.pop_response.side_effect = _slow_pop
|
||||
|
||||
with patch.object(bridge, "_publish_ws"), patch.object(bridge, "_api_approve"):
|
||||
bridge._handle_approval("ws-1", {"items": _approval_items()})
|
||||
|
||||
# The pending entry should be resolved within the timeout
|
||||
resolved = _wait_pending_resolved(bridge, "ws-1", "_pending_approvals", deadline_s=3.0)
|
||||
assert resolved, "Approval thread did not exit within expected timeout"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 6: Concurrent approval + workstream close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApprovalDuringClose:
|
||||
"""An approval arriving at the exact same time as a ws_closed event
|
||||
should not leave orphaned state."""
|
||||
|
||||
def test_no_orphaned_pending_after_close(self):
|
||||
for _ in range(ITERATIONS):
|
||||
bridge = _make_bridge(approval_timeout=0.1)
|
||||
bridge._broker.pop_response.return_value = None # timeout
|
||||
|
||||
barrier = threading.Barrier(2, timeout=5)
|
||||
|
||||
def _send_approval(bridge=bridge, barrier=barrier):
|
||||
barrier.wait()
|
||||
with patch.object(bridge, "_publish_ws"), patch.object(bridge, "_api_approve"):
|
||||
bridge._handle_approval("ws-1", {"items": _approval_items()})
|
||||
|
||||
def _close_ws(bridge=bridge, barrier=barrier):
|
||||
barrier.wait()
|
||||
with (
|
||||
patch.object(bridge, "_publish_global"),
|
||||
patch.object(bridge, "_publish_cluster"),
|
||||
):
|
||||
bridge._handle_global_event({"type": "ws_closed", "ws_id": "ws-1"})
|
||||
|
||||
t1 = threading.Thread(target=_send_approval)
|
||||
t2 = threading.Thread(target=_close_ws)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=5)
|
||||
t2.join(timeout=5)
|
||||
assert not t1.is_alive(), "Approval thread hung"
|
||||
assert not t2.is_alive(), "Close thread hung"
|
||||
|
||||
# Wait for spawned _wait_approval thread to resolve (if close
|
||||
# didn't remove the entry first)
|
||||
resolved = _wait_pending_resolved(bridge, "ws-1", "_pending_approvals")
|
||||
assert resolved, "Orphaned pending approval"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race 7: Plan review refinement loop (tombstone → cleanup → re-entry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanReviewRefinementLoop:
|
||||
"""After a plan review is resolved, a ws_state event should clean up the
|
||||
tombstone so the refinement-loop plan_review event is handled correctly."""
|
||||
|
||||
def test_refinement_loop_allows_reentry(self):
|
||||
for _ in range(ITERATIONS):
|
||||
bridge = _make_bridge()
|
||||
bridge._broker.pop_response.return_value = (
|
||||
'{"type": "plan_feedback", "feedback": "refine this"}'
|
||||
)
|
||||
|
||||
# Step 1: first plan review — creates pending entry, resolves it
|
||||
with patch.object(bridge, "_publish_ws"), patch.object(bridge._http, "post"):
|
||||
bridge._handle_plan_review("ws-1", {"content": "plan v1"})
|
||||
|
||||
_wait_pending_resolved(bridge, "ws-1", "_pending_plan_reviews")
|
||||
|
||||
# Verify tombstone is present (resolved_at > 0)
|
||||
with bridge._lock:
|
||||
assert "ws-1" in bridge._pending_plan_reviews
|
||||
assert bridge._pending_plan_reviews["ws-1"][1] > 0
|
||||
|
||||
# Step 2: ws_state event cleans up the resolved tombstone
|
||||
with (
|
||||
patch.object(bridge, "_publish_ws"),
|
||||
patch.object(bridge, "_publish_global"),
|
||||
patch.object(bridge, "_publish_cluster"),
|
||||
):
|
||||
bridge._handle_global_event(
|
||||
{"type": "ws_state", "ws_id": "ws-1", "state": "working"}
|
||||
)
|
||||
|
||||
with bridge._lock:
|
||||
assert "ws-1" not in bridge._pending_plan_reviews
|
||||
|
||||
# Step 3: refinement plan_review arrives — should create new entry
|
||||
with patch.object(bridge, "_publish_ws"), patch.object(bridge._http, "post"):
|
||||
bridge._handle_plan_review("ws-1", {"content": "plan v2"})
|
||||
|
||||
_wait_pending_resolved(bridge, "ws-1", "_pending_plan_reviews")
|
||||
|
||||
with bridge._lock:
|
||||
assert "ws-1" in bridge._pending_plan_reviews
|
||||
@@ -2,17 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
return backend
|
||||
|
||||
|
||||
class TestChannelUserCRUD:
|
||||
"""Tests for channel_users table operations."""
|
||||
|
||||
+60
-27
@@ -3,54 +3,55 @@
|
||||
import argparse
|
||||
|
||||
import turnstone.core.config as config_mod
|
||||
from turnstone.core.config import apply_config, load_config
|
||||
from turnstone.core.config import apply_config, load_config, set_config_path
|
||||
|
||||
|
||||
def _reset_cache():
|
||||
"""Clear the module-level config cache between tests."""
|
||||
config_mod._cache = None
|
||||
config_mod._config_path = None
|
||||
|
||||
|
||||
def test_load_config_missing_file(tmp_path, monkeypatch):
|
||||
def test_load_config_missing_file(tmp_path):
|
||||
_reset_cache()
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nope.toml")
|
||||
set_config_path(str(tmp_path / "nope.toml"))
|
||||
assert load_config() == {}
|
||||
|
||||
|
||||
def test_load_config_valid_toml(tmp_path, monkeypatch):
|
||||
def test_load_config_valid_toml(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[redis]\nhost = "10.0.0.1"\nport = 6380\npassword = "secret"\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
result = load_config()
|
||||
assert result["redis"]["host"] == "10.0.0.1"
|
||||
assert result["redis"]["port"] == 6380
|
||||
assert result["redis"]["password"] == "secret"
|
||||
|
||||
|
||||
def test_load_config_section(tmp_path, monkeypatch):
|
||||
def test_load_config_section(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[api]\nbase_url = "http://x:8000/v1"\n[redis]\nhost = "y"\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
assert load_config("redis") == {"host": "y"}
|
||||
assert load_config("api") == {"base_url": "http://x:8000/v1"}
|
||||
assert load_config("nonexistent") == {}
|
||||
|
||||
|
||||
def test_load_config_invalid_toml(tmp_path, monkeypatch):
|
||||
def test_load_config_invalid_toml(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text("this is not valid toml [[[")
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
assert load_config() == {}
|
||||
|
||||
|
||||
def test_load_config_caches(tmp_path, monkeypatch):
|
||||
def test_load_config_caches(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[api]\nbase_url = "http://first"\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
first = load_config()
|
||||
assert first["api"]["base_url"] == "http://first"
|
||||
|
||||
@@ -60,14 +61,14 @@ def test_load_config_caches(tmp_path, monkeypatch):
|
||||
assert second["api"]["base_url"] == "http://first"
|
||||
|
||||
|
||||
def test_apply_config_sets_defaults(tmp_path, monkeypatch):
|
||||
def test_apply_config_sets_defaults(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
'[redis]\nhost = "redis.local"\nport = 7777\npassword = "pw"\n'
|
||||
'[bridge]\nserver_url = "http://bridge:9090"\n'
|
||||
)
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--redis-host", default="localhost")
|
||||
@@ -84,11 +85,11 @@ def test_apply_config_sets_defaults(tmp_path, monkeypatch):
|
||||
assert args.server_url == "http://bridge:9090"
|
||||
|
||||
|
||||
def test_apply_config_cli_overrides(tmp_path, monkeypatch):
|
||||
def test_apply_config_cli_overrides(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[redis]\nhost = "config-host"\nport = 7777\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--redis-host", default="localhost")
|
||||
@@ -102,11 +103,11 @@ def test_apply_config_cli_overrides(tmp_path, monkeypatch):
|
||||
assert args.redis_port == 7777 # config wins (no CLI override)
|
||||
|
||||
|
||||
def test_apply_config_missing_keys_keep_defaults(tmp_path, monkeypatch):
|
||||
def test_apply_config_missing_keys_keep_defaults(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[redis]\nhost = "only-host"\n') # no port, no password
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--redis-host", default="localhost")
|
||||
@@ -121,9 +122,9 @@ def test_apply_config_missing_keys_keep_defaults(tmp_path, monkeypatch):
|
||||
assert args.redis_password is None # original default kept
|
||||
|
||||
|
||||
def test_apply_config_no_file(tmp_path, monkeypatch):
|
||||
def test_apply_config_no_file(tmp_path):
|
||||
_reset_cache()
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", tmp_path / "nope.toml")
|
||||
set_config_path(str(tmp_path / "nope.toml"))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--redis-host", default="localhost")
|
||||
@@ -133,11 +134,11 @@ def test_apply_config_no_file(tmp_path, monkeypatch):
|
||||
assert args.redis_host == "localhost"
|
||||
|
||||
|
||||
def test_apply_config_model_section(tmp_path, monkeypatch):
|
||||
def test_apply_config_model_section(tmp_path):
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[model]\nname = "qwen-72b"\ntemperature = 0.3\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default=None)
|
||||
@@ -158,7 +159,7 @@ def test_tavily_key_from_config(tmp_path, monkeypatch):
|
||||
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text('[api]\ntavily_key = "tvly-from-config"\n')
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
|
||||
key = config_mod.get_tavily_key()
|
||||
@@ -174,14 +175,14 @@ def test_tavily_key_fallback_to_env(tmp_path, monkeypatch):
|
||||
# Config exists but no tavily_key in it
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text("[api]\n")
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "tvly-from-env")
|
||||
|
||||
key = config_mod.get_tavily_key()
|
||||
assert key == "tvly-from-env"
|
||||
|
||||
|
||||
def test_apply_config_judge_section(tmp_path, monkeypatch):
|
||||
def test_apply_config_judge_section(tmp_path):
|
||||
"""apply_config() loads [judge] section and maps to argparse dests."""
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
@@ -193,7 +194,7 @@ def test_apply_config_judge_section(tmp_path, monkeypatch):
|
||||
"timeout = 30.0\n"
|
||||
"read_only_tools = false\n"
|
||||
)
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--judge", dest="judge_enabled", action="store_true", default=False)
|
||||
@@ -212,12 +213,12 @@ def test_apply_config_judge_section(tmp_path, monkeypatch):
|
||||
assert args.judge_read_only_tools is False
|
||||
|
||||
|
||||
def test_apply_config_judge_cli_overrides(tmp_path, monkeypatch):
|
||||
def test_apply_config_judge_cli_overrides(tmp_path):
|
||||
"""CLI flags override config.toml [judge] values."""
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text("[judge]\nenabled = true\nconfidence_threshold = 0.85\n")
|
||||
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
||||
set_config_path(str(cfg))
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--judge", dest="judge_enabled", action="store_true", default=False)
|
||||
@@ -229,3 +230,35 @@ def test_apply_config_judge_cli_overrides(tmp_path, monkeypatch):
|
||||
|
||||
assert args.judge_enabled is False # CLI wins
|
||||
assert args.judge_confidence == 0.85 # config wins (no CLI override)
|
||||
|
||||
|
||||
def test_set_config_path_overrides_default(tmp_path):
|
||||
"""set_config_path() overrides the default config location."""
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "custom.toml"
|
||||
cfg.write_text('[api]\nbase_url = "http://custom:9999"\n')
|
||||
set_config_path(str(cfg))
|
||||
assert load_config("api") == {"base_url": "http://custom:9999"}
|
||||
|
||||
|
||||
def test_env_var_overrides_default(tmp_path, monkeypatch):
|
||||
"""$TURNSTONE_CONFIG env var overrides the default config location."""
|
||||
_reset_cache()
|
||||
cfg = tmp_path / "env.toml"
|
||||
cfg.write_text('[api]\nbase_url = "http://env:7777"\n')
|
||||
monkeypatch.setenv("TURNSTONE_CONFIG", str(cfg))
|
||||
assert load_config("api") == {"base_url": "http://env:7777"}
|
||||
|
||||
|
||||
def test_set_config_path_overrides_env_var(tmp_path, monkeypatch):
|
||||
"""set_config_path() takes precedence over $TURNSTONE_CONFIG."""
|
||||
_reset_cache()
|
||||
env_cfg = tmp_path / "env.toml"
|
||||
env_cfg.write_text('[api]\nbase_url = "http://env"\n')
|
||||
monkeypatch.setenv("TURNSTONE_CONFIG", str(env_cfg))
|
||||
|
||||
explicit_cfg = tmp_path / "explicit.toml"
|
||||
explicit_cfg.write_text('[api]\nbase_url = "http://explicit"\n')
|
||||
set_config_path(str(explicit_cfg))
|
||||
|
||||
assert load_config("api") == {"base_url": "http://explicit"}
|
||||
|
||||
+105
-17
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -47,8 +47,8 @@ class MockBroker:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_collector(broker=None, poll_interval=999, discovery_interval=999):
|
||||
"""Create a collector with long intervals so threads don't auto-fire."""
|
||||
def _make_collector(broker=None, poll_interval=0, discovery_interval=999):
|
||||
"""Create a collector with zero poll interval (no jitter delay in tests)."""
|
||||
b = broker or MockBroker()
|
||||
return ClusterCollector(
|
||||
broker=b,
|
||||
@@ -264,6 +264,57 @@ class TestCollectorPolling:
|
||||
assert q.empty()
|
||||
assert len(c._nodes["node-a"].workstreams) == 0
|
||||
|
||||
def test_poll_401_preserves_workstreams_and_marks_unreachable(self):
|
||||
"""A 401 from the server must NOT wipe workstream data."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
|
||||
)
|
||||
|
||||
# Mock httpx to return 401
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
401,
|
||||
json={"error": "Unauthorized"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
# Workstream data must be preserved, node marked unreachable
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "existing"
|
||||
|
||||
def test_poll_403_preserves_workstreams(self):
|
||||
"""A 403 should also preserve state and mark unreachable."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "keep-me", "state": "running"}},
|
||||
)
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
403,
|
||||
json={"error": "Forbidden"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
|
||||
class TestCollectorEvents:
|
||||
"""Real-time event handling from cluster channel."""
|
||||
@@ -902,6 +953,8 @@ class TestConsoleWorkstreamCreation:
|
||||
],
|
||||
2,
|
||||
)
|
||||
# get_all_nodes delegates to get_nodes (mirrors real implementation)
|
||||
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
|
||||
return collector
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -1050,6 +1103,39 @@ class TestConsoleWorkstreamCreation:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["target_node"] == "pool"
|
||||
|
||||
def test_create_with_resume_ws_directed(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in directed dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"node_id": "node-a", "resume_ws": "old-ws-id-123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-123"
|
||||
|
||||
def test_create_with_resume_ws_pool(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in pool dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"node_id": "pool", "resume_ws": "old-ws-id-456"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-456"
|
||||
|
||||
def test_create_with_resume_ws_auto(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in auto-select dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"resume_ws": "old-ws-id-789"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-789"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy tests
|
||||
@@ -1182,47 +1268,49 @@ class TestProxyRewriting:
|
||||
class TestPickBestNode:
|
||||
"""Test the _pick_best_node helper."""
|
||||
|
||||
@staticmethod
|
||||
def _mock_collector(nodes: list) -> MagicMock:
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_nodes.return_value = (nodes, len(nodes))
|
||||
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
|
||||
return collector
|
||||
|
||||
def test_picks_node_with_most_headroom(self):
|
||||
from turnstone.console.server import _pick_best_node
|
||||
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_nodes.return_value = (
|
||||
collector = self._mock_collector(
|
||||
[
|
||||
{"node_id": "busy", "reachable": True, "max_ws": 10, "ws_total": 9},
|
||||
{"node_id": "free", "reachable": True, "max_ws": 10, "ws_total": 2},
|
||||
{"node_id": "mid", "reachable": True, "max_ws": 10, "ws_total": 5},
|
||||
],
|
||||
3,
|
||||
]
|
||||
)
|
||||
assert _pick_best_node(collector) == "free"
|
||||
|
||||
def test_skips_unreachable_nodes(self):
|
||||
from turnstone.console.server import _pick_best_node
|
||||
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_nodes.return_value = (
|
||||
collector = self._mock_collector(
|
||||
[
|
||||
{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0},
|
||||
{"node_id": "up", "reachable": True, "max_ws": 10, "ws_total": 5},
|
||||
],
|
||||
2,
|
||||
]
|
||||
)
|
||||
assert _pick_best_node(collector) == "up"
|
||||
|
||||
def test_returns_empty_when_no_nodes(self):
|
||||
from turnstone.console.server import _pick_best_node
|
||||
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_nodes.return_value = ([], 0)
|
||||
collector = self._mock_collector([])
|
||||
assert _pick_best_node(collector) == ""
|
||||
|
||||
def test_returns_empty_when_all_unreachable(self):
|
||||
from turnstone.console.server import _pick_best_node
|
||||
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_nodes.return_value = (
|
||||
[{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0}],
|
||||
1,
|
||||
collector = self._mock_collector(
|
||||
[
|
||||
{"node_id": "down", "reachable": False, "max_ws": 10, "ws_total": 0},
|
||||
]
|
||||
)
|
||||
assert _pick_best_node(collector) == ""
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for turnstone.core.env — subprocess environment scrubbing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.env import _is_safe, _is_secret, scrubbed_env
|
||||
|
||||
|
||||
class TestIsSecret:
|
||||
def test_explicit_scrub_list(self):
|
||||
assert _is_secret("OPENAI_API_KEY") is True
|
||||
assert _is_secret("ANTHROPIC_API_KEY") is True
|
||||
assert _is_secret("TURNSTONE_JWT_SECRET") is True
|
||||
assert _is_secret("AWS_SECRET_ACCESS_KEY") is True
|
||||
|
||||
def test_suffix_matching(self):
|
||||
assert _is_secret("MY_CUSTOM_API_KEY") is True
|
||||
assert _is_secret("DB_PASSWORD") is True
|
||||
assert _is_secret("AUTH_TOKEN") is True
|
||||
assert _is_secret("SERVICE_CREDENTIAL") is True
|
||||
assert _is_secret("GCP_CREDENTIALS") is True
|
||||
|
||||
def test_safe_vars_not_secret(self):
|
||||
assert _is_secret("PATH") is False
|
||||
assert _is_secret("HOME") is False
|
||||
assert _is_secret("LANG") is False
|
||||
|
||||
def test_no_false_positives_on_substring(self):
|
||||
"""Suffix matching avoids false positives like MONKEYTYPE."""
|
||||
assert _is_secret("MONKEYTYPE") is False
|
||||
assert _is_secret("KEYBOARD_LAYOUT") is False
|
||||
assert _is_secret("PYTHONPATH") is False
|
||||
assert _is_secret("EDITOR") is False
|
||||
assert _is_secret("GOPATH") is False
|
||||
|
||||
|
||||
class TestIsSafe:
|
||||
def test_safe_names(self):
|
||||
assert _is_safe("PATH") is True
|
||||
assert _is_safe("HOME") is True
|
||||
assert _is_safe("TERM") is True
|
||||
assert _is_safe("MANWIDTH") is True
|
||||
|
||||
def test_safe_prefixes(self):
|
||||
assert _is_safe("LC_ALL") is True
|
||||
assert _is_safe("LC_CTYPE") is True
|
||||
assert _is_safe("XDG_RUNTIME_DIR") is True
|
||||
|
||||
def test_non_safe_names(self):
|
||||
assert _is_safe("OPENAI_API_KEY") is False
|
||||
assert _is_safe("CUSTOM_VAR") is False
|
||||
|
||||
|
||||
class TestScrubbedEnv:
|
||||
def test_strips_api_keys(self):
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"HOME": "/home/user",
|
||||
"OPENAI_API_KEY": "sk-secret",
|
||||
"ANTHROPIC_API_KEY": "ant-secret",
|
||||
"CUSTOM_VAR": "safe_value",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env()
|
||||
|
||||
assert result["PATH"] == "/usr/bin"
|
||||
assert result["HOME"] == "/home/user"
|
||||
assert result["CUSTOM_VAR"] == "safe_value"
|
||||
assert "OPENAI_API_KEY" not in result
|
||||
assert "ANTHROPIC_API_KEY" not in result
|
||||
|
||||
def test_strips_pattern_matched_secrets(self):
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"MY_SERVICE_TOKEN": "tok-123",
|
||||
"DB_PASSWORD": "pass123",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env()
|
||||
|
||||
assert "MY_SERVICE_TOKEN" not in result
|
||||
assert "DB_PASSWORD" not in result
|
||||
|
||||
def test_extra_vars_merged(self):
|
||||
fake_env = {"PATH": "/usr/bin"}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env(extra={"MANWIDTH": "80"})
|
||||
|
||||
assert result["MANWIDTH"] == "80"
|
||||
assert result["PATH"] == "/usr/bin"
|
||||
|
||||
def test_passthrough_overrides_scrub(self):
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"OPENAI_API_KEY": "sk-needed",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env(passthrough=["OPENAI_API_KEY"])
|
||||
|
||||
assert result["OPENAI_API_KEY"] == "sk-needed"
|
||||
|
||||
def test_preserves_locale_vars(self):
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"LC_ALL": "en_US.UTF-8",
|
||||
"LC_CTYPE": "en_US.UTF-8",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env()
|
||||
|
||||
assert result["LC_ALL"] == "en_US.UTF-8"
|
||||
assert result["LC_CTYPE"] == "en_US.UTF-8"
|
||||
|
||||
def test_preserves_unknown_non_secret_vars(self):
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"PYTHONPATH": "/opt/lib",
|
||||
"GOPATH": "/home/user/go",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env()
|
||||
|
||||
assert result["PYTHONPATH"] == "/opt/lib"
|
||||
assert result["GOPATH"] == "/home/user/go"
|
||||
|
||||
def test_extra_can_reintroduce_scrubbed_var(self):
|
||||
"""extra= intentionally overrides scrubbing (operator-controlled)."""
|
||||
fake_env = {"PATH": "/usr/bin", "OPENAI_API_KEY": "sk-original"}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env(extra={"OPENAI_API_KEY": "sk-injected"})
|
||||
|
||||
assert result["OPENAI_API_KEY"] == "sk-injected"
|
||||
|
||||
def test_less_prefix_does_not_leak_secrets(self):
|
||||
"""LESS pager vars are safe but LESS_SECRET_TOKEN is not."""
|
||||
fake_env = {
|
||||
"PATH": "/usr/bin",
|
||||
"LESS": "-R",
|
||||
"LESSOPEN": "| lesspipe %s",
|
||||
"LESS_SECRET_TOKEN": "tok-secret",
|
||||
}
|
||||
with patch.dict(os.environ, fake_env, clear=True):
|
||||
result = scrubbed_env()
|
||||
|
||||
assert result["LESS"] == "-R"
|
||||
assert result["LESSOPEN"] == "| lesspipe %s"
|
||||
assert "LESS_SECRET_TOKEN" not in result
|
||||
@@ -8,18 +8,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -221,6 +221,63 @@ class TestBackendHealthMonitor:
|
||||
mon.record_failure()
|
||||
assert mon.circuit_state == CircuitState.OPEN # type: ignore[comparison-overlap]
|
||||
|
||||
def test_probe_loop_autonomous_recovery(
|
||||
self, mock_client: MagicMock, mock_metrics: MagicMock
|
||||
) -> None:
|
||||
"""_probe_loop transitions OPEN → HALF_OPEN → CLOSED without user requests."""
|
||||
# Use very short intervals so the test is fast
|
||||
mon = BackendHealthMonitor(
|
||||
client=mock_client,
|
||||
probe_interval=0.05,
|
||||
probe_timeout=1.0,
|
||||
failure_threshold=1,
|
||||
cooldown=0.1,
|
||||
)
|
||||
# Trip the circuit
|
||||
mon.record_failure()
|
||||
assert mon.circuit_state == CircuitState.OPEN
|
||||
|
||||
# Backend is healthy — probe_once will succeed
|
||||
mock_client.with_options.return_value.models.list.return_value = MagicMock()
|
||||
|
||||
# Start the probe loop and wait for autonomous recovery
|
||||
mon.start()
|
||||
try:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
while mon.circuit_state != CircuitState.CLOSED and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert mon.circuit_state == CircuitState.CLOSED
|
||||
# User requests should flow again without anyone calling acquire_request_permit
|
||||
assert mon.acquire_request_permit() is True
|
||||
finally:
|
||||
mon.stop()
|
||||
if mon._thread:
|
||||
mon._thread.join(timeout=2.0)
|
||||
|
||||
def test_probe_loop_no_user_permit_during_probe(
|
||||
self, mock_client: MagicMock, mock_metrics: MagicMock
|
||||
) -> None:
|
||||
"""While background probe is in HALF_OPEN, user requests are blocked."""
|
||||
mon = BackendHealthMonitor(
|
||||
client=mock_client,
|
||||
probe_interval=0.05,
|
||||
probe_timeout=1.0,
|
||||
failure_threshold=1,
|
||||
cooldown=0.1,
|
||||
)
|
||||
mon.record_failure()
|
||||
assert mon.circuit_state == CircuitState.OPEN
|
||||
|
||||
# Force into HALF_OPEN as the probe loop would
|
||||
with mon._lock:
|
||||
mon._state = CircuitState.HALF_OPEN
|
||||
mon._half_open_permit = False # probe consumes it
|
||||
|
||||
# User requests should be blocked — only the probe gets through
|
||||
assert mon.acquire_request_permit() is False
|
||||
|
||||
def test_stop_thread(self, mock_client: MagicMock) -> None:
|
||||
"""stop() signals the probe loop to exit."""
|
||||
mon = _make_monitor(mock_client)
|
||||
|
||||
@@ -182,7 +182,6 @@ class TestErrorHandling:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -215,7 +214,6 @@ class TestErrorHandling:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -259,7 +257,6 @@ class TestMultiTurnToolUse:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
@@ -305,7 +302,6 @@ class TestMultiTurnToolUse:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
@@ -4,16 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
def _make_verdict_kwargs(**overrides):
|
||||
"""Build default kwargs for create_intent_verdict."""
|
||||
|
||||
+143
-46
@@ -1,4 +1,4 @@
|
||||
"""Tests for the load_skill built-in tool."""
|
||||
"""Tests for the skill built-in tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,25 +9,25 @@ from turnstone.core.tools import BUILTIN_TOOL_NAMES, PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
class TestToolRegistration:
|
||||
"""Verify load_skill is registered correctly."""
|
||||
"""Verify skill is registered correctly."""
|
||||
|
||||
def test_in_builtin_tool_names(self) -> None:
|
||||
assert "load_skill" in BUILTIN_TOOL_NAMES
|
||||
assert "skill" in BUILTIN_TOOL_NAMES
|
||||
|
||||
def test_not_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_not_task_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import TASK_AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_has_primary_key(self) -> None:
|
||||
assert PRIMARY_KEY_MAP.get("load_skill") == "name"
|
||||
assert PRIMARY_KEY_MAP.get("skill") == "name"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,12 +82,12 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
|
||||
|
||||
|
||||
class TestPrepareLoadSkill:
|
||||
"""Test _prepare_load_skill validation and item dict shape."""
|
||||
"""Test _prepare_skill validation and item dict shape."""
|
||||
|
||||
def test_load_valid(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "load_skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "skill"
|
||||
assert item["action"] == "load"
|
||||
assert item["name"] == "code-review"
|
||||
assert item["needs_approval"] is True
|
||||
@@ -96,19 +96,19 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_load_missing_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load"})
|
||||
item = session._prepare_skill("call-1", {"action": "load"})
|
||||
assert "error" in item
|
||||
assert "name" in item["error"].lower()
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_load_empty_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": ""})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_search_with_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == "code review"
|
||||
assert item["needs_approval"] is False
|
||||
@@ -116,30 +116,30 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_search_without_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == ""
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_invalid_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "delete"})
|
||||
item = session._prepare_skill("call-1", {"action": "delete"})
|
||||
assert "error" in item
|
||||
assert "delete" in item["error"]
|
||||
|
||||
def test_empty_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": ""})
|
||||
item = session._prepare_skill("call-1", {"action": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_header_for_load(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert "my-skill" in item["header"]
|
||||
|
||||
def test_header_for_search(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "testing"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "testing"})
|
||||
assert "testing" in item["header"]
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestPrepareLoadSkill:
|
||||
|
||||
|
||||
class TestExecLoadSkill:
|
||||
"""Test _exec_load_skill execution logic."""
|
||||
"""Test _exec_skill execution logic."""
|
||||
|
||||
def test_load_existing_skill(self) -> None:
|
||||
skills = [
|
||||
@@ -164,8 +164,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert call_id == "call-1"
|
||||
assert "code-review" in result
|
||||
@@ -177,8 +177,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session([])
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -188,8 +188,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_skill(item)
|
||||
|
||||
session.ui.on_tool_result.assert_called_once()
|
||||
|
||||
@@ -216,10 +216,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
# docs-writer shouldn't match "code" query
|
||||
@@ -241,10 +241,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
# Should be limited to 10
|
||||
assert result.count("skill-") == 10
|
||||
@@ -254,10 +254,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = []
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -276,21 +276,21 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "risky"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "risky"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "high" in result
|
||||
|
||||
def test_search_storage_failure_returns_empty(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "test"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "test"})
|
||||
|
||||
with patch(
|
||||
"turnstone.core.storage._registry.get_storage", side_effect=RuntimeError("no storage")
|
||||
):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -307,10 +307,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill(
|
||||
"call-1", {"action": "load", "name": "disabled-skill"}
|
||||
)
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "disabled-skill"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -321,8 +319,8 @@ class TestExecLoadSkill:
|
||||
session._skill_name = "active"
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "already active" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -352,10 +350,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "enabled-skill" in result
|
||||
assert "disabled-skill" not in result
|
||||
@@ -375,14 +373,113 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
|
||||
def test_preparer_load_has_approval_label(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "load_skill__my-skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "skill__my-skill"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Skill Catalog Disclosure (Agent Skills standard compliance)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillCatalogDisclosure:
|
||||
"""Verify <available-skills> catalog appears in system messages."""
|
||||
|
||||
def _build_session_with_system_messages(
|
||||
self,
|
||||
search_skills: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
"""Build a session and call _init_system_messages to get dev_parts."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
session = ChatSession.__new__(ChatSession)
|
||||
ui = MagicMock()
|
||||
session.ui = ui
|
||||
session.model = "test-model"
|
||||
session._ws_id = "ws-test"
|
||||
session._node_id = "node-1"
|
||||
session._skill_name = None
|
||||
session._skill_content = None
|
||||
session._skill_resources = {}
|
||||
session._applied_skill_content = None
|
||||
session.context_window = 128000
|
||||
session.messages = []
|
||||
session._config = {}
|
||||
session.creative_mode = False
|
||||
session.instructions = ""
|
||||
session.system_messages = []
|
||||
session._agent_system_messages = []
|
||||
session.reasoning_effort = "medium"
|
||||
session._pending_nudge = []
|
||||
session._tool_search = None
|
||||
session._mcp_client = None
|
||||
session._notify_on_complete = "{}"
|
||||
|
||||
# Memory stubs
|
||||
session._memory_config = MagicMock()
|
||||
session._memory_config.fetch_limit = 0
|
||||
session._user_id = ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.session.list_skills_by_activation",
|
||||
return_value=search_skills or [],
|
||||
),
|
||||
patch.object(session, "_get_visible_memories", return_value=[]),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
return session
|
||||
|
||||
def test_catalog_present_with_search_skills(self) -> None:
|
||||
skills = [
|
||||
{"name": "pdf-processing", "description": "Extract PDF text and forms."},
|
||||
{"name": "data-analysis", "description": "Analyze datasets."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" in content
|
||||
assert "pdf-processing" in content
|
||||
assert "data-analysis" in content
|
||||
assert "</available-skills>" in content
|
||||
|
||||
def test_catalog_omitted_when_no_search_skills(self) -> None:
|
||||
session = self._build_session_with_system_messages(search_skills=[])
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" not in content
|
||||
|
||||
def test_catalog_capped_at_30(self) -> None:
|
||||
skills = [{"name": f"skill-{i:03d}", "description": f"Desc {i}"} for i in range(50)]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
# Should include first 30, not all 50
|
||||
assert "skill-029" in content
|
||||
assert "skill-030" not in content
|
||||
|
||||
def test_catalog_escapes_html(self) -> None:
|
||||
skills = [
|
||||
{"name": "xss-test", "description": "Handle <script> & 'quotes'."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<script>" in content
|
||||
assert "<script>" not in content.replace("<available-skills>", "").replace(
|
||||
"</available-skills>", ""
|
||||
).replace("<skill>", "").replace("</skill>", "").replace("<name>", "").replace(
|
||||
"</name>", ""
|
||||
).replace("<description>", "").replace("</description>", "")
|
||||
|
||||
def test_catalog_includes_hint(self) -> None:
|
||||
skills = [{"name": "test", "description": "Test skill."}]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "/skill" in content
|
||||
|
||||
@@ -26,6 +26,7 @@ from turnstone.console.server import (
|
||||
admin_get_mcp_server,
|
||||
admin_import_mcp_config,
|
||||
admin_list_mcp_servers,
|
||||
admin_mcp_reload,
|
||||
admin_update_mcp_server,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
@@ -96,6 +97,11 @@ _ROUTES = [
|
||||
admin_import_mcp_config,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/mcp-servers/reload",
|
||||
admin_mcp_reload,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/mcp-servers/{server_id}",
|
||||
admin_get_mcp_server,
|
||||
@@ -115,6 +121,21 @@ _ROUTES = [
|
||||
]
|
||||
|
||||
|
||||
def _routes_with_internal() -> list[Mount]:
|
||||
"""Routes including the node-side internal endpoint (lazy-imported)."""
|
||||
from turnstone.server import internal_mcp_reload
|
||||
|
||||
return [
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
*_ROUTES[0].routes, # type: ignore[union-attr]
|
||||
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
@@ -550,6 +571,7 @@ def _fake_request(*nodes: dict[str, Any], proxy_client: Any = None) -> MagicMock
|
||||
"""Build a minimal mock request with collector and proxy_client."""
|
||||
collector = MagicMock()
|
||||
collector.get_nodes.return_value = (list(nodes), len(nodes))
|
||||
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
|
||||
req = MagicMock()
|
||||
req.app.state.collector = collector
|
||||
req.app.state.proxy_client = proxy_client or AsyncMock()
|
||||
@@ -693,3 +715,175 @@ class TestNotifyNodesMcpReload:
|
||||
result = await _notify_nodes_mcp_reload(req)
|
||||
assert result["n1"] == {"reloaded": 2}
|
||||
assert "error" in result["n2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console reload endpoint: POST /v1/api/admin/mcp-servers/reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdminMcpReloadEndpoint:
|
||||
"""HTTP-level tests for the console reload endpoint."""
|
||||
|
||||
def test_reload_success(self, client: TestClient) -> None:
|
||||
"""Reload endpoint returns status ok and fan-out results."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"n1": {"reloaded": 3}},
|
||||
):
|
||||
r = client.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["results"] == {"n1": {"reloaded": 3}}
|
||||
|
||||
def test_reload_empty_cluster(self, client: TestClient) -> None:
|
||||
"""Reload with no nodes returns empty results."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
r = client.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["results"] == {}
|
||||
|
||||
def test_reload_permission_denied(self, client_no_perm: TestClient) -> None:
|
||||
"""Reload without admin.mcp permission is rejected."""
|
||||
r = client_no_perm.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 403
|
||||
assert "admin.mcp" in r.json()["error"]
|
||||
|
||||
def test_reload_no_storage(self) -> None:
|
||||
"""Reload returns 503 when auth_storage is not available."""
|
||||
app = Starlette(
|
||||
routes=_ROUTES,
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
# Deliberately omit app.state.auth_storage
|
||||
no_storage_client = TestClient(app, raise_server_exceptions=False)
|
||||
r = no_storage_client.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 503
|
||||
|
||||
def test_reload_mixed_node_results(self, client: TestClient) -> None:
|
||||
"""Reload propagates per-node errors in results."""
|
||||
with patch(
|
||||
"turnstone.console.server._notify_nodes_mcp_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"n1": {"reloaded": 2},
|
||||
"n2": {"error": "Connection refused"},
|
||||
},
|
||||
):
|
||||
r = client.post("/v1/api/admin/mcp-servers/reload")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["results"]["n1"] == {"reloaded": 2}
|
||||
assert "error" in data["results"]["n2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node reload endpoint: POST /v1/api/_internal/mcp-reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInternalMcpReloadEndpoint:
|
||||
"""HTTP-level tests for the node-side MCP reload endpoint."""
|
||||
|
||||
@pytest.fixture()
|
||||
def node_client(self, storage: SQLiteBackend) -> TestClient:
|
||||
"""TestClient with an MCP client manager on app.state."""
|
||||
app = Starlette(
|
||||
routes=_routes_with_internal(),
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
mgr = MagicMock()
|
||||
mgr.reconcile_sync.return_value = {
|
||||
"added": ["new-srv"],
|
||||
"removed": [],
|
||||
"updated": [],
|
||||
}
|
||||
app.state.mcp_client = mgr
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
def test_reload_calls_reconcile(self, node_client: TestClient, storage: SQLiteBackend) -> None:
|
||||
"""Reload endpoint calls reconcile_sync and returns its result."""
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
||||
r = node_client.post("/v1/api/_internal/mcp-reload")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["added"] == ["new-srv"]
|
||||
assert data["removed"] == []
|
||||
assert data["updated"] == []
|
||||
|
||||
def test_reload_passes_storage_to_reconcile(
|
||||
self,
|
||||
storage: SQLiteBackend,
|
||||
) -> None:
|
||||
"""Verify reconcile_sync receives the storage backend."""
|
||||
app = Starlette(
|
||||
routes=_routes_with_internal(),
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
mgr = MagicMock()
|
||||
mgr.reconcile_sync.return_value = {"added": [], "removed": [], "updated": []}
|
||||
app.state.mcp_client = mgr
|
||||
c = TestClient(app, raise_server_exceptions=False)
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
||||
r = c.post("/v1/api/_internal/mcp-reload")
|
||||
assert r.status_code == 200
|
||||
mgr.reconcile_sync.assert_called_once_with(storage)
|
||||
|
||||
def test_reload_creates_manager_when_missing(self, storage: SQLiteBackend) -> None:
|
||||
"""When mcp_client is absent, a new MCPClientManager is created."""
|
||||
app = Starlette(
|
||||
routes=_routes_with_internal(),
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
# No mcp_client on app.state
|
||||
c = TestClient(app, raise_server_exceptions=False)
|
||||
with (
|
||||
patch("turnstone.core.storage._registry.get_storage", return_value=storage),
|
||||
patch("turnstone.core.mcp_client.MCPClientManager") as mock_cls,
|
||||
):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.reconcile_sync.return_value = {
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"updated": [],
|
||||
}
|
||||
mock_cls.return_value = mock_mgr
|
||||
r = c.post("/v1/api/_internal/mcp-reload")
|
||||
assert r.status_code == 200
|
||||
mock_cls.assert_called_once_with({})
|
||||
mock_mgr.start.assert_called_once()
|
||||
mock_mgr.reconcile_sync.assert_called_once_with(storage)
|
||||
|
||||
def test_reload_reconcile_result_in_response(self, storage: SQLiteBackend) -> None:
|
||||
"""Full reconcile result fields (added/removed/updated) appear in JSON."""
|
||||
app = Starlette(
|
||||
routes=_routes_with_internal(),
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
mgr = MagicMock()
|
||||
mgr.reconcile_sync.return_value = {
|
||||
"added": ["a"],
|
||||
"removed": ["b"],
|
||||
"updated": ["c"],
|
||||
}
|
||||
app.state.mcp_client = mgr
|
||||
c = TestClient(app, raise_server_exceptions=False)
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
||||
r = c.post("/v1/api/_internal/mcp-reload")
|
||||
data = r.json()
|
||||
assert data["added"] == ["a"]
|
||||
assert data["removed"] == ["b"]
|
||||
assert data["updated"] == ["c"]
|
||||
|
||||
@@ -398,6 +398,72 @@ class TestResolveInstallConfig:
|
||||
config = resolve_install_config(server, "remote", 0)
|
||||
assert config["url"] == "https://us-east.example.com/mcp"
|
||||
|
||||
def test_remote_variable_substitution_invalid_scheme(self) -> None:
|
||||
server = RegistryServer(
|
||||
name="io.example/test",
|
||||
version="1.0.0",
|
||||
remotes=[
|
||||
RegistryRemote(
|
||||
type="streamable-http",
|
||||
url="{scheme}://evil.example.com/mcp",
|
||||
variables={
|
||||
"scheme": RegistryRemoteVariable(is_required=True),
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(MCPRegistryError, match="Invalid URL scheme"):
|
||||
resolve_install_config(server, "remote", 0, variables={"scheme": "file"})
|
||||
|
||||
def test_remote_variable_substitution_preserves_valid_scheme(self) -> None:
|
||||
server = RegistryServer(
|
||||
name="io.example/test",
|
||||
version="1.0.0",
|
||||
remotes=[
|
||||
RegistryRemote(
|
||||
type="streamable-http",
|
||||
url="https://{host}.example.com/mcp",
|
||||
variables={
|
||||
"host": RegistryRemoteVariable(is_required=True),
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
config = resolve_install_config(server, "remote", 0, variables={"host": "api"})
|
||||
assert config["url"] == "https://api.example.com/mcp"
|
||||
|
||||
def test_remote_variable_substitution_missing_hostname(self) -> None:
|
||||
"""URL like https:///mcp has valid scheme but no hostname."""
|
||||
server = RegistryServer(
|
||||
name="io.example/test",
|
||||
version="1.0.0",
|
||||
remotes=[
|
||||
RegistryRemote(
|
||||
type="streamable-http",
|
||||
url="https:///mcp",
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(MCPRegistryError, match="hostname is missing"):
|
||||
resolve_install_config(server, "remote", 0)
|
||||
|
||||
def test_remote_variable_substitution_embedded_credentials(self) -> None:
|
||||
server = RegistryServer(
|
||||
name="io.example/test",
|
||||
version="1.0.0",
|
||||
remotes=[
|
||||
RegistryRemote(
|
||||
type="streamable-http",
|
||||
url="https://{creds}@example.com/mcp",
|
||||
variables={
|
||||
"creds": RegistryRemoteVariable(is_required=True),
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
with pytest.raises(MCPRegistryError, match="embedded credentials"):
|
||||
resolve_install_config(server, "remote", 0, variables={"creds": "user:pass"})
|
||||
|
||||
def test_remote_no_remotes(self) -> None:
|
||||
server = RegistryServer(name="io.example/test", version="1.0.0")
|
||||
with pytest.raises(MCPRegistryError, match="no remote"):
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
@@ -18,11 +18,13 @@ if TYPE_CHECKING:
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
_get_registry_url,
|
||||
admin_registry_install,
|
||||
admin_registry_search,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.mcp_registry import (
|
||||
DEFAULT_REGISTRY_URL,
|
||||
MCPRegistryError,
|
||||
RegistryPackage,
|
||||
RegistryRemote,
|
||||
@@ -362,7 +364,7 @@ class TestRegistryInstall:
|
||||
def test_install_max_servers(self, client: TestClient, storage: SQLiteBackend) -> None:
|
||||
import uuid
|
||||
|
||||
for i in range(50):
|
||||
for i in range(200):
|
||||
storage.create_mcp_server(
|
||||
server_id=uuid.uuid4().hex,
|
||||
name=f"server-{i}",
|
||||
@@ -549,3 +551,73 @@ class TestRegistryInstall:
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert "custom 'name'" in resp.json()["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_registry_url fallback chain tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_request(storage: Any = None, config_store: Any = None) -> MagicMock:
|
||||
"""Build a mock Request with app.state.auth_storage and app.state.config_store."""
|
||||
request = MagicMock()
|
||||
request.app.state.auth_storage = storage
|
||||
request.app.state.config_store = config_store
|
||||
return request
|
||||
|
||||
|
||||
class TestGetRegistryUrl:
|
||||
"""Verify three-tier URL resolution: DB setting -> config.toml -> default."""
|
||||
|
||||
def test_returns_db_setting_when_available(self) -> None:
|
||||
config_store = MagicMock()
|
||||
config_store.get.return_value = "https://custom.registry.example.com"
|
||||
request = _mock_request(config_store=config_store)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
url = _get_registry_url(request)
|
||||
|
||||
assert url == "https://custom.registry.example.com"
|
||||
config_store.get.assert_called_once_with("mcp.registry_url")
|
||||
|
||||
def test_falls_back_to_config_when_config_store_returns_empty(self) -> None:
|
||||
config_store = MagicMock()
|
||||
config_store.get.return_value = ""
|
||||
request = _mock_request(config_store=config_store)
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"registry_url": "https://config.registry.example.com"},
|
||||
):
|
||||
url = _get_registry_url(request)
|
||||
|
||||
assert url == "https://config.registry.example.com"
|
||||
|
||||
def test_falls_back_to_config_when_no_config_store(self) -> None:
|
||||
request = _mock_request()
|
||||
|
||||
with patch(
|
||||
"turnstone.core.config.load_config",
|
||||
return_value={"registry_url": "https://config.registry.example.com"},
|
||||
):
|
||||
url = _get_registry_url(request)
|
||||
|
||||
assert url == "https://config.registry.example.com"
|
||||
|
||||
def test_falls_back_to_default_when_both_unavailable(self) -> None:
|
||||
config_store = MagicMock()
|
||||
config_store.get.return_value = ""
|
||||
request = _mock_request(config_store=config_store)
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
url = _get_registry_url(request)
|
||||
|
||||
assert url == DEFAULT_REGISTRY_URL
|
||||
|
||||
def test_falls_back_to_default_when_no_config_store_or_config(self) -> None:
|
||||
request = _mock_request()
|
||||
|
||||
with patch("turnstone.core.config.load_config", return_value={}):
|
||||
url = _get_registry_url(request)
|
||||
|
||||
assert url == DEFAULT_REGISTRY_URL
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
def _make_id() -> str:
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from turnstone.core.model_registry import (
|
||||
ModelConfig,
|
||||
ModelRegistry,
|
||||
detect_model,
|
||||
load_model_registry,
|
||||
)
|
||||
|
||||
@@ -590,3 +591,36 @@ class TestProtocolModel:
|
||||
assert isinstance(restored, CreateWorkstreamMessage)
|
||||
assert restored.model == "local"
|
||||
assert restored.name == "ws1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_model — startup timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectModelTimeout:
|
||||
def test_uses_short_timeout_and_no_retries(self) -> None:
|
||||
"""detect_model() uses with_options(timeout=10, max_retries=0)."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = "test-model"
|
||||
mock_model.owned_by = "test"
|
||||
|
||||
fast_client = MagicMock()
|
||||
fast_client.models.list.return_value = MagicMock(data=[mock_model])
|
||||
|
||||
client = MagicMock()
|
||||
client.with_options.return_value = fast_client
|
||||
|
||||
result = detect_model(client, provider="openai")
|
||||
client.with_options.assert_called_once_with(timeout=10.0, max_retries=0)
|
||||
fast_client.models.list.assert_called_once()
|
||||
assert result[0] == "test-model"
|
||||
|
||||
def test_connection_error_non_fatal(self) -> None:
|
||||
"""detect_model(fatal=False) returns (None, None) on connection error."""
|
||||
client = MagicMock()
|
||||
client.with_options.return_value = client
|
||||
client.models.list.side_effect = OSError("Connection refused")
|
||||
|
||||
result = detect_model(client, provider="openai", fatal=False)
|
||||
assert result == (None, None)
|
||||
|
||||
+172
-3
@@ -22,6 +22,7 @@ from turnstone.core.oidc import (
|
||||
load_oidc_config,
|
||||
provision_oidc_user,
|
||||
validate_id_token,
|
||||
validate_issuer_url,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -293,6 +294,162 @@ class TestLoadOIDCConfig:
|
||||
assert cfg.redirect_base == "http://localhost:8000"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateIssuerURL:
|
||||
"""Tests for ``validate_issuer_url`` SSRF protection."""
|
||||
|
||||
def test_valid_https_url(self):
|
||||
"""Public HTTPS issuer URL passes validation."""
|
||||
# Should not raise -- mock DNS to return a public IP.
|
||||
with patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
],
|
||||
):
|
||||
validate_issuer_url("https://idp.example.com")
|
||||
|
||||
def test_rejects_http_non_localhost(self):
|
||||
"""HTTP is rejected for non-localhost hosts."""
|
||||
with pytest.raises(OIDCError, match="must use HTTPS"):
|
||||
validate_issuer_url("http://idp.example.com")
|
||||
|
||||
def test_allows_http_localhost(self):
|
||||
"""HTTP is allowed for localhost (development)."""
|
||||
with patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
],
|
||||
):
|
||||
validate_issuer_url("http://localhost:8080")
|
||||
|
||||
def test_allows_http_localhost_subdomain(self):
|
||||
"""HTTP is allowed for *.localhost subdomains."""
|
||||
with patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("127.0.0.1", 0)),
|
||||
],
|
||||
):
|
||||
validate_issuer_url("http://keycloak.localhost:8080")
|
||||
|
||||
def test_rejects_embedded_credentials(self):
|
||||
"""URLs with userinfo (user:pass@host) are rejected."""
|
||||
with pytest.raises(OIDCError, match="embedded credentials"):
|
||||
validate_issuer_url("https://admin:secret@idp.example.com")
|
||||
|
||||
def test_rejects_username_only(self):
|
||||
"""URLs with just a username are rejected."""
|
||||
with pytest.raises(OIDCError, match="embedded credentials"):
|
||||
validate_issuer_url("https://admin@idp.example.com")
|
||||
|
||||
def test_rejects_no_hostname(self):
|
||||
"""URLs without a hostname are rejected."""
|
||||
with pytest.raises(OIDCError, match="no hostname"):
|
||||
validate_issuer_url("https://")
|
||||
|
||||
def test_rejects_private_10_range(self):
|
||||
"""Hostnames resolving to 10.x.x.x are rejected."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.1", 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*10.0.0.1"),
|
||||
):
|
||||
validate_issuer_url("https://internal.corp.example.com")
|
||||
|
||||
def test_rejects_private_172_range(self):
|
||||
"""Hostnames resolving to 172.16-31.x.x are rejected."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("172.16.0.1", 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*172.16.0.1"),
|
||||
):
|
||||
validate_issuer_url("https://internal.corp.example.com")
|
||||
|
||||
def test_rejects_private_192_168_range(self):
|
||||
"""Hostnames resolving to 192.168.x.x are rejected."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("192.168.1.1", 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*192.168.1.1"),
|
||||
):
|
||||
validate_issuer_url("https://internal.corp.example.com")
|
||||
|
||||
def test_rejects_loopback_127(self):
|
||||
"""Hostnames resolving to 127.x.x.x are rejected (non-localhost host)."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("127.0.0.1", 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*127.0.0.1"),
|
||||
):
|
||||
validate_issuer_url("https://evil.example.com")
|
||||
|
||||
def test_rejects_ipv6_loopback(self):
|
||||
"""Hostnames resolving to ::1 are rejected (non-localhost host)."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(10, 1, 6, "", ("::1", 0, 0, 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*::1"),
|
||||
):
|
||||
validate_issuer_url("https://evil.example.com")
|
||||
|
||||
def test_rejects_ipv6_private(self):
|
||||
"""Hostnames resolving to fc00::/7 are rejected."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(10, 1, 6, "", ("fd00::1", 0, 0, 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*fd00::1"),
|
||||
):
|
||||
validate_issuer_url("https://evil.example.com")
|
||||
|
||||
def test_rejects_link_local(self):
|
||||
"""Hostnames resolving to link-local addresses are rejected."""
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
|
||||
pytest.raises(OIDCError, match="non-public address.*169.254.169.254"),
|
||||
):
|
||||
validate_issuer_url("https://metadata.internal")
|
||||
|
||||
def test_rejects_unresolvable_hostname(self):
|
||||
"""DNS resolution failure is rejected."""
|
||||
import socket as _socket
|
||||
|
||||
with (
|
||||
patch("socket.getaddrinfo", side_effect=_socket.gaierror("not found")),
|
||||
pytest.raises(OIDCError, match="cannot be resolved"),
|
||||
):
|
||||
validate_issuer_url("https://nonexistent.invalid")
|
||||
|
||||
def test_rejects_mixed_addresses(self):
|
||||
"""If any resolved address is private, the URL is rejected."""
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
(2, 1, 6, "", ("10.0.0.1", 0)),
|
||||
],
|
||||
),
|
||||
pytest.raises(OIDCError, match="non-public address.*10.0.0.1"),
|
||||
):
|
||||
validate_issuer_url("https://dual-homed.example.com")
|
||||
|
||||
def test_discover_rejects_ssrf(self):
|
||||
"""discover_oidc returns enabled=False when issuer URL fails SSRF check."""
|
||||
config = _make_config(
|
||||
issuer="http://10.0.0.1:8080",
|
||||
authorization_endpoint="",
|
||||
token_endpoint="",
|
||||
userinfo_endpoint="",
|
||||
jwks_uri="",
|
||||
)
|
||||
|
||||
async def _run():
|
||||
result = await discover_oidc(config)
|
||||
assert result.enabled is False
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect URI Builder
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -869,6 +1026,9 @@ class TestApplyRoleMapping:
|
||||
|
||||
|
||||
class TestDiscoverOIDC:
|
||||
# Mock DNS result for a public IP — reused across discovery tests.
|
||||
_PUBLIC_ADDR = [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
|
||||
def test_discover_oidc_success(self):
|
||||
"""Mock httpx response, verify endpoints populated."""
|
||||
config = _make_config(
|
||||
@@ -891,7 +1051,10 @@ class TestDiscoverOIDC:
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.authorization_endpoint == "https://idp.example.com/authorize"
|
||||
@@ -916,7 +1079,10 @@ class TestDiscoverOIDC:
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(_failing_get)
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is False
|
||||
@@ -954,7 +1120,10 @@ class TestDiscoverOIDC:
|
||||
|
||||
async def _run():
|
||||
client = _mock_async_client(lambda url: _async_return(mock_response))
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
with (
|
||||
patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
):
|
||||
result = await discover_oidc(config)
|
||||
|
||||
assert result.enabled is False
|
||||
|
||||
@@ -131,7 +131,7 @@ def authorize_client(storage: SQLiteBackend, oidc_config: OIDCConfig) -> TestCli
|
||||
)
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "test-jwt-secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
@@ -468,7 +468,7 @@ class TestOIDCCallback:
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = backend
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
|
||||
@@ -492,7 +492,7 @@ class TestOIDCCallback:
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
limiter = LoginRateLimiter(max_attempts=1, window_seconds=300)
|
||||
limiter.record("ip:testclient")
|
||||
|
||||
@@ -6,15 +6,6 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC Identity CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,16 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
def _make_assessment_kwargs(**overrides):
|
||||
"""Build default kwargs for record_output_assessment."""
|
||||
|
||||
@@ -4,17 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
backend = SQLiteBackend(str(tmp_path / "test.db"))
|
||||
return backend
|
||||
|
||||
|
||||
def _make_task_kwargs(**overrides):
|
||||
"""Build default kwargs for create_scheduled_task."""
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Integration tests for SDK governance methods against a real Starlette app.
|
||||
|
||||
Verifies round-trip serialization: SDK -> HTTP -> Starlette handler -> storage
|
||||
-> JSON response -> Pydantic model validation in the SDK client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.api.console_schemas import (
|
||||
ListOrgsResponse,
|
||||
ListRolesResponse,
|
||||
ListToolPoliciesResponse,
|
||||
OrgInfo,
|
||||
RoleInfo,
|
||||
ToolPolicyInfo,
|
||||
)
|
||||
from turnstone.api.schemas import StatusResponse
|
||||
from turnstone.console.server import (
|
||||
admin_create_policy,
|
||||
admin_create_role,
|
||||
admin_delete_policy,
|
||||
admin_delete_role,
|
||||
admin_get_org,
|
||||
admin_list_orgs,
|
||||
admin_list_policies,
|
||||
admin_list_roles,
|
||||
admin_update_org,
|
||||
admin_update_policy,
|
||||
admin_update_role,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
from turnstone.sdk.console import AsyncTurnstoneConsole
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth bypass middleware — injects a full-access AuthResult on every request.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-admin",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset(
|
||||
{
|
||||
"read",
|
||||
"write",
|
||||
"approve",
|
||||
"admin.roles",
|
||||
"admin.orgs",
|
||||
"admin.policies",
|
||||
}
|
||||
),
|
||||
)
|
||||
resp: Response = await call_next(request)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app() -> Starlette:
|
||||
return Starlette(
|
||||
routes=[
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
# Roles
|
||||
Route("/api/admin/roles", admin_list_roles),
|
||||
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
|
||||
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
|
||||
# Orgs
|
||||
Route("/api/admin/orgs", admin_list_orgs),
|
||||
Route("/api/admin/orgs/{org_id}", admin_get_org),
|
||||
Route("/api/admin/orgs/{org_id}", admin_update_org, methods=["PUT"]),
|
||||
# Policies
|
||||
Route("/api/admin/policies", admin_list_policies),
|
||||
Route("/api/admin/policies", admin_create_policy, methods=["POST"]),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_update_policy,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/policies/{policy_id}",
|
||||
admin_delete_policy,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Any) -> SQLiteBackend:
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sdk_client(storage: SQLiteBackend):
|
||||
"""SDK client wired to a real Starlette app via ASGITransport."""
|
||||
app = _make_app()
|
||||
app.state.auth_storage = storage
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as hc:
|
||||
yield AsyncTurnstoneConsole(httpx_client=hc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Roles round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRolesRoundTrip:
|
||||
@pytest.mark.anyio
|
||||
async def test_list_roles_empty(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
resp = await sdk_client.list_roles()
|
||||
assert isinstance(resp, ListRolesResponse)
|
||||
assert resp.roles == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_list_role(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
role = await sdk_client.create_role(
|
||||
"analyst", display_name="Data Analyst", permissions="read,write"
|
||||
)
|
||||
assert isinstance(role, RoleInfo)
|
||||
assert role.name == "analyst"
|
||||
assert role.display_name == "Data Analyst"
|
||||
assert role.permissions == "read,write"
|
||||
assert role.builtin is False
|
||||
assert role.role_id # non-empty
|
||||
|
||||
# List should now contain the new role
|
||||
resp = await sdk_client.list_roles()
|
||||
assert len(resp.roles) == 1
|
||||
assert resp.roles[0].role_id == role.role_id
|
||||
assert resp.roles[0].name == "analyst"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_update_role(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
role = await sdk_client.create_role("ops", permissions="read")
|
||||
assert role.permissions == "read"
|
||||
|
||||
updated = await sdk_client.update_role(
|
||||
role.role_id, display_name="Operations", permissions="read,write,approve"
|
||||
)
|
||||
assert isinstance(updated, RoleInfo)
|
||||
assert updated.display_name == "Operations"
|
||||
assert updated.permissions == "read,write,approve"
|
||||
assert updated.role_id == role.role_id
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_delete_role(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
role = await sdk_client.create_role("temp-role", permissions="read")
|
||||
|
||||
result = await sdk_client.delete_role(role.role_id)
|
||||
assert isinstance(result, StatusResponse)
|
||||
assert result.status == "ok"
|
||||
|
||||
# Verify gone
|
||||
resp = await sdk_client.list_roles()
|
||||
assert resp.roles == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_lifecycle(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
"""Create -> list -> update -> list -> delete -> list."""
|
||||
# Create
|
||||
role = await sdk_client.create_role(
|
||||
"lifecycle", display_name="Lifecycle", permissions="read"
|
||||
)
|
||||
role_id = role.role_id
|
||||
|
||||
# List confirms creation
|
||||
roles = (await sdk_client.list_roles()).roles
|
||||
assert len(roles) == 1
|
||||
assert roles[0].role_id == role_id
|
||||
|
||||
# Update
|
||||
updated = await sdk_client.update_role(role_id, permissions="read,write")
|
||||
assert updated.permissions == "read,write"
|
||||
|
||||
# List still has one
|
||||
roles = (await sdk_client.list_roles()).roles
|
||||
assert len(roles) == 1
|
||||
assert roles[0].permissions == "read,write"
|
||||
|
||||
# Delete
|
||||
await sdk_client.delete_role(role_id)
|
||||
|
||||
# List is empty
|
||||
roles = (await sdk_client.list_roles()).roles
|
||||
assert roles == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_nonexistent_role_raises(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.delete_role("nonexistent")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_nonexistent_role_raises(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.update_role("nonexistent", display_name="Nope")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Policies round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPoliciesRoundTrip:
|
||||
@pytest.mark.anyio
|
||||
async def test_list_policies_empty(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
resp = await sdk_client.list_policies()
|
||||
assert isinstance(resp, ListToolPoliciesResponse)
|
||||
assert resp.policies == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_and_list_policy(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
policy = await sdk_client.create_policy("Allow bash", "bash_*", "allow", priority=10)
|
||||
assert isinstance(policy, ToolPolicyInfo)
|
||||
assert policy.name == "Allow bash"
|
||||
assert policy.tool_pattern == "bash_*"
|
||||
assert policy.action == "allow"
|
||||
assert policy.priority == 10
|
||||
assert policy.enabled is True
|
||||
assert policy.policy_id # non-empty
|
||||
|
||||
resp = await sdk_client.list_policies()
|
||||
assert len(resp.policies) == 1
|
||||
assert resp.policies[0].policy_id == policy.policy_id
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_update_policy(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
policy = await sdk_client.create_policy("Deny write", "write_*", "deny", priority=5)
|
||||
|
||||
updated = await sdk_client.update_policy(
|
||||
policy.policy_id, name="Allow write", action="allow", priority=20
|
||||
)
|
||||
assert isinstance(updated, ToolPolicyInfo)
|
||||
assert updated.name == "Allow write"
|
||||
assert updated.action == "allow"
|
||||
assert updated.priority == 20
|
||||
assert updated.policy_id == policy.policy_id
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_delete_policy(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
policy = await sdk_client.create_policy("Temp policy", "temp_*", "ask")
|
||||
|
||||
result = await sdk_client.delete_policy(policy.policy_id)
|
||||
assert isinstance(result, StatusResponse)
|
||||
assert result.status == "ok"
|
||||
|
||||
resp = await sdk_client.list_policies()
|
||||
assert resp.policies == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_full_lifecycle(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
"""Create -> list -> update -> list -> delete -> list."""
|
||||
policy = await sdk_client.create_policy("Lifecycle", "test_*", "deny", priority=1)
|
||||
pid = policy.policy_id
|
||||
|
||||
policies = (await sdk_client.list_policies()).policies
|
||||
assert len(policies) == 1
|
||||
|
||||
await sdk_client.update_policy(pid, action="allow", priority=99)
|
||||
policies = (await sdk_client.list_policies()).policies
|
||||
assert policies[0].action == "allow"
|
||||
assert policies[0].priority == 99
|
||||
|
||||
await sdk_client.delete_policy(pid)
|
||||
policies = (await sdk_client.list_policies()).policies
|
||||
assert policies == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_nonexistent_policy_raises(
|
||||
self, sdk_client: AsyncTurnstoneConsole
|
||||
) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.delete_policy("nonexistent")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_nonexistent_policy_raises(
|
||||
self, sdk_client: AsyncTurnstoneConsole
|
||||
) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.update_policy("nonexistent", name="Nope")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_policy_invalid_action_raises(
|
||||
self, sdk_client: AsyncTurnstoneConsole
|
||||
) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.create_policy("Bad", "tool_*", "yolo")
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Orgs round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrgsRoundTrip:
|
||||
@pytest.mark.anyio
|
||||
async def test_list_orgs_empty(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
resp = await sdk_client.list_orgs()
|
||||
assert isinstance(resp, ListOrgsResponse)
|
||||
assert resp.orgs == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_org(self, sdk_client: AsyncTurnstoneConsole, storage: SQLiteBackend) -> None:
|
||||
storage.create_org(
|
||||
org_id="org-1", name="acme", display_name="Acme Corp", settings='{"k": "v"}'
|
||||
)
|
||||
org = await sdk_client.get_org("org-1")
|
||||
assert isinstance(org, OrgInfo)
|
||||
assert org.org_id == "org-1"
|
||||
assert org.name == "acme"
|
||||
assert org.display_name == "Acme Corp"
|
||||
assert org.settings == '{"k": "v"}'
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_orgs_after_seed(
|
||||
self, sdk_client: AsyncTurnstoneConsole, storage: SQLiteBackend
|
||||
) -> None:
|
||||
storage.create_org(org_id="org-a", name="alpha", display_name="Alpha")
|
||||
storage.create_org(org_id="org-b", name="beta", display_name="Beta")
|
||||
|
||||
resp = await sdk_client.list_orgs()
|
||||
assert len(resp.orgs) == 2
|
||||
names = {o.name for o in resp.orgs}
|
||||
assert names == {"alpha", "beta"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_org(
|
||||
self, sdk_client: AsyncTurnstoneConsole, storage: SQLiteBackend
|
||||
) -> None:
|
||||
storage.create_org(org_id="org-1", name="acme", display_name="Acme Corp")
|
||||
|
||||
updated = await sdk_client.update_org("org-1", display_name="Acme Inc.")
|
||||
assert isinstance(updated, OrgInfo)
|
||||
assert updated.display_name == "Acme Inc."
|
||||
assert updated.org_id == "org-1"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_nonexistent_org_raises(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.get_org("nonexistent")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_nonexistent_org_raises(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
from turnstone.sdk._types import TurnstoneAPIError
|
||||
|
||||
with pytest.raises(TurnstoneAPIError) as exc_info:
|
||||
await sdk_client.update_org("nonexistent", display_name="Nope")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Pydantic model field validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelValidation:
|
||||
"""Verify that all expected fields are populated and correctly typed."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_role_info_fields(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
role = await sdk_client.create_role("reviewer", permissions="read")
|
||||
assert isinstance(role.role_id, str)
|
||||
assert isinstance(role.name, str)
|
||||
assert isinstance(role.display_name, str)
|
||||
assert isinstance(role.permissions, str)
|
||||
assert isinstance(role.builtin, bool)
|
||||
assert isinstance(role.org_id, str)
|
||||
assert isinstance(role.created, str)
|
||||
assert isinstance(role.updated, str)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_policy_info_fields(self, sdk_client: AsyncTurnstoneConsole) -> None:
|
||||
policy = await sdk_client.create_policy("Test", "read_*", "allow", priority=5)
|
||||
assert isinstance(policy.policy_id, str)
|
||||
assert isinstance(policy.name, str)
|
||||
assert isinstance(policy.tool_pattern, str)
|
||||
assert isinstance(policy.action, str)
|
||||
assert isinstance(policy.priority, int)
|
||||
assert isinstance(policy.org_id, str)
|
||||
assert isinstance(policy.enabled, bool)
|
||||
assert isinstance(policy.created_by, str)
|
||||
assert isinstance(policy.created, str)
|
||||
assert isinstance(policy.updated, str)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_org_info_fields(
|
||||
self, sdk_client: AsyncTurnstoneConsole, storage: SQLiteBackend
|
||||
) -> None:
|
||||
storage.create_org(org_id="org-v", name="validate", display_name="Validate")
|
||||
org = await sdk_client.get_org("org-v")
|
||||
assert isinstance(org.org_id, str)
|
||||
assert isinstance(org.name, str)
|
||||
assert isinstance(org.display_name, str)
|
||||
assert isinstance(org.settings, str)
|
||||
assert isinstance(org.created, str)
|
||||
assert isinstance(org.updated, str)
|
||||
@@ -623,6 +623,7 @@ class TestServerHealthMetrics:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -799,6 +800,7 @@ class TestServerRateLimiting:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestServiceRegistry:
|
||||
def test_register_and_list(self, storage):
|
||||
|
||||
@@ -753,3 +753,268 @@ class TestGetCapabilitiesOverride:
|
||||
caps = session._get_capabilities()
|
||||
# Default OpenAI provider for unknown model → no vision
|
||||
assert caps.supports_vision is False
|
||||
|
||||
|
||||
class TestTitleRetry:
|
||||
"""_generate_title resets _title_generated on failure."""
|
||||
|
||||
def test_title_generated_reset_on_failure(self, tmp_db):
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
# Mock provider to raise
|
||||
session._provider = MagicMock()
|
||||
session._provider.create_completion.side_effect = RuntimeError("API error")
|
||||
|
||||
session._generate_title()
|
||||
|
||||
assert session._title_generated is False
|
||||
|
||||
def test_title_generated_stays_true_on_success(self, tmp_db):
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
with patch("turnstone.core.session.update_workstream_title"):
|
||||
session._generate_title()
|
||||
|
||||
# Flag stays True after successful generation
|
||||
assert session._title_generated is True
|
||||
|
||||
def test_title_skipped_after_resume_changes_ws_id(self, tmp_db):
|
||||
"""If ws_id changes (via resume) during title generation, discard the result."""
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
original_ws_id = session._ws_id
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
# Simulate resume() changing ws_id while title generation is in flight
|
||||
def _change_ws_id(*args, **kwargs):
|
||||
session._ws_id = "different-ws-id"
|
||||
return result
|
||||
|
||||
session._provider.create_completion.side_effect = _change_ws_id
|
||||
|
||||
with patch("turnstone.core.session.update_workstream_title") as mock_update:
|
||||
session._generate_title()
|
||||
|
||||
# Title should NOT be applied to the new workstream
|
||||
mock_update.assert_not_called()
|
||||
# Restore for cleanup
|
||||
session._ws_id = original_ws_id
|
||||
|
||||
|
||||
class TestLiveConfigUpdate:
|
||||
"""ConfigStore-backed sessions pick up settings changes at point-of-use."""
|
||||
|
||||
def test_memory_config_reads_from_config_store(self, tmp_db):
|
||||
"""_mem_cfg returns live values from ConfigStore when present."""
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
storage = SQLiteBackend(str(tmp_db), create_tables=True)
|
||||
cs = ConfigStore(storage)
|
||||
session = _make_session(config_store=cs)
|
||||
|
||||
# Default: relevance_k=5
|
||||
assert session._mem_cfg.relevance_k == 5
|
||||
|
||||
# Admin changes the setting
|
||||
cs.set("memory.relevance_k", 10, changed_by="test")
|
||||
assert session._mem_cfg.relevance_k == 10
|
||||
|
||||
def test_judge_config_reads_from_config_store(self, tmp_db):
|
||||
"""_judge_cfg returns live behavioral flags from ConfigStore."""
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
storage = SQLiteBackend(str(tmp_db), create_tables=True)
|
||||
cs = ConfigStore(storage)
|
||||
session = _make_session(
|
||||
judge_config=JudgeConfig(),
|
||||
config_store=cs,
|
||||
)
|
||||
|
||||
# Default: enabled=True
|
||||
assert session._judge_cfg.enabled is True
|
||||
|
||||
# Admin disables the judge
|
||||
cs.set("judge.enabled", False, changed_by="test")
|
||||
assert session._judge_cfg.enabled is False
|
||||
|
||||
def test_judge_client_config_stays_frozen(self, tmp_db):
|
||||
"""LLM client fields (model, provider) are frozen from creation time."""
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
storage = SQLiteBackend(str(tmp_db), create_tables=True)
|
||||
cs = ConfigStore(storage)
|
||||
session = _make_session(
|
||||
judge_config=JudgeConfig(model="original-model"),
|
||||
config_store=cs,
|
||||
)
|
||||
|
||||
# Change the model in ConfigStore — should NOT affect the session
|
||||
cs.set("judge.model", "new-model", changed_by="test")
|
||||
assert session._judge_cfg.model == "original-model"
|
||||
|
||||
def test_judge_disable_after_init_stops_future_use(self, tmp_db):
|
||||
"""Disabling judge.enabled after IntentJudge is created returns None."""
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
storage = SQLiteBackend(str(tmp_db), create_tables=True)
|
||||
cs = ConfigStore(storage)
|
||||
session = _make_session(
|
||||
judge_config=JudgeConfig(),
|
||||
config_store=cs,
|
||||
)
|
||||
|
||||
# Force judge initialization by setting a mock
|
||||
session._judge = MagicMock()
|
||||
assert session._ensure_judge() is not None
|
||||
|
||||
# Admin disables the judge — cached instance should NOT be returned
|
||||
cs.set("judge.enabled", False, changed_by="test")
|
||||
assert session._ensure_judge() is None
|
||||
|
||||
def test_fallback_to_frozen_without_config_store(self, tmp_db):
|
||||
"""Without ConfigStore (CLI mode), frozen config is used."""
|
||||
from turnstone.core.memory_relevance import MemoryConfig
|
||||
|
||||
session = _make_session(memory_config=MemoryConfig(relevance_k=3))
|
||||
assert session._mem_cfg.relevance_k == 3
|
||||
|
||||
|
||||
class TestAgentOutputGuard:
|
||||
"""Output guard should evaluate tool results in _run_agent, not just the main loop."""
|
||||
|
||||
def test_agent_loop_calls_evaluate_output(self):
|
||||
"""_run_agent passes tool output through _evaluate_output when output_guard is enabled."""
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
|
||||
session = _make_session(judge_config=JudgeConfig(output_guard=True))
|
||||
|
||||
with patch.object(session, "_evaluate_output", wraps=lambda cid, o, fn: o) as mock_eval:
|
||||
# Simulate _run_agent getting a tool call response then a text response
|
||||
call_count = [0]
|
||||
|
||||
def fake_create(**kwargs):
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
# First call: model returns a tool call
|
||||
choice = MagicMock()
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_1"
|
||||
tc.function.name = "read_file"
|
||||
tc.function.arguments = '{"path": "/tmp/test"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
else:
|
||||
# Second call: model returns text (done)
|
||||
choice = MagicMock()
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "Done"
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
# Mock tool preparation to return a simple output
|
||||
def fake_prepare(tc_dict, **kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "read_file",
|
||||
"needs_approval": False,
|
||||
"execute": lambda p: ("call_1", "file contents with sk-proj-SECRET123"),
|
||||
}
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="test",
|
||||
)
|
||||
|
||||
mock_eval.assert_called_once()
|
||||
args = mock_eval.call_args[0]
|
||||
assert args[0] == "call_1" # call_id
|
||||
assert "sk-proj-SECRET123" in args[1] # output
|
||||
assert args[2] == "read_file" # func_name
|
||||
|
||||
def test_agent_loop_skips_guard_when_disabled(self):
|
||||
"""_run_agent does not call _evaluate_output when output_guard is disabled."""
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
|
||||
session = _make_session(judge_config=JudgeConfig(output_guard=False))
|
||||
|
||||
with patch.object(session, "_evaluate_output") as mock_eval:
|
||||
call_count = [0]
|
||||
|
||||
def fake_create(**kwargs):
|
||||
call_count[0] += 1
|
||||
resp = MagicMock()
|
||||
if call_count[0] == 1:
|
||||
choice = MagicMock()
|
||||
choice.finish_reason = "tool_calls"
|
||||
tc = MagicMock()
|
||||
tc.id = "call_1"
|
||||
tc.function.name = "read_file"
|
||||
tc.function.arguments = '{"path": "/tmp/test"}'
|
||||
choice.message.tool_calls = [tc]
|
||||
choice.message.content = None
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
else:
|
||||
choice = MagicMock()
|
||||
choice.finish_reason = "stop"
|
||||
choice.message.tool_calls = None
|
||||
choice.message.content = "Done"
|
||||
resp.choices = [choice]
|
||||
resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
|
||||
return resp
|
||||
|
||||
session.client.chat.completions.create = fake_create
|
||||
|
||||
def fake_prepare(tc_dict, **kwargs):
|
||||
return {
|
||||
"call_id": tc_dict["id"],
|
||||
"func_name": "read_file",
|
||||
"needs_approval": False,
|
||||
"execute": lambda p: ("call_1", "safe output"),
|
||||
}
|
||||
|
||||
with patch.object(session, "_prepare_tool", side_effect=fake_prepare):
|
||||
session._run_agent(
|
||||
[{"role": "user", "content": "test"}],
|
||||
tools=[{"type": "function", "function": {"name": "read_file"}}],
|
||||
label="test",
|
||||
)
|
||||
|
||||
mock_eval.assert_not_called()
|
||||
|
||||
@@ -607,3 +607,150 @@ class TestPruneWorkstreams:
|
||||
# Config rows should be cleaned up
|
||||
assert load_workstream_config("orphan_cfg") == {}
|
||||
assert load_workstream_config("stale_cfg") == {}
|
||||
|
||||
|
||||
# ── Parallel tool exception isolation ────────────────────────────────
|
||||
|
||||
|
||||
class TestParallelToolExceptionIsolation:
|
||||
"""Bug #117: one tool raising should not kill the entire batch."""
|
||||
|
||||
def test_exception_in_one_tool_does_not_kill_batch(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="test-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
def succeed(item):
|
||||
return item["call_id"], "ok"
|
||||
|
||||
def fail(item):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
items = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"func_name": "bash",
|
||||
"execute": succeed,
|
||||
"needs_approval": False,
|
||||
"header": "test",
|
||||
"preview": "",
|
||||
},
|
||||
{
|
||||
"call_id": "c2",
|
||||
"func_name": "math",
|
||||
"execute": fail,
|
||||
"needs_approval": False,
|
||||
"header": "test",
|
||||
"preview": "",
|
||||
},
|
||||
]
|
||||
|
||||
tool_calls = [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "math", "arguments": "{}"}},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(session, "_prepare_tool", side_effect=items),
|
||||
patch.object(session, "_evaluate_intent"),
|
||||
patch.object(session, "_emit_state"),
|
||||
patch.object(session, "_init_system_messages"),
|
||||
patch.object(session, "_check_cancelled"),
|
||||
):
|
||||
session.ui.approve_tools.return_value = (True, None)
|
||||
results, _ = session._execute_tools(tool_calls)
|
||||
|
||||
assert results[0] == ("c1", "ok")
|
||||
assert results[1][0] == "c2"
|
||||
assert "Error executing math" in results[1][1]
|
||||
assert "boom" in results[1][1]
|
||||
|
||||
|
||||
# ── Web search tool gating ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebSearchGating:
|
||||
"""Bug #117: web_search should not be offered without a backend."""
|
||||
|
||||
def test_web_search_filtered_when_no_backend(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=False)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" not in names
|
||||
|
||||
def test_web_search_kept_when_tavily_available(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=False)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value="tvly-test-key"),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
def test_web_search_kept_when_native_support(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="gpt-5-search-api",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=True)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
@@ -179,7 +179,10 @@ class TestDeleteSetting:
|
||||
# Delete it
|
||||
r = client.delete("/v1/api/admin/settings/tools.timeout")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["key"] == "tools.timeout"
|
||||
assert body["default"] == 120 # registry default for tools.timeout
|
||||
|
||||
def test_delete_then_list_shows_default(self, client):
|
||||
client.put(
|
||||
|
||||
+3
-3
@@ -74,7 +74,7 @@ class TestSimEngine:
|
||||
|
||||
def test_llm_response_returns_content(self, engine):
|
||||
async def _test():
|
||||
content, tool_calls = await engine.simulate_llm_response(True, 1)
|
||||
content, tool_calls = await engine.simulate_llm_response(True)
|
||||
assert isinstance(content, str)
|
||||
assert len(content) > 0
|
||||
assert isinstance(tool_calls, list)
|
||||
@@ -85,8 +85,8 @@ class TestSimEngine:
|
||||
async def _test():
|
||||
e1 = SimEngine(fast_config, rng=random.Random(123))
|
||||
e2 = SimEngine(fast_config, rng=random.Random(123))
|
||||
c1, t1 = await e1.simulate_llm_response(True, 1)
|
||||
c2, t2 = await e2.simulate_llm_response(True, 1)
|
||||
c1, t1 = await e1.simulate_llm_response(True)
|
||||
c2, t2 = await e2.simulate_llm_response(True)
|
||||
assert c1 == c2
|
||||
assert len(t1) == len(t2)
|
||||
|
||||
|
||||
+316
-6
@@ -18,7 +18,7 @@ description: Automated code review skill
|
||||
author: Test Author
|
||||
version: 2.0.0
|
||||
tags: [python, review, quality]
|
||||
allowed_tools: [read_file, list_directory]
|
||||
allowed-tools: [read_file, list_directory]
|
||||
license: MIT
|
||||
compatibility: ">=0.7"
|
||||
---
|
||||
@@ -187,13 +187,13 @@ Content.
|
||||
|
||||
|
||||
class TestAllowedTools:
|
||||
"""Verify allowed_tools parsing."""
|
||||
"""Verify allowed-tools parsing (Agent Skills standard hyphenated field)."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: tools-list
|
||||
allowed_tools: [bash, read_file]
|
||||
allowed-tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -201,11 +201,12 @@ Content.
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["bash", "read_file"]
|
||||
|
||||
def test_csv_format(self) -> None:
|
||||
def test_space_delimited_format(self) -> None:
|
||||
"""Standard format per Agent Skills spec."""
|
||||
raw = """\
|
||||
---
|
||||
name: tools-csv
|
||||
allowed_tools: "bash, read_file, write_file"
|
||||
name: tools-space
|
||||
allowed-tools: "bash read_file write_file"
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -219,6 +220,19 @@ Content.
|
||||
name: no-tools
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == []
|
||||
|
||||
def test_underscore_key_not_read(self) -> None:
|
||||
"""allowed_tools (underscore) is not a SKILL.md field — ignored by parser."""
|
||||
raw = """\
|
||||
---
|
||||
name: legacy-key
|
||||
allowed_tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
@@ -247,3 +261,299 @@ class TestValidateSkillName:
|
||||
assert validate_skill_name("HAS-UPPER") is not None
|
||||
assert validate_skill_name("has space") is not None
|
||||
assert validate_skill_name("-leading-hyphen") is not None
|
||||
|
||||
def test_consecutive_hyphens_rejected(self) -> None:
|
||||
"""Agent Skills spec: consecutive hyphens not allowed."""
|
||||
assert validate_skill_name("foo--bar") is not None
|
||||
assert "consecutive hyphens" in (validate_skill_name("a--b") or "")
|
||||
# Single hyphens are fine
|
||||
assert validate_skill_name("foo-bar") is None
|
||||
|
||||
|
||||
# -- Agent Skills Standard Compliance Tests -----------------------------------
|
||||
|
||||
|
||||
class TestStandardAllowedTools:
|
||||
"""Agent Skills spec: 'allowed-tools' (hyphenated), space-delimited."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: standard-tools
|
||||
allowed-tools: ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_space_delimited(self) -> None:
|
||||
"""Standard format: space-delimited string."""
|
||||
raw = """\
|
||||
---
|
||||
name: space-tools
|
||||
allowed-tools: "Bash(git:*) Bash(jq:*) Read"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_mixed_space_comma_delimiters(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: mixed-delim
|
||||
allowed-tools: "Read, Write Bash"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Read", "Write", "Bash"]
|
||||
|
||||
|
||||
class TestStandardMetadataNesting:
|
||||
"""Standard puts author/version under metadata map."""
|
||||
|
||||
def test_metadata_author(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: nested-author
|
||||
description: Test skill
|
||||
metadata:
|
||||
author: example-org
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "example-org"
|
||||
assert result.version == "2.0"
|
||||
|
||||
def test_top_level_takes_precedence(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: precedence
|
||||
description: Test skill
|
||||
author: top-level
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
author: nested
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "top-level"
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_metadata_version_only(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: version-only
|
||||
description: Test
|
||||
metadata:
|
||||
version: "3.5.1"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "3.5.1"
|
||||
assert result.author == ""
|
||||
|
||||
def test_null_author_uses_default(self) -> None:
|
||||
"""YAML null/bare key must not produce the string 'None'."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-author
|
||||
description: Test
|
||||
author:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == ""
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_null_version_uses_default(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: null-version
|
||||
description: Test
|
||||
version:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_null_description_falls_back_to_body(self) -> None:
|
||||
"""YAML null description must not produce 'None' string."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-desc
|
||||
description:
|
||||
---
|
||||
|
||||
First paragraph here.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.description == "First paragraph here."
|
||||
assert "None" not in result.description
|
||||
|
||||
def test_null_license_and_compatibility(self) -> None:
|
||||
"""YAML null license/compatibility must not produce 'None' string."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-fields
|
||||
description: Test
|
||||
license:
|
||||
compatibility:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.license == ""
|
||||
assert result.compatibility == ""
|
||||
|
||||
|
||||
class TestStandardFieldLengths:
|
||||
"""Spec caps: description <= 1024, compatibility <= 500."""
|
||||
|
||||
def test_description_truncated_at_1024(self) -> None:
|
||||
long_desc = "x" * 1200
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-desc
|
||||
description: "{long_desc}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.description) == 1024
|
||||
|
||||
def test_compatibility_truncated_at_500(self) -> None:
|
||||
long_compat = "y" * 600
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-compat
|
||||
description: Short
|
||||
compatibility: "{long_compat}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.compatibility) == 500
|
||||
|
||||
def test_short_fields_unchanged(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: short
|
||||
description: Brief
|
||||
compatibility: Requires git
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.description == "Brief"
|
||||
assert result.compatibility == "Requires git"
|
||||
|
||||
|
||||
class TestLenientMode:
|
||||
"""Lenient parsing for cross-client skill ingestion."""
|
||||
|
||||
def test_invalid_name_sanitized(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert result.name == "invalidname"
|
||||
|
||||
def test_unsalvageable_name_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: "!!!"
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_missing_description_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: no-desc
|
||||
---
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_broken_yaml_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: [broken: yaml: {{{
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_malformed_yaml_colon_in_description_recovers(self) -> None:
|
||||
"""Standard recommends retrying unquoted colon values."""
|
||||
raw = """\
|
||||
---
|
||||
name: colon-desc
|
||||
description: Use this skill when: the user asks about PDFs
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
# The frontmatter library may parse this fine, but if not,
|
||||
# the retry mechanism should recover.
|
||||
assert result is not None
|
||||
assert result.name == "colon-desc"
|
||||
assert "PDF" in result.description
|
||||
|
||||
def test_strict_mode_still_raises(self) -> None:
|
||||
"""Default strict mode unchanged."""
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
parse_skill_md(raw)
|
||||
|
||||
def test_consecutive_hyphens_lenient(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: foo--bar
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert "--" not in result.name
|
||||
|
||||
@@ -4,16 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestDeleteSkillResourceByPath:
|
||||
def test_delete_existing(self, storage):
|
||||
|
||||
+469
-5
@@ -132,6 +132,7 @@ def _create_template(db, template_id, name, content, **kwargs):
|
||||
notify_on_complete=kwargs.get("notify_on_complete", "{}"),
|
||||
enabled=kwargs.get("enabled", True),
|
||||
allowed_tools=kwargs.get("allowed_tools", "[]"),
|
||||
priority=kwargs.get("priority", 0),
|
||||
)
|
||||
|
||||
|
||||
@@ -293,7 +294,7 @@ class TestSkillStorage:
|
||||
assert result == []
|
||||
|
||||
def test_list_skills_by_activation_ordered_by_name(self, db):
|
||||
"""Results are ordered by name ascending."""
|
||||
"""Results are ordered by name ascending when priority is equal."""
|
||||
_create_template(db, "s2", "beta-search", "B", activation="search")
|
||||
_create_template(db, "s1", "alpha-search", "A", activation="search")
|
||||
results = db.list_skills_by_activation("search")
|
||||
@@ -301,6 +302,51 @@ class TestSkillStorage:
|
||||
assert results[0]["name"] == "alpha-search"
|
||||
assert results[1]["name"] == "beta-search"
|
||||
|
||||
def test_list_skills_by_activation_ordered_by_priority(self, db):
|
||||
"""Results are ordered by priority ascending, then name."""
|
||||
_create_template(db, "s1", "style", "S", activation="default", priority=20)
|
||||
_create_template(db, "s2", "safety", "F", activation="default", priority=10)
|
||||
_create_template(db, "s3", "tone", "T", activation="default", priority=10)
|
||||
results = db.list_skills_by_activation("default")
|
||||
assert len(results) == 3
|
||||
assert results[0]["name"] == "safety"
|
||||
assert results[1]["name"] == "tone"
|
||||
assert results[2]["name"] == "style"
|
||||
|
||||
def test_priority_default_is_zero(self, db):
|
||||
"""Priority defaults to 0 when not specified."""
|
||||
_create_template(db, "s1", "skill", "content")
|
||||
tpl = db.get_prompt_template("s1")
|
||||
assert tpl is not None
|
||||
assert tpl["priority"] == 0
|
||||
|
||||
def test_priority_roundtrip(self, db):
|
||||
"""Priority can be set on create and retrieved."""
|
||||
_create_template(db, "s1", "skill", "content", priority=42)
|
||||
tpl = db.get_prompt_template("s1")
|
||||
assert tpl is not None
|
||||
assert tpl["priority"] == 42
|
||||
|
||||
def test_priority_update(self, db):
|
||||
"""Priority can be updated."""
|
||||
_create_template(db, "s1", "skill", "content", priority=10)
|
||||
db.update_prompt_template("s1", priority=99)
|
||||
tpl = db.get_prompt_template("s1")
|
||||
assert tpl is not None
|
||||
assert tpl["priority"] == 99
|
||||
|
||||
def test_list_default_templates_ordered_by_priority(self, db):
|
||||
"""list_default_templates() respects priority ordering."""
|
||||
_create_template(db, "s1", "beta", "b", activation="default", priority=10)
|
||||
_create_template(db, "s2", "alpha", "a", activation="default", priority=5)
|
||||
_create_template(db, "s3", "gamma", "g", activation="default", priority=1)
|
||||
|
||||
results = db.list_default_templates()
|
||||
assert len(results) == 3
|
||||
assert results[0]["name"] == "gamma"
|
||||
assert results[1]["name"] == "alpha"
|
||||
assert results[2]["name"] == "beta"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1b. Skill resource storage tests
|
||||
@@ -802,8 +848,8 @@ class TestSkillAPI:
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_skill_readonly_rejected(self, api_client, api_storage):
|
||||
"""Updating a readonly (MCP-sourced) skill returns 403."""
|
||||
def test_update_skill_readonly_spec_fields_rejected(self, api_client, api_storage):
|
||||
"""Updating spec fields on a readonly skill returns 400 (filtered to nothing)."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
@@ -815,9 +861,65 @@ class TestSkillAPI:
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"description": "hacked"},
|
||||
json={"description": "hacked", "content": "evil"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.status_code == 400
|
||||
assert "runtime config" in resp.json()["error"].lower()
|
||||
|
||||
def test_update_skill_readonly_runtime_config_allowed(self, api_client, api_storage):
|
||||
"""Runtime config fields can be updated on a readonly (installed) skill."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed-skill",
|
||||
"external content",
|
||||
origin="source",
|
||||
readonly=True,
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"model": "gpt-5", "temperature": 0.5, "enabled": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model"] == "gpt-5"
|
||||
assert data["temperature"] == 0.5
|
||||
assert data["enabled"] is False
|
||||
# Spec fields must remain unchanged
|
||||
assert data["content"] == "external content"
|
||||
|
||||
def test_update_skill_readonly_mixed_body_filters_spec(self, api_client, api_storage):
|
||||
"""When JS sends all fields for a readonly skill, spec fields are silently dropped."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed",
|
||||
"original content",
|
||||
origin="source",
|
||||
readonly=True,
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
# Simulate what the browser form submits: every field present
|
||||
json={
|
||||
"name": "hacked",
|
||||
"content": "evil content",
|
||||
"description": "tampered",
|
||||
"model": "gpt-5",
|
||||
"enabled": False,
|
||||
"token_budget": 50000,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Config fields updated
|
||||
assert data["model"] == "gpt-5"
|
||||
assert data["enabled"] is False
|
||||
assert data["token_budget"] == 50000
|
||||
# Spec fields unchanged
|
||||
assert data["name"] == "installed"
|
||||
assert data["content"] == "original content"
|
||||
assert data["description"] == ""
|
||||
|
||||
def test_update_skill_recomputes_token_estimate(self, api_client, api_storage):
|
||||
"""Updating content recomputes token_estimate."""
|
||||
@@ -1156,6 +1258,49 @@ class TestSkillSessionConfigApplication:
|
||||
parsed_tools = json.loads(tpl["allowed_tools"])
|
||||
assert parsed_tools == ["bash", "read_file", "write_file"]
|
||||
|
||||
def test_license_compatibility_roundtrip(self, db):
|
||||
"""Agent Skills spec fields license and compatibility round-trip."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec1",
|
||||
name="spec-fields-skill",
|
||||
category="general",
|
||||
content="Spec test.",
|
||||
skill_license="Apache-2.0",
|
||||
compatibility="Requires git, docker, jq",
|
||||
)
|
||||
tpl = db.get_skill_by_name("spec-fields-skill")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "Apache-2.0"
|
||||
assert tpl["compatibility"] == "Requires git, docker, jq"
|
||||
|
||||
def test_license_compatibility_default_empty(self, db):
|
||||
"""license and compatibility default to empty string."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec2",
|
||||
name="no-spec-fields",
|
||||
category="general",
|
||||
content="No spec fields.",
|
||||
)
|
||||
tpl = db.get_skill_by_name("no-spec-fields")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == ""
|
||||
assert tpl["compatibility"] == ""
|
||||
|
||||
def test_update_license_compatibility(self, db):
|
||||
"""license and compatibility can be updated."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec3",
|
||||
name="updatable-spec",
|
||||
category="general",
|
||||
content="Test.",
|
||||
)
|
||||
db.update_prompt_template("spec3", license="MIT")
|
||||
db.update_prompt_template("spec3", compatibility="Python 3.11+")
|
||||
tpl = db.get_prompt_template("spec3")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "MIT"
|
||||
assert tpl["compatibility"] == "Python 3.11+"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Migration behavior tests
|
||||
@@ -1456,3 +1601,322 @@ class TestSkillAdminEndpoints:
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "integer" in resp.json()["error"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Skill session config applied to workstream via server handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillConfigAppliedToWorkstream:
|
||||
"""Verify that skill session config fields are applied to the ChatSession
|
||||
when a workstream is created via the server ``create_workstream`` handler.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def _ws_app(self, tmp_path):
|
||||
"""Build a minimal Starlette app with the real ``create_workstream``
|
||||
handler, a real ``WorkstreamManager``, and a temp SQLite storage
|
||||
backend. Returns ``(TestClient, WorkstreamManager, storage)``.
|
||||
"""
|
||||
import queue
|
||||
import threading
|
||||
|
||||
import turnstone.core.storage._registry as _reg
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
from turnstone.server import create_workstream
|
||||
|
||||
storage = SQLiteBackend(str(tmp_path / "ws_test.db"))
|
||||
|
||||
# Inject the test storage as the global singleton so that
|
||||
# get_storage() / get_skill_by_name() resolve against it.
|
||||
old_storage = _reg._storage
|
||||
_reg._storage = storage
|
||||
|
||||
def _session_factory(
|
||||
ui: Any, model_alias: Any = None, ws_id: Any = None, **kwargs: Any
|
||||
) -> ChatSession:
|
||||
return ChatSession(
|
||||
client=MagicMock(),
|
||||
model=model_alias or "test-model",
|
||||
ui=ui,
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
ws_id=ws_id,
|
||||
skill=kwargs.get("skill"),
|
||||
)
|
||||
|
||||
mgr = WorkstreamManager(_session_factory)
|
||||
|
||||
routes = [
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
Route(
|
||||
"/api/workstreams/new",
|
||||
create_workstream,
|
||||
methods=["POST"],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
app = Starlette(
|
||||
routes=routes,
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.workstreams = mgr
|
||||
app.state.skip_permissions = True
|
||||
app.state.global_queue = queue.Queue()
|
||||
app.state.global_listeners = []
|
||||
app.state.global_listeners_lock = threading.Lock()
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
yield client, mgr, storage
|
||||
|
||||
# Restore original storage singleton.
|
||||
_reg._storage = old_storage
|
||||
|
||||
def test_session_receives_temperature(self, _ws_app):
|
||||
"""Skill temperature overrides the session default."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(storage, "s1", "warm-skill", "Be warm.", temperature=0.9, enabled=True)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "warm-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws_id = resp.json()["ws_id"]
|
||||
ws = mgr.get(ws_id)
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session.temperature == 0.9
|
||||
|
||||
def test_session_receives_max_tokens(self, _ws_app):
|
||||
"""Skill max_tokens overrides the session default."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(storage, "s1", "token-skill", "Be concise.", max_tokens=1024, enabled=True)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "token-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session.max_tokens == 1024
|
||||
|
||||
def test_session_receives_token_budget(self, _ws_app):
|
||||
"""Skill token_budget is applied to the session."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "budget-skill", "Stay on budget.", token_budget=50000, enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "budget-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session._token_budget == 50000
|
||||
|
||||
def test_session_receives_reasoning_effort(self, _ws_app):
|
||||
"""Skill reasoning_effort is applied to the session."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "effort-skill", "Think hard.", reasoning_effort="high", enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "effort-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session.reasoning_effort == "high"
|
||||
|
||||
def test_session_receives_agent_max_turns(self, _ws_app):
|
||||
"""Skill agent_max_turns is applied to the session."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "turns-skill", "Few turns.", agent_max_turns=3, enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "turns-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session.agent_max_turns == 3
|
||||
|
||||
def test_auto_approve_set_on_ui(self, _ws_app):
|
||||
"""Skill auto_approve=True propagates to the WebUI."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "approve-skill", "Auto approve.", auto_approve=True, enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "approve-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
assert ws.ui.auto_approve is True
|
||||
|
||||
def test_allowed_tools_set_on_ui(self, _ws_app):
|
||||
"""Skill allowed_tools are parsed and set as auto_approve_tools on the UI."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage,
|
||||
"s1",
|
||||
"tools-skill",
|
||||
"Restricted tools.",
|
||||
allowed_tools='["bash", "read_file"]',
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "tools-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
assert ws.ui.auto_approve_tools == {"bash", "read_file"}
|
||||
|
||||
def test_skill_model_overrides_resolved_model(self, _ws_app):
|
||||
"""Skill model field overrides the default session model."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "model-skill", "Use specific model.", model="gpt-5", enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "model-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
assert ws.session.model == "gpt-5"
|
||||
|
||||
def test_all_session_config_fields_applied(self, _ws_app):
|
||||
"""All session config fields from a skill are applied together."""
|
||||
from turnstone.server import WebUI
|
||||
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage,
|
||||
"s1",
|
||||
"full-skill",
|
||||
"Full config skill.",
|
||||
model="gpt-5",
|
||||
temperature=0.8,
|
||||
reasoning_effort="high",
|
||||
max_tokens=2048,
|
||||
token_budget=100000,
|
||||
agent_max_turns=10,
|
||||
auto_approve=True,
|
||||
allowed_tools='["bash", "write_file", "read_file"]',
|
||||
notify_on_complete='{"channel": "discord"}',
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "full-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
sess = ws.session
|
||||
assert sess.model == "gpt-5"
|
||||
assert sess.temperature == 0.8
|
||||
assert sess.reasoning_effort == "high"
|
||||
assert sess.max_tokens == 2048
|
||||
assert sess._token_budget == 100000
|
||||
assert sess.agent_max_turns == 10
|
||||
assert sess._notify_on_complete == '{"channel": "discord"}'
|
||||
assert sess._applied_skill_id == "s1"
|
||||
assert sess._applied_skill_content == "Full config skill."
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
assert ws.ui.auto_approve is True
|
||||
assert ws.ui.auto_approve_tools == {"bash", "write_file", "read_file"}
|
||||
|
||||
def test_disabled_skill_returns_400(self, _ws_app):
|
||||
"""Creating a workstream with a disabled skill returns 400."""
|
||||
client, _mgr, storage = _ws_app
|
||||
_create_template(storage, "s1", "disabled-skill", "Disabled.", enabled=False)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "disabled-skill"})
|
||||
assert resp.status_code == 400
|
||||
assert "disabled" in resp.json()["error"].lower()
|
||||
|
||||
def test_unknown_skill_returns_400(self, _ws_app):
|
||||
"""Creating a workstream with a nonexistent skill returns 400."""
|
||||
client, _mgr, _storage = _ws_app
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-such-skill"})
|
||||
assert resp.status_code == 400
|
||||
assert "not found" in resp.json()["error"].lower()
|
||||
|
||||
def test_zero_token_budget_is_noop(self, _ws_app):
|
||||
"""Skill with token_budget=0 — handler skips budget application (> 0 guard)."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "no-budget-skill", "No budget.", token_budget=0, enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-budget-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None and ws.session is not None
|
||||
# Budget stays at default (0) — the handler's > 0 guard prevents application
|
||||
assert ws.session._token_budget == 0
|
||||
|
||||
def test_empty_allowed_tools_is_noop(self, _ws_app):
|
||||
"""Skill with allowed_tools='[]' — handler skips (empty check)."""
|
||||
client, mgr, storage = _ws_app
|
||||
_create_template(
|
||||
storage, "s1", "no-tools-skill", "No tools.", allowed_tools="[]", enabled=True
|
||||
)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-tools-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws = mgr.get(resp.json()["ws_id"])
|
||||
assert ws is not None
|
||||
# auto_approve_tools stays at default (empty set)
|
||||
assert ws.ui.auto_approve_tools == set()
|
||||
|
||||
def test_skill_lineage_in_workstreams_table(self, _ws_app):
|
||||
"""skill_id and skill_version columns are populated in the workstreams table."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import workstreams
|
||||
|
||||
client, _mgr, storage = _ws_app
|
||||
_create_template(storage, "s1", "lineage-skill", "Track me.", enabled=True)
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={"skill": "lineage-skill"})
|
||||
assert resp.status_code == 200
|
||||
ws_id = resp.json()["ws_id"]
|
||||
|
||||
with storage._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(workstreams.c.skill_id, workstreams.c.skill_version).where(
|
||||
workstreams.c.ws_id == ws_id
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "s1"
|
||||
assert row[1] == 1
|
||||
|
||||
def test_no_skill_lineage_when_no_skill(self, _ws_app):
|
||||
"""Workstream without a skill has empty skill_id and zero skill_version."""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import workstreams
|
||||
|
||||
client, _mgr, storage = _ws_app
|
||||
|
||||
resp = client.post("/v1/api/workstreams/new", json={})
|
||||
assert resp.status_code == 200
|
||||
ws_id = resp.json()["ws_id"]
|
||||
|
||||
with storage._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(workstreams.c.skill_id, workstreams.c.skill_version).where(
|
||||
workstreams.c.ws_id == ws_id
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == ""
|
||||
assert row[1] == 0
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
"""Tests for the SQLite storage backend."""
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import init_storage, reset_storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(tmp_path):
|
||||
"""Create a fresh SQLiteBackend for each test."""
|
||||
reset_storage()
|
||||
b = init_storage("sqlite", path=str(tmp_path / "test.db"), run_migrations=False)
|
||||
yield b
|
||||
reset_storage()
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# -- Workstream registration ---------------------------------------------------
|
||||
|
||||
@@ -289,6 +279,73 @@ class TestWorkstreams:
|
||||
assert rows[0][6] == "node-a"
|
||||
|
||||
|
||||
# -- Structured memory touch ---------------------------------------------------
|
||||
|
||||
|
||||
class TestTouchStructuredMemory:
|
||||
@staticmethod
|
||||
def _create_memory(
|
||||
backend: Any, name: str = "m1", scope: str = "global", scope_id: str = ""
|
||||
) -> None:
|
||||
import uuid
|
||||
|
||||
backend.create_structured_memory(
|
||||
memory_id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
description="test desc",
|
||||
mem_type="project",
|
||||
scope=scope,
|
||||
scope_id=scope_id,
|
||||
content="test content",
|
||||
)
|
||||
|
||||
def test_batch_touch_multiple(self, backend):
|
||||
self._create_memory(backend, name="a")
|
||||
self._create_memory(backend, name="b")
|
||||
self._create_memory(backend, name="c")
|
||||
|
||||
count = backend.touch_structured_memories(
|
||||
[
|
||||
("a", "global", ""),
|
||||
("b", "global", ""),
|
||||
("c", "global", ""),
|
||||
]
|
||||
)
|
||||
assert count == 3
|
||||
|
||||
for name in ("a", "b", "c"):
|
||||
mem = backend.get_structured_memory_by_name(name, "global", "")
|
||||
assert int(mem["access_count"]) == 1
|
||||
|
||||
def test_batch_touch_empty_list(self, backend):
|
||||
assert backend.touch_structured_memories([]) == 0
|
||||
|
||||
def test_batch_touch_partial_match(self, backend):
|
||||
self._create_memory(backend, name="exists")
|
||||
|
||||
count = backend.touch_structured_memories(
|
||||
[
|
||||
("exists", "global", ""),
|
||||
("missing", "global", ""),
|
||||
]
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
mem = backend.get_structured_memory_by_name("exists", "global", "")
|
||||
assert int(mem["access_count"]) == 1
|
||||
|
||||
def test_batch_touch_with_duplicates(self, backend):
|
||||
"""Duplicate keys in batch should each increment access_count once."""
|
||||
self._create_memory(backend, name="dup")
|
||||
|
||||
# Two identical keys — storage gets called twice for the same row
|
||||
count = backend.touch_structured_memories([("dup", "global", ""), ("dup", "global", "")])
|
||||
assert count == 2
|
||||
|
||||
mem = backend.get_structured_memory_by_name("dup", "global", "")
|
||||
assert int(mem["access_count"]) == 2
|
||||
|
||||
|
||||
# -- Lifecycle -----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
"""Tests for structured memory storage backend operations."""
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestCreateAndGet:
|
||||
def test_create_and_get_by_id(self, backend):
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for tool policy enforcement across CLI, bridge, and channel entry points."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.cli import TerminalUI
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCLIPolicyEnforcement:
|
||||
"""Tool policies should be enforced in CLI approve_tools()."""
|
||||
|
||||
def _make_items(self, *tool_names: str) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"call_id": f"call_{i}",
|
||||
"header": f"Tool: {name}",
|
||||
"preview": "",
|
||||
"func_name": name,
|
||||
"approval_label": name,
|
||||
"needs_approval": True,
|
||||
}
|
||||
for i, name in enumerate(tool_names)
|
||||
]
|
||||
|
||||
def test_deny_policy_blocks_tool(self):
|
||||
"""A 'deny' policy verdict should block the tool without prompting."""
|
||||
ui = TerminalUI()
|
||||
items = self._make_items("bash")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.policy.evaluate_tool_policies_batch",
|
||||
return_value={"bash": "deny"},
|
||||
),
|
||||
patch(
|
||||
"turnstone.core.storage._registry.get_storage",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
|
||||
assert items[0].get("denied") is True
|
||||
assert items[0].get("error")
|
||||
assert "policy" in items[0]["error"].lower()
|
||||
|
||||
def test_allow_policy_auto_approves(self):
|
||||
"""An 'allow' policy verdict should auto-approve without prompting."""
|
||||
ui = TerminalUI()
|
||||
items = self._make_items("read_file")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.policy.evaluate_tool_policies_batch",
|
||||
return_value={"read_file": "allow"},
|
||||
),
|
||||
patch(
|
||||
"turnstone.core.storage._registry.get_storage",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
|
||||
assert approved is True
|
||||
|
||||
def test_no_storage_skips_policies(self):
|
||||
"""When storage is unavailable, policies are skipped (best-effort)."""
|
||||
ui = TerminalUI()
|
||||
items = self._make_items("bash")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.storage._registry.get_storage",
|
||||
return_value=None,
|
||||
),
|
||||
patch("builtins.input", return_value="y"),
|
||||
):
|
||||
approved, _ = ui.approve_tools(items)
|
||||
|
||||
# Should fall through to normal prompt (which we answered 'y')
|
||||
assert approved is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBridgePolicyEnforcement:
|
||||
"""Tool policies should be enforced in bridge _handle_approval()."""
|
||||
|
||||
def _make_bridge(self):
|
||||
from turnstone.mq.bridge import Bridge
|
||||
|
||||
broker = MagicMock()
|
||||
return Bridge(
|
||||
server_url="http://localhost:8080",
|
||||
broker=broker,
|
||||
node_id="test-node",
|
||||
approval_timeout=1,
|
||||
)
|
||||
|
||||
def _approval_items(self, *tool_names: str) -> list[dict]:
|
||||
return [
|
||||
{"func_name": name, "needs_approval": True, "approval_label": name}
|
||||
for name in tool_names
|
||||
]
|
||||
|
||||
def test_deny_policy_rejects_approval(self):
|
||||
"""A 'deny' policy should reject the approval."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.policy.evaluate_tool_policies_batch",
|
||||
return_value={"bash": "deny"},
|
||||
),
|
||||
patch(
|
||||
"turnstone.core.storage._registry._storage",
|
||||
new=MagicMock(),
|
||||
),
|
||||
patch.object(bridge, "_api_approve") as mock_approve,
|
||||
patch.object(bridge, "_publish_ws"),
|
||||
):
|
||||
bridge._handle_approval("ws-1", {"items": self._approval_items("bash")})
|
||||
|
||||
mock_approve.assert_called_once()
|
||||
assert mock_approve.call_args.kwargs.get("approved") is False
|
||||
|
||||
def test_allow_policy_approves(self):
|
||||
"""An 'allow' policy should auto-approve."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.policy.evaluate_tool_policies_batch",
|
||||
return_value={"read_file": "allow"},
|
||||
),
|
||||
patch(
|
||||
"turnstone.core.storage._registry.get_storage",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(bridge, "_api_approve") as mock_approve,
|
||||
patch.object(bridge, "_publish_ws"),
|
||||
):
|
||||
bridge._handle_approval("ws-1", {"items": self._approval_items("read_file")})
|
||||
|
||||
mock_approve.assert_called_once()
|
||||
assert mock_approve.call_args.kwargs.get("approved") is True
|
||||
|
||||
def test_mixed_deny_rejects_batch(self):
|
||||
"""If any tool is denied, the whole batch is rejected."""
|
||||
bridge = self._make_bridge()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.policy.evaluate_tool_policies_batch",
|
||||
return_value={"bash": "deny", "read_file": "allow"},
|
||||
),
|
||||
patch(
|
||||
"turnstone.core.storage._registry._storage",
|
||||
new=MagicMock(),
|
||||
),
|
||||
patch.object(bridge, "_api_approve") as mock_approve,
|
||||
patch.object(bridge, "_publish_ws"),
|
||||
):
|
||||
bridge._handle_approval("ws-1", {"items": self._approval_items("bash", "read_file")})
|
||||
|
||||
mock_approve.assert_called_once()
|
||||
assert mock_approve.call_args.kwargs.get("approved") is False
|
||||
@@ -128,17 +128,9 @@ class TestToolSearchManager:
|
||||
return ToolSearchManager(
|
||||
all_tools,
|
||||
always_on_names={"bash", "read_file", "edit_file"},
|
||||
threshold=5,
|
||||
max_results=3,
|
||||
)
|
||||
|
||||
def test_should_activate_above_threshold(self, manager):
|
||||
assert manager.should_activate()
|
||||
|
||||
def test_should_not_activate_below_threshold(self, builtin_tools):
|
||||
mgr = ToolSearchManager(builtin_tools, always_on_names={"bash", "read_file", "edit_file"})
|
||||
assert not mgr.should_activate()
|
||||
|
||||
def test_visible_tools_initially_builtin_only(self, manager):
|
||||
visible = manager.get_visible_tools()
|
||||
names = {_tool_name(t) for t in visible}
|
||||
@@ -201,9 +193,6 @@ class TestToolSearchManager:
|
||||
names = {_tool_name(t) for t in deferred}
|
||||
assert "mcp__github__create_issue" not in names
|
||||
|
||||
def test_get_all_tools_returns_everything(self, manager, builtin_tools, mcp_tools):
|
||||
assert len(manager.get_all_tools()) == len(builtin_tools) + len(mcp_tools)
|
||||
|
||||
def test_search_tool_definition_format(self, manager):
|
||||
defn = manager.get_search_tool_definition()
|
||||
assert defn["type"] == "function"
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestToolsMetadata:
|
||||
"watch": "command",
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"load_skill": "name",
|
||||
"skill": "name",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestUserCRUD:
|
||||
def test_create_and_get(self, db):
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
def _make_watch_kwargs(**overrides):
|
||||
"""Build default kwargs for create_watch."""
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for turnstone.core.web_search — pluggable web search backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from turnstone.core.web_search import (
|
||||
DuckDuckGoClient,
|
||||
MCPSearchClient,
|
||||
TavilyClient,
|
||||
_format_ddg,
|
||||
_format_tavily,
|
||||
resolve_web_search_client,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatTavily:
|
||||
def test_formats_answer_and_results(self):
|
||||
data = {
|
||||
"answer": "Python is great",
|
||||
"results": [
|
||||
{"title": "Python.org", "url": "https://python.org", "content": "Official site"},
|
||||
{"title": "PyPI", "url": "https://pypi.org", "content": "Package index"},
|
||||
],
|
||||
}
|
||||
out = _format_tavily(data, "python")
|
||||
assert "Answer: Python is great" in out
|
||||
assert "[Python.org](https://python.org)" in out
|
||||
assert "[PyPI](https://pypi.org)" in out
|
||||
|
||||
def test_no_results(self):
|
||||
out = _format_tavily({"results": []}, "nothing")
|
||||
assert "No results for 'nothing'" in out
|
||||
|
||||
def test_no_answer(self):
|
||||
data = {
|
||||
"results": [{"title": "T", "url": "http://t", "content": "C"}],
|
||||
}
|
||||
out = _format_tavily(data, "q")
|
||||
assert "Answer:" not in out
|
||||
assert "[T](http://t)" in out
|
||||
|
||||
|
||||
class TestFormatDDG:
|
||||
def test_formats_results(self):
|
||||
results = [
|
||||
{"title": "DDG Result", "href": "https://ddg.example.com", "body": "Search body"},
|
||||
]
|
||||
out = _format_ddg(results, "test")
|
||||
assert "[DDG Result](https://ddg.example.com)" in out
|
||||
assert "Search body" in out
|
||||
|
||||
def test_no_results(self):
|
||||
out = _format_ddg([], "nothing")
|
||||
assert "No results for 'nothing'" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTavilyClient:
|
||||
def test_search_calls_api(self):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"answer": "42",
|
||||
"results": [{"title": "T", "url": "http://t", "content": "C"}],
|
||||
}
|
||||
with patch("turnstone.core.web_search.httpx.post", return_value=mock_resp) as mock_post:
|
||||
client = TavilyClient("test-key", timeout=10)
|
||||
result = client.search("meaning of life", max_results=3)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
call_kwargs = mock_post.call_args
|
||||
assert call_kwargs.kwargs["json"]["query"] == "meaning of life"
|
||||
assert call_kwargs.kwargs["json"]["max_results"] == 3
|
||||
assert "Answer: 42" in result
|
||||
|
||||
|
||||
class TestDuckDuckGoClient:
|
||||
def test_integration_via_mock_ddgs(self):
|
||||
"""Patch the ddgs import inside DuckDuckGoClient.search."""
|
||||
mock_ddgs = MagicMock()
|
||||
mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs)
|
||||
mock_ddgs.__exit__ = MagicMock(return_value=False)
|
||||
mock_ddgs.text.return_value = [
|
||||
{"title": "DDG Result", "href": "https://ddg.co", "body": "Found it"},
|
||||
]
|
||||
mock_module = MagicMock()
|
||||
mock_module.DDGS.return_value = mock_ddgs
|
||||
with patch.dict("sys.modules", {"ddgs": mock_module}):
|
||||
client = DuckDuckGoClient(timeout=10)
|
||||
result = client.search("test query", max_results=3)
|
||||
mock_ddgs.text.assert_called_once_with("test query", max_results=3)
|
||||
assert "[DDG Result](https://ddg.co)" in result
|
||||
assert "Found it" in result
|
||||
|
||||
|
||||
class TestMCPSearchClient:
|
||||
def test_delegates_to_mcp(self):
|
||||
mcp = MagicMock()
|
||||
mcp.call_tool_sync.return_value = "MCP search results"
|
||||
client = MCPSearchClient(mcp, "mcp__ddg__search", timeout=30)
|
||||
result = client.search("test", max_results=3, topic="news")
|
||||
mcp.call_tool_sync.assert_called_once_with(
|
||||
"mcp__ddg__search",
|
||||
{"query": "test", "max_results": 3, "topic": "news"},
|
||||
timeout=30,
|
||||
)
|
||||
assert result == "MCP search results"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveClient:
|
||||
def test_auto_tavily_when_key_present(self):
|
||||
client = resolve_web_search_client("", tavily_key="key")
|
||||
assert isinstance(client, TavilyClient)
|
||||
|
||||
def test_auto_ddg_when_no_tavily(self):
|
||||
with patch("turnstone.core.web_search._ddg_available", return_value=True):
|
||||
client = resolve_web_search_client("", tavily_key=None)
|
||||
assert isinstance(client, DuckDuckGoClient)
|
||||
|
||||
def test_auto_none_when_nothing_available(self):
|
||||
with patch("turnstone.core.web_search._ddg_available", return_value=False):
|
||||
client = resolve_web_search_client("", tavily_key=None)
|
||||
assert client is None
|
||||
|
||||
def test_explicit_tavily(self):
|
||||
client = resolve_web_search_client("tavily", tavily_key="key")
|
||||
assert isinstance(client, TavilyClient)
|
||||
|
||||
def test_explicit_tavily_no_key(self):
|
||||
client = resolve_web_search_client("tavily", tavily_key=None)
|
||||
assert client is None
|
||||
|
||||
def test_explicit_ddg(self):
|
||||
with patch("turnstone.core.web_search._ddg_available", return_value=True):
|
||||
client = resolve_web_search_client("ddg", tavily_key=None)
|
||||
assert isinstance(client, DuckDuckGoClient)
|
||||
|
||||
def test_explicit_ddg_not_installed(self):
|
||||
with patch("turnstone.core.web_search._ddg_available", return_value=False):
|
||||
client = resolve_web_search_client("ddg", tavily_key=None)
|
||||
assert client is None
|
||||
|
||||
def test_mcp_backend(self):
|
||||
mcp = MagicMock()
|
||||
mcp.is_mcp_tool.return_value = True
|
||||
client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=mcp)
|
||||
assert isinstance(client, MCPSearchClient)
|
||||
mcp.is_mcp_tool.assert_called_with("mcp__ddg__search")
|
||||
|
||||
def test_mcp_backend_not_connected(self):
|
||||
mcp = MagicMock()
|
||||
mcp.is_mcp_tool.return_value = False
|
||||
client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=mcp)
|
||||
assert client is None
|
||||
|
||||
def test_mcp_backend_no_client(self):
|
||||
client = resolve_web_search_client("mcp:ddg:search", tavily_key=None, mcp_client=None)
|
||||
assert client is None
|
||||
|
||||
def test_unknown_backend_returns_none(self):
|
||||
client = resolve_web_search_client("typo_backend", tavily_key="key")
|
||||
assert client is None
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.8.2"
|
||||
__version__ = "0.8.6"
|
||||
|
||||
@@ -137,6 +137,9 @@ class ConsoleCreateWsRequest(BaseModel):
|
||||
default="", description="Optional first message sent after creation"
|
||||
)
|
||||
skill: str = Field(default="", description="Skill name (replaces default skills)")
|
||||
resume_ws: str = Field(
|
||||
default="", description="Workstream ID to resume (loads previous conversation)"
|
||||
)
|
||||
|
||||
|
||||
class ConsoleCreateWsResponse(BaseModel):
|
||||
@@ -306,7 +309,10 @@ class SkillInfo(BaseModel):
|
||||
agent_max_turns: int | None = None
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
priority: int = 0
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
scan_status: str = ""
|
||||
scan_report: str = "{}"
|
||||
scan_version: str = ""
|
||||
@@ -336,7 +342,10 @@ class CreateSkillRequest(BaseModel):
|
||||
agent_max_turns: int | None = None
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
priority: int = 0
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
|
||||
|
||||
class UpdateSkillRequest(BaseModel):
|
||||
@@ -359,7 +368,10 @@ class UpdateSkillRequest(BaseModel):
|
||||
agent_max_turns: int | None = None
|
||||
notify_on_complete: str | None = None
|
||||
enabled: bool | None = None
|
||||
priority: int | None = None
|
||||
allowed_tools: str | None = None
|
||||
license: str | None = None
|
||||
compatibility: str | None = None
|
||||
|
||||
|
||||
class ListSkillsResponse(BaseModel):
|
||||
|
||||
@@ -82,6 +82,7 @@ from turnstone.api.schemas import (
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
CreateUserRequest,
|
||||
DeleteSettingResponse,
|
||||
ErrorResponse,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
@@ -751,7 +752,7 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
"/v1/api/admin/settings/{key}",
|
||||
"DELETE",
|
||||
"Reset a setting to its default value",
|
||||
response_model=StatusResponse,
|
||||
response_model=DeleteSettingResponse,
|
||||
query_params=[
|
||||
QueryParam("node_id", "Node ID for node-scoped settings"),
|
||||
],
|
||||
@@ -855,6 +856,7 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
_ALL_MODELS: list[type[BaseModel]] = [
|
||||
ErrorResponse,
|
||||
StatusResponse,
|
||||
DeleteSettingResponse,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
AuthSetupRequest,
|
||||
|
||||
@@ -8,6 +8,7 @@ as the single source of truth for the generated OpenAPI spec.
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -34,6 +35,14 @@ class StatusResponse(BaseModel):
|
||||
status: str = Field(default="ok", examples=["ok"])
|
||||
|
||||
|
||||
class DeleteSettingResponse(BaseModel):
|
||||
"""DELETE /v1/api/admin/settings/{key} response."""
|
||||
|
||||
status: str = Field(default="ok", examples=["ok"])
|
||||
key: str = Field(description="Dotted setting key that was reset")
|
||||
default: Any = Field(description="Registry default value the setting reverted to")
|
||||
|
||||
|
||||
class AuthLoginRequest(BaseModel):
|
||||
"""POST /v1/api/auth/login request body.
|
||||
|
||||
|
||||
@@ -157,8 +157,10 @@ class McpStatus(BaseModel):
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(examples=["ok", "degraded"])
|
||||
version: str = ""
|
||||
node_id: str = ""
|
||||
uptime_seconds: float = 0.0
|
||||
model: str = ""
|
||||
max_ws: int = Field(default=10, description="Maximum concurrent workstreams")
|
||||
workstreams: WorkstreamCounts = WorkstreamCounts()
|
||||
backend: BackendStatus | None = None
|
||||
mcp: McpStatus | None = None
|
||||
|
||||
@@ -306,7 +306,49 @@ class TurnstoneBot:
|
||||
await sm.append(event.text)
|
||||
|
||||
elif isinstance(event, ApprovalRequestEvent):
|
||||
if self.config.auto_approve or self._should_auto_approve(event):
|
||||
# Evaluate admin tool policies before auto-approve.
|
||||
_policy_handled = False
|
||||
if self.storage is not None:
|
||||
try:
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch
|
||||
|
||||
_tool_names = [
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in event.items
|
||||
if it.get("needs_approval") and it.get("func_name") and not it.get("error")
|
||||
]
|
||||
_tool_names = [n for n in _tool_names if n]
|
||||
if _tool_names:
|
||||
verdicts = await asyncio.to_thread(
|
||||
evaluate_tool_policies_batch,
|
||||
self.storage,
|
||||
_tool_names,
|
||||
)
|
||||
if any(v == "deny" for v in verdicts.values()):
|
||||
denied = [n for n, v in verdicts.items() if v == "deny"]
|
||||
await self.router.send_approval(
|
||||
ws_id,
|
||||
event.correlation_id,
|
||||
approved=False,
|
||||
feedback=f"Blocked by tool policy: {', '.join(denied)}",
|
||||
)
|
||||
await thread.send(
|
||||
f"*Tool blocked by admin policy: {', '.join(denied)}*"
|
||||
)
|
||||
_policy_handled = True
|
||||
elif all(verdicts.get(n) == "allow" for n in _tool_names):
|
||||
await self.router.send_approval(
|
||||
ws_id,
|
||||
event.correlation_id,
|
||||
approved=True,
|
||||
)
|
||||
await thread.send("*Tool approved by policy.*")
|
||||
_policy_handled = True
|
||||
except Exception:
|
||||
log.debug("Tool policy evaluation failed for ws %s", ws_id, exc_info=True)
|
||||
if not _policy_handled and (
|
||||
self.config.auto_approve or self._should_auto_approve(event)
|
||||
):
|
||||
await self.router.send_approval(ws_id, event.correlation_id, approved=True)
|
||||
await thread.send("*Tool auto-approved.*")
|
||||
else:
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""chat.py — Backward-compatibility shim.
|
||||
|
||||
All functionality has been moved to submodules:
|
||||
- turnstone.core.session: ChatSession, SessionUI
|
||||
- turnstone.core.tools: TOOLS, AGENT_TOOLS, TASK_AGENT_TOOLS
|
||||
- turnstone.core.edit: find_occurrences, pick_nearest
|
||||
- turnstone.core.sandbox: validate_math_code, execute_math_sandboxed
|
||||
- turnstone.core.safety: is_command_blocked, sanitize_command
|
||||
- turnstone.core.web: strip_html, check_ssrf
|
||||
- turnstone.core.memory: save_message, structured memory facade, etc.
|
||||
- turnstone.ui.colors: ANSI constants and helpers
|
||||
- turnstone.ui.markdown: MarkdownRenderer
|
||||
- turnstone.ui.spinner: Spinner
|
||||
- turnstone.cli: TerminalUI, main, detect_model
|
||||
"""
|
||||
|
||||
# Re-export public API for backward compatibility
|
||||
from turnstone.cli import detect_model, main # noqa: F401
|
||||
from turnstone.core.session import ChatSession, SessionUI # noqa: F401
|
||||
from turnstone.core.tools import AGENT_TOOLS, TASK_AGENT_TOOLS, TOOLS # noqa: F401
|
||||
from turnstone.core.web import strip_html as _strip_html # noqa: F401
|
||||
from turnstone.ui.colors import ( # noqa: F401
|
||||
BLUE,
|
||||
BOLD,
|
||||
CYAN,
|
||||
DIM,
|
||||
GRAY,
|
||||
GREEN,
|
||||
ITALIC,
|
||||
MAGENTA,
|
||||
RED,
|
||||
RESET,
|
||||
YELLOW,
|
||||
bold,
|
||||
cyan,
|
||||
dim,
|
||||
green,
|
||||
red,
|
||||
yellow,
|
||||
)
|
||||
from turnstone.ui.markdown import MarkdownRenderer # noqa: F401
|
||||
from turnstone.ui.spinner import Spinner # noqa: F401
|
||||
+74
-6
@@ -14,6 +14,7 @@ import textwrap
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.judge import JudgeConfig
|
||||
from turnstone.core.session import ChatSession, SessionUI
|
||||
from turnstone.core.workstream import Workstream, WorkstreamManager, WorkstreamState
|
||||
from turnstone.ui.colors import (
|
||||
@@ -134,11 +135,42 @@ class TerminalUI(SessionUI):
|
||||
"""
|
||||
pending = [it for it in items if it.get("needs_approval") and not it.get("error")]
|
||||
|
||||
# Evaluate admin tool policies (deny/allow/ask) before prompting.
|
||||
if pending:
|
||||
try:
|
||||
from turnstone.core.policy import evaluate_tool_policies_batch
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
if storage is not None:
|
||||
_policy_names = [
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in pending
|
||||
if it.get("func_name")
|
||||
]
|
||||
if _policy_names:
|
||||
verdicts = evaluate_tool_policies_batch(storage, _policy_names)
|
||||
for it in pending:
|
||||
policy_name = it.get("approval_label", "") or it.get("func_name", "")
|
||||
verdict = verdicts.get(policy_name)
|
||||
if verdict == "deny":
|
||||
it["denied"] = True
|
||||
it["error"] = f"Blocked by tool policy ('{policy_name}')"
|
||||
it["needs_approval"] = False
|
||||
elif verdict == "allow":
|
||||
it["needs_approval"] = False
|
||||
pending = [
|
||||
it for it in items if it.get("needs_approval") and not it.get("error")
|
||||
]
|
||||
except Exception:
|
||||
pass # Best-effort — no policy enforcement on error
|
||||
|
||||
with self._print_lock:
|
||||
# Print all headers, previews, and heuristic verdicts
|
||||
for item in items:
|
||||
if item.get("error"):
|
||||
sys.stdout.write(f" {red(item['header'])}\n")
|
||||
sys.stdout.write(f" {red(item['error'])}\n")
|
||||
else:
|
||||
sys.stdout.write(f" {yellow(item['header'])}\n")
|
||||
if item.get("preview"):
|
||||
@@ -162,7 +194,11 @@ class TerminalUI(SessionUI):
|
||||
|
||||
# Per-tool auto-approve check
|
||||
if self.auto_approve_tools:
|
||||
pending_names = {it.get("func_name", "") for it in pending if it.get("func_name")}
|
||||
pending_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in pending
|
||||
if it.get("func_name")
|
||||
}
|
||||
if pending_names and pending_names.issubset(self.auto_approve_tools):
|
||||
return True, None
|
||||
|
||||
@@ -195,7 +231,11 @@ class TerminalUI(SessionUI):
|
||||
break
|
||||
|
||||
if decision in ("a", "always"):
|
||||
tool_names = {it.get("func_name", "") for it in pending if it.get("func_name")}
|
||||
tool_names = {
|
||||
it.get("approval_label", "") or it.get("func_name", "")
|
||||
for it in pending
|
||||
if it.get("func_name") and not it.get("error")
|
||||
}
|
||||
tool_names.discard("")
|
||||
tool_names.discard("__budget_override__")
|
||||
self.auto_approve_tools.update(tool_names)
|
||||
@@ -752,10 +792,15 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
|
||||
|
||||
|
||||
def detect_model(client: Any, provider: str = "openai") -> tuple[str, int | None]:
|
||||
"""Auto-detect model — delegates to :func:`turnstone.core.model_registry.detect_model`."""
|
||||
"""Auto-detect model — delegates to :func:`turnstone.core.model_registry.detect_model`.
|
||||
|
||||
CLI always uses fatal=True, so model is never None.
|
||||
"""
|
||||
from turnstone.core.model_registry import detect_model as _detect
|
||||
|
||||
return _detect(client, provider=provider)
|
||||
model, ctx = _detect(client, provider=provider)
|
||||
assert model is not None # fatal=True guarantees non-None or SystemExit
|
||||
return model, ctx
|
||||
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────
|
||||
@@ -870,6 +915,12 @@ def main() -> None:
|
||||
default=5,
|
||||
help="Max tools returned per tool search query (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--web-search-backend",
|
||||
default="",
|
||||
metavar="BACKEND",
|
||||
help="Web search backend: '' (auto), 'tavily', 'ddg', or 'mcp:server:tool'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
default=None,
|
||||
@@ -959,8 +1010,9 @@ def main() -> None:
|
||||
default=0.7,
|
||||
help="Confidence threshold for judge (default: 0.7)",
|
||||
)
|
||||
from turnstone.core.config import apply_config
|
||||
from turnstone.core.config import add_config_arg, apply_config
|
||||
|
||||
add_config_arg(parser)
|
||||
apply_config(
|
||||
parser,
|
||||
["api", "model", "session", "tools", "console", "auth", "mcp", "database", "judge"],
|
||||
@@ -980,7 +1032,7 @@ def main() -> None:
|
||||
db_url = getattr(args, "db_url", None) or os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = getattr(args, "db_path", None) or os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
db_pool_size = int(
|
||||
getattr(args, "db_pool_size", None) or os.environ.get("TURNSTONE_DB_POOL_SIZE", "5")
|
||||
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)
|
||||
|
||||
@@ -1040,6 +1092,20 @@ def main() -> None:
|
||||
storage=_get_storage(),
|
||||
)
|
||||
|
||||
# apply_config() merges [judge] config.toml values into args as
|
||||
# judge_base_url, judge_api_key, etc. Output_guard and redact_secrets
|
||||
# default to True, enabling the heuristic guard even when the LLM judge
|
||||
# is disabled via --no-judge.
|
||||
judge_config = JudgeConfig(
|
||||
enabled=args.judge_enabled,
|
||||
model=args.judge_model,
|
||||
provider=args.judge_provider,
|
||||
base_url=getattr(args, "judge_base_url", ""),
|
||||
api_key=getattr(args, "judge_api_key", ""),
|
||||
confidence_threshold=args.judge_confidence,
|
||||
timeout=args.judge_timeout,
|
||||
)
|
||||
|
||||
# ChatSession factory — captures shared config for creating workstreams
|
||||
def session_factory(
|
||||
ui: SessionUI | None,
|
||||
@@ -1070,7 +1136,9 @@ def main() -> None:
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
web_search_backend=args.web_search_backend,
|
||||
skill=skill or args.skill or None,
|
||||
judge_config=judge_config,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
from turnstone.mq.broker import RedisBroker
|
||||
|
||||
log = logging.getLogger("turnstone.console.collector")
|
||||
@@ -53,11 +54,12 @@ class ClusterCollector:
|
||||
self,
|
||||
broker: RedisBroker,
|
||||
prefix: str = "turnstone",
|
||||
poll_interval: float = 10.0,
|
||||
poll_interval: float = 15.0,
|
||||
discovery_interval: float = 15.0,
|
||||
max_poll_workers: int = 50,
|
||||
http_timeout: float = 5.0,
|
||||
max_poll_workers: int = 200,
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
):
|
||||
self._broker = broker
|
||||
self._prefix = prefix
|
||||
@@ -65,16 +67,26 @@ class ClusterCollector:
|
||||
self._discovery_interval = discovery_interval
|
||||
self._max_poll_workers = max_poll_workers
|
||||
self._http_timeout = http_timeout
|
||||
self._token_manager = token_manager
|
||||
# Static auth header — only used when no token_manager is present.
|
||||
# When a token_manager exists, auth is injected per-request via
|
||||
# extra_headers in _poll_all_nodes to avoid stale JWT expiry.
|
||||
self._static_auth: dict[str, str] | None = None
|
||||
if auth_token and token_manager is None:
|
||||
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._nodes: dict[str, NodeSnapshot] = {}
|
||||
self._running = False
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._poll_pool = ThreadPoolExecutor(max_workers=max_poll_workers)
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
self._http_client = httpx.Client(timeout=http_timeout, headers=headers)
|
||||
self._http_client = httpx.Client(
|
||||
timeout=httpx.Timeout(connect=10, read=http_timeout, write=5, pool=http_timeout),
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_poll_workers + 10,
|
||||
max_keepalive_connections=min(max_poll_workers, 200),
|
||||
),
|
||||
)
|
||||
|
||||
# SSE fan-out to browser clients
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
@@ -212,6 +224,7 @@ class ClusterCollector:
|
||||
self._nodes[nid].server_url = meta.get(
|
||||
"server_url", self._nodes[nid].server_url
|
||||
)
|
||||
self._nodes[nid].max_ws = meta.get("max_ws", self._nodes[nid].max_ws)
|
||||
|
||||
# Remove nodes whose heartbeats expired
|
||||
lost = [nid for nid in self._nodes if nid not in active_ids]
|
||||
@@ -233,8 +246,35 @@ class ClusterCollector:
|
||||
log.exception("Poll loop error")
|
||||
time.sleep(self._poll_interval)
|
||||
|
||||
@staticmethod
|
||||
def _node_jitter(node_id: str, window: float) -> float:
|
||||
"""Deterministic per-node delay within a sliding window.
|
||||
|
||||
Uses a Mersenne prime (2^31 - 1) to hash the node_id into a
|
||||
stable offset so each node is polled at a different point in
|
||||
the cycle. The offset is consistent across restarts for the
|
||||
same node_id, giving an even spread without randomness.
|
||||
"""
|
||||
h = hash(node_id) & 0x7FFFFFFF # positive 31-bit
|
||||
return (h % 2147483647) / 2147483647 * window # M31 = 2^31 - 1
|
||||
|
||||
def _poll_all_nodes(self) -> None:
|
||||
"""Fetch dashboard data from all known nodes in parallel."""
|
||||
"""Fetch dashboard data from all known nodes in parallel.
|
||||
|
||||
Submissions are throttled by the thread pool size to avoid a
|
||||
thundering herd — at most ``max_poll_workers`` concurrent HTTP
|
||||
requests are in flight at any time. Each worker sleeps a
|
||||
deterministic per-node jitter (derived from its node_id) to
|
||||
spread requests across the first half of the poll interval.
|
||||
"""
|
||||
# Snapshot current auth header for this poll cycle. Per-request
|
||||
# headers avoid mutating shared client state (thread-safe).
|
||||
if self._token_manager is not None:
|
||||
poll_headers: dict[str, str] | None = {
|
||||
"Authorization": f"Bearer {self._token_manager.token}"
|
||||
}
|
||||
else:
|
||||
poll_headers = self._static_auth
|
||||
with self._lock:
|
||||
targets = [
|
||||
(n.node_id, n.server_url)
|
||||
@@ -245,27 +285,57 @@ class ClusterCollector:
|
||||
if not targets:
|
||||
return
|
||||
|
||||
futures = {self._poll_pool.submit(self._fetch_node, nid, url): nid for nid, url in targets}
|
||||
jitter_window = self._poll_interval / 2
|
||||
|
||||
def _jittered_fetch(
|
||||
nid: str, url: str, headers: dict[str, str] | None
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
delay = self._node_jitter(nid, jitter_window)
|
||||
if delay > 0.1:
|
||||
time.sleep(delay)
|
||||
return self._fetch_node(nid, url, headers)
|
||||
|
||||
futures = {
|
||||
self._poll_pool.submit(_jittered_fetch, nid, url, poll_headers): nid
|
||||
for nid, url in targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
nid = futures[future]
|
||||
try:
|
||||
dashboard, health = future.result()
|
||||
self._apply_poll(nid, dashboard, health)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code in (401, 403):
|
||||
log.warning(
|
||||
"Auth failure polling node %s: HTTP %d", nid, exc.response.status_code
|
||||
)
|
||||
else:
|
||||
log.debug("Failed to poll node %s: HTTP %d", nid, exc.response.status_code)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
except Exception:
|
||||
log.debug("Failed to poll node %s", nid)
|
||||
log.warning("Failed to poll node %s", nid, exc_info=True)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
|
||||
def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
def _fetch_node(
|
||||
self,
|
||||
node_id: str,
|
||||
server_url: str,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Fetch /v1/api/dashboard and /health from a single node."""
|
||||
base = server_url.rstrip("/")
|
||||
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard")
|
||||
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard", headers=extra_headers)
|
||||
dash_resp.raise_for_status()
|
||||
dash_data: dict[str, Any] = dash_resp.json()
|
||||
try:
|
||||
health_resp = self._http_client.get(f"{base}/health")
|
||||
health_resp = self._http_client.get(f"{base}/health", headers=extra_headers)
|
||||
health_data: dict[str, Any] = health_resp.json()
|
||||
except Exception:
|
||||
log.debug("Failed to fetch health from %s", node_id, exc_info=True)
|
||||
health_data = {}
|
||||
return dash_data, health_data
|
||||
|
||||
@@ -373,9 +443,12 @@ class ClusterCollector:
|
||||
}
|
||||
|
||||
def get_nodes(
|
||||
self, sort_by: str = "activity", limit: int = 100, offset: int = 0
|
||||
self, sort_by: str = "activity", limit: int | None = 100, offset: int = 0
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return sorted, paginated node list with per-node counts."""
|
||||
"""Return sorted, paginated node list with per-node counts.
|
||||
|
||||
Pass ``limit=None`` to return all nodes (no pagination).
|
||||
"""
|
||||
with self._lock:
|
||||
items = []
|
||||
for node in self._nodes.values():
|
||||
@@ -423,8 +496,15 @@ class ClusterCollector:
|
||||
elif sort_by == "name":
|
||||
items.sort(key=lambda n: n["node_id"])
|
||||
|
||||
if limit is None:
|
||||
return items[offset:], total
|
||||
return items[offset : offset + limit], total
|
||||
|
||||
def get_all_nodes(self) -> list[dict[str, Any]]:
|
||||
"""Return all nodes without pagination (for fan-out operations)."""
|
||||
nodes, _ = self.get_nodes(sort_by="activity", limit=None)
|
||||
return nodes
|
||||
|
||||
def get_workstreams(
|
||||
self,
|
||||
state: str | None = None,
|
||||
|
||||
+181
-82
@@ -168,7 +168,7 @@ def _get_server_url(request: Request, node_id: str) -> str | None:
|
||||
|
||||
def _pick_best_node(collector: ClusterCollector) -> str:
|
||||
"""Select the reachable node with the most available capacity."""
|
||||
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
|
||||
nodes = collector.get_all_nodes()
|
||||
best_id = ""
|
||||
best_headroom = -1
|
||||
for n in nodes:
|
||||
@@ -252,7 +252,7 @@ async def cluster_snapshot(request: Request) -> JSONResponse:
|
||||
|
||||
async def cluster_events_sse(request: Request) -> Response:
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=500)
|
||||
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=2000)
|
||||
|
||||
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -371,6 +371,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
raw_model = body.get("model", "")
|
||||
raw_initial_message = body.get("initial_message", "")
|
||||
raw_skill = body.get("skill", "")
|
||||
raw_resume_ws = body.get("resume_ws", "")
|
||||
if not isinstance(raw_node_id, str):
|
||||
raw_node_id = "" if raw_node_id is None else None
|
||||
if not isinstance(raw_name, str):
|
||||
@@ -381,15 +382,20 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
raw_initial_message = "" if raw_initial_message is None else None
|
||||
if not isinstance(raw_skill, str):
|
||||
raw_skill = "" if raw_skill is None else None
|
||||
if not isinstance(raw_resume_ws, str):
|
||||
raw_resume_ws = "" if raw_resume_ws is None else None
|
||||
if (
|
||||
raw_node_id is None
|
||||
or raw_name is None
|
||||
or raw_model is None
|
||||
or raw_initial_message is None
|
||||
or raw_skill is None
|
||||
or raw_resume_ws is None
|
||||
):
|
||||
return JSONResponse(
|
||||
{"error": "node_id, name, model, initial_message, and skill must be strings"},
|
||||
{
|
||||
"error": "node_id, name, model, initial_message, skill, and resume_ws must be strings"
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
node_id = raw_node_id
|
||||
@@ -397,6 +403,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
model = raw_model[:128]
|
||||
initial_message = raw_initial_message[:4096]
|
||||
skill = raw_skill[:256]
|
||||
resume_ws = raw_resume_ws[:64]
|
||||
|
||||
from turnstone.mq.protocol import CreateWorkstreamMessage
|
||||
|
||||
@@ -407,6 +414,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
model=model,
|
||||
initial_message=initial_message,
|
||||
skill=skill,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
broker.push_inbound(msg.to_json())
|
||||
log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name)
|
||||
@@ -435,6 +443,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
target_node=node_id,
|
||||
initial_message=initial_message,
|
||||
skill=skill,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
broker.push_inbound(msg.to_json(), node_id=node_id)
|
||||
|
||||
@@ -670,17 +679,40 @@ async def _proxy_sse(
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
# Create async HTTP client for proxy routes
|
||||
headers: dict[str, str] = {}
|
||||
token = app.state.proxy_auth_token
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
app.state.proxy_client = httpx.AsyncClient(timeout=30, headers=headers)
|
||||
# Separate client for SSE streams — longer read timeout, shared connection pool
|
||||
# Create async HTTP clients for proxy routes. Auth headers are NOT baked
|
||||
# in — _proxy_auth_headers() injects a fresh token per-request so JWTs
|
||||
# auto-rotate via ServiceTokenManager instead of expiring after 1 hour.
|
||||
# Size the pool above the fan-out limit to leave headroom for non-fan-out
|
||||
# proxy traffic (UI proxying, SSE streams, etc.).
|
||||
#
|
||||
# Build a ConfigStore so console settings reads get type validation and
|
||||
# caching instead of raw storage.get_system_setting() calls.
|
||||
storage = getattr(app.state, "auth_storage", None)
|
||||
config_store = None
|
||||
if storage:
|
||||
try:
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
|
||||
config_store = ConfigStore(storage)
|
||||
except Exception:
|
||||
log.warning("Failed to initialise ConfigStore", exc_info=True)
|
||||
app.state.config_store = config_store
|
||||
fan_out = (
|
||||
config_store.get("cluster.node_fan_out_limit") if config_store else _NODE_FAN_OUT_LIMIT
|
||||
)
|
||||
app.state.fan_out_limit = fan_out
|
||||
app.state.proxy_client = httpx.AsyncClient(
|
||||
timeout=30,
|
||||
limits=httpx.Limits(
|
||||
max_connections=fan_out + 50,
|
||||
max_keepalive_connections=min(fan_out // 4, 100),
|
||||
),
|
||||
)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(keepalive_expiry=30),
|
||||
headers=headers,
|
||||
limits=httpx.Limits(
|
||||
max_connections=1100, max_keepalive_connections=100, keepalive_expiry=30
|
||||
),
|
||||
)
|
||||
# Start scheduler if configured
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
@@ -1467,10 +1499,10 @@ async def admin_list_watches(request: Request) -> JSONResponse:
|
||||
if err:
|
||||
return err
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes, _ = collector.get_nodes(limit=500)
|
||||
nodes = collector.get_all_nodes()
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _fetch_node(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
server_url = (node.get("server_url") or "").rstrip("/")
|
||||
@@ -1509,9 +1541,14 @@ async def admin_list_watches(request: Request) -> JSONResponse:
|
||||
_VALID_WATCH_ID = re.compile(r"^[a-fA-F0-9]+$")
|
||||
|
||||
# Max concurrent outbound requests when fanning out to cluster nodes.
|
||||
# Sized below the default httpx pool limit (100) to leave headroom for
|
||||
# other proxy traffic (UI proxying, SSE streams, etc.).
|
||||
_NODE_FAN_OUT_LIMIT = 50
|
||||
# Must stay below the httpx pool limit (set in _lifespan) to leave
|
||||
# headroom for non-fan-out proxy traffic (UI proxying, SSE streams).
|
||||
_NODE_FAN_OUT_LIMIT = 200 # fallback; prefer cluster.node_fan_out_limit from storage
|
||||
|
||||
|
||||
def _get_fan_out_limit(request: Request) -> int:
|
||||
"""Return the fan-out limit cached at startup on app.state."""
|
||||
return int(getattr(request.app.state, "fan_out_limit", _NODE_FAN_OUT_LIMIT))
|
||||
|
||||
|
||||
async def admin_cancel_watch(request: Request) -> Response:
|
||||
@@ -2134,6 +2171,25 @@ async def admin_delete_policy(request: Request) -> JSONResponse:
|
||||
|
||||
_VALID_ACTIVATIONS = {"named", "default", "search"}
|
||||
|
||||
# Fields that may be updated on installed (readonly) skills.
|
||||
# These are local runtime configuration — not part of the SKILL.md spec —
|
||||
# so they don't compromise the fidelity of an externally-sourced skill.
|
||||
_SKILL_RUNTIME_CONFIG_FIELDS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"temperature",
|
||||
"reasoning_effort",
|
||||
"max_tokens",
|
||||
"token_budget",
|
||||
"agent_max_turns",
|
||||
"auto_approve",
|
||||
"allowed_tools",
|
||||
"enabled",
|
||||
"notify_on_complete",
|
||||
"priority",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], JSONResponse | None]:
|
||||
"""Parse and validate session config fields from a skill request body.
|
||||
@@ -2285,7 +2341,10 @@ def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str,
|
||||
"agent_max_turns": r.get("agent_max_turns"),
|
||||
"notify_on_complete": r.get("notify_on_complete", "{}"),
|
||||
"enabled": r.get("enabled", True),
|
||||
"priority": r.get("priority", 0),
|
||||
"allowed_tools": r.get("allowed_tools", "[]"),
|
||||
"license": r.get("license", ""),
|
||||
"compatibility": r.get("compatibility", ""),
|
||||
"scan_status": r.get("scan_status", ""),
|
||||
"scan_report": r.get("scan_report", "{}"),
|
||||
"scan_version": r.get("scan_version", ""),
|
||||
@@ -2370,6 +2429,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
org_id = str(body.get("org_id", "")).strip()[:64]
|
||||
author = str(body.get("author", "")).strip()[:256]
|
||||
version = str(body.get("version", "1.0.0")).strip()[:64]
|
||||
license_val = str(body.get("license", "")).strip()[:128]
|
||||
compatibility = str(body.get("compatibility", "")).strip()[:500]
|
||||
|
||||
raw_tags = body.get("tags", [])
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -2395,6 +2456,11 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
if activation == "default":
|
||||
is_default = True
|
||||
|
||||
try:
|
||||
priority = max(-1000, min(1000, int(body.get("priority", 0) or 0)))
|
||||
except (ValueError, TypeError):
|
||||
priority = 0
|
||||
|
||||
if not name:
|
||||
return JSONResponse({"error": "name is required"}, status_code=400)
|
||||
if not content:
|
||||
@@ -2418,8 +2484,11 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
tags=tags_str,
|
||||
version=version,
|
||||
author=author,
|
||||
skill_license=license_val,
|
||||
compatibility=compatibility,
|
||||
activation=activation,
|
||||
token_estimate=token_estimate,
|
||||
priority=priority,
|
||||
**session_fields,
|
||||
)
|
||||
|
||||
@@ -2456,8 +2525,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
existing = storage.get_prompt_template(skill_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if existing.get("readonly"):
|
||||
return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403)
|
||||
is_readonly = bool(existing.get("readonly"))
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
@@ -2497,6 +2565,10 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
updates["author"] = str(body["author"]).strip()[:256]
|
||||
if "version" in body:
|
||||
updates["version"] = str(body["version"]).strip()[:64]
|
||||
if "license" in body:
|
||||
updates["license"] = str(body["license"]).strip()[:128]
|
||||
if "compatibility" in body:
|
||||
updates["compatibility"] = str(body["compatibility"]).strip()[:500]
|
||||
if "tags" in body:
|
||||
raw_tags = body["tags"]
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -2508,6 +2580,18 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
except (ValueError, TypeError):
|
||||
tag_str = "[]"
|
||||
updates["tags"] = tag_str
|
||||
if "priority" in body:
|
||||
try:
|
||||
updates["priority"] = max(-1000, min(1000, int(body["priority"] or 0)))
|
||||
except (ValueError, TypeError):
|
||||
updates["priority"] = 0
|
||||
|
||||
# Installed (readonly) skills: restrict updates to runtime config only.
|
||||
# Spec/content fields are locked to preserve external-source fidelity.
|
||||
if is_readonly:
|
||||
updates = {k: v for k, v in updates.items() if k in _SKILL_RUNTIME_CONFIG_FIELDS}
|
||||
if not updates:
|
||||
return JSONResponse({"error": "No runtime config fields to update"}, status_code=400)
|
||||
|
||||
# Snapshot current state for version history before applying update
|
||||
existing_versions = storage.list_skill_versions(skill_id)
|
||||
@@ -2526,7 +2610,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"skill.update",
|
||||
"skill.update.config" if is_readonly else "skill.update",
|
||||
"skill",
|
||||
skill_id,
|
||||
updates,
|
||||
@@ -3024,20 +3108,18 @@ async def admin_delete_skill_resource(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
def _get_discovery_url(request: Request) -> str:
|
||||
"""Get skills discovery URL from DB settings, config.toml, or default."""
|
||||
"""Get skills discovery URL via ConfigStore, config.toml, or default."""
|
||||
from turnstone.core.config import load_config
|
||||
from turnstone.core.skill_sources import DEFAULT_DISCOVERY_URL
|
||||
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage:
|
||||
try:
|
||||
row = storage.get_system_setting("skills.discovery_url")
|
||||
if row:
|
||||
val = json.loads(row["value"])
|
||||
if val:
|
||||
return str(val)
|
||||
except (KeyError, json.JSONDecodeError, TypeError, AttributeError):
|
||||
pass
|
||||
# ConfigStore: validated + cached
|
||||
config_store = getattr(request.app.state, "config_store", None)
|
||||
if config_store:
|
||||
val = config_store.get("skills.discovery_url")
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
# Fall back to config.toml [skills] section
|
||||
skills_cfg = load_config("skills")
|
||||
url = skills_cfg.get("discovery_url", "")
|
||||
if url:
|
||||
@@ -3203,6 +3285,8 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
source_url=pkg_source_url,
|
||||
version=parsed.version,
|
||||
author=parsed.author,
|
||||
skill_license=parsed.license,
|
||||
compatibility=parsed.compatibility,
|
||||
activation="named",
|
||||
token_estimate=token_estimate,
|
||||
allowed_tools=allowed_tools_str,
|
||||
@@ -3394,31 +3478,40 @@ async def admin_delete_memory(request: Request) -> JSONResponse:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _publish_config_change(request: Request, *, key: str, node_id: str, action: str) -> None:
|
||||
"""Fan out config-reload to all known server nodes (best-effort).
|
||||
async def _publish_config_change(request: Request) -> None:
|
||||
"""Fan out config-reload to all known server nodes (best-effort, async).
|
||||
|
||||
Uses the collector's node registry and the existing proxy auth
|
||||
mechanism — no MQ dependency.
|
||||
Uses the collector's node registry, the shared async proxy client,
|
||||
and bounded concurrency via the fan-out semaphore.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
import httpx
|
||||
# Reload the console's own ConfigStore so cached values stay fresh
|
||||
# (must happen even when collector is absent — e.g. standalone console)
|
||||
config_store = getattr(request.app.state, "config_store", None)
|
||||
if config_store:
|
||||
config_store.reload()
|
||||
|
||||
collector = getattr(request.app.state, "collector", None)
|
||||
if not collector:
|
||||
return
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
with contextlib.suppress(Exception):
|
||||
nodes = collector.get_nodes()
|
||||
for node in nodes.get("nodes", []):
|
||||
url = node.get("url", "")
|
||||
if url:
|
||||
with contextlib.suppress(Exception):
|
||||
httpx.post(
|
||||
f"{url}/v1/api/_internal/config-reload",
|
||||
headers=headers,
|
||||
timeout=5.0,
|
||||
)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _notify(url: str) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
await client.post(
|
||||
f"{url.rstrip('/')}/v1/api/_internal/config-reload",
|
||||
headers=headers,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Config reload failed for %s", url, exc_info=True)
|
||||
|
||||
nodes = collector.get_all_nodes()
|
||||
tasks = [_notify(n["server_url"]) for n in nodes if n.get("server_url")]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def admin_list_settings(request: Request) -> JSONResponse:
|
||||
@@ -3574,7 +3667,7 @@ async def admin_update_setting(request: Request) -> JSONResponse:
|
||||
ip,
|
||||
)
|
||||
|
||||
_publish_config_change(request, key=key, node_id=node_id, action="set")
|
||||
await _publish_config_change(request)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -3609,7 +3702,7 @@ async def admin_delete_setting(request: Request) -> JSONResponse:
|
||||
|
||||
key = request.path_params["key"]
|
||||
try:
|
||||
validate_key(key)
|
||||
defn = validate_key(key)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
|
||||
|
||||
@@ -3629,9 +3722,9 @@ async def admin_delete_setting(request: Request) -> JSONResponse:
|
||||
ip,
|
||||
)
|
||||
|
||||
_publish_config_change(request, key=key, node_id=node_id, action="delete")
|
||||
await _publish_config_change(request)
|
||||
|
||||
return JSONResponse({"status": "ok", "key": key})
|
||||
return JSONResponse({"status": "ok", "key": key, "default": defn.default})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3640,21 +3733,16 @@ async def admin_delete_setting(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
def _get_registry_url(request: Request) -> str:
|
||||
"""Get the MCP Registry URL from DB settings, config.toml, or default."""
|
||||
"""Get the MCP Registry URL via ConfigStore, config.toml, or default."""
|
||||
from turnstone.core.config import load_config
|
||||
from turnstone.core.mcp_registry import DEFAULT_REGISTRY_URL
|
||||
|
||||
# Check database settings first
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage:
|
||||
try:
|
||||
row = storage.get_system_setting("mcp.registry_url")
|
||||
if row:
|
||||
val = json.loads(row["value"])
|
||||
if val:
|
||||
return str(val)
|
||||
except (KeyError, json.JSONDecodeError, TypeError, AttributeError):
|
||||
pass
|
||||
# ConfigStore: validated + cached
|
||||
config_store = getattr(request.app.state, "config_store", None)
|
||||
if config_store:
|
||||
val = config_store.get("mcp.registry_url")
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
# Fall back to config.toml [mcp] section
|
||||
mcp_cfg = load_config("mcp")
|
||||
@@ -3805,8 +3893,9 @@ async def admin_registry_install(request: Request) -> JSONResponse:
|
||||
|
||||
# Check max servers
|
||||
current = storage.list_mcp_servers()
|
||||
if len(current) >= _MCP_MAX_SERVERS:
|
||||
return JSONResponse({"error": f"Maximum {_MCP_MAX_SERVERS} servers"}, status_code=400)
|
||||
max_servers = _get_mcp_max_servers(request)
|
||||
if len(current) >= max_servers:
|
||||
return JSONResponse({"error": f"Maximum {max_servers} servers"}, status_code=400)
|
||||
|
||||
# Fetch the specific server from the registry
|
||||
registry_url = _get_registry_url(request)
|
||||
@@ -3902,7 +3991,15 @@ async def admin_registry_install(request: Request) -> JSONResponse:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MCP_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
_MCP_MAX_SERVERS = 50
|
||||
_MCP_MAX_SERVERS = 200 # fallback; prefer cluster.mcp_max_servers from storage
|
||||
|
||||
|
||||
def _get_mcp_max_servers(request: Request) -> int:
|
||||
"""Read cluster.mcp_max_servers via ConfigStore (validated + cached)."""
|
||||
config_store = getattr(request.app.state, "config_store", None)
|
||||
if config_store:
|
||||
return int(config_store.get("cluster.mcp_max_servers"))
|
||||
return _MCP_MAX_SERVERS
|
||||
|
||||
|
||||
def _mask_mcp_secrets(server: dict[str, Any], reveal: bool = False) -> dict[str, Any]:
|
||||
@@ -3940,10 +4037,10 @@ async def _collect_mcp_status(
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Query all nodes for MCP status. Returns {node_id: {server_name: status}}."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
|
||||
nodes = collector.get_all_nodes()
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
|
||||
node_id = node.get("node_id", "")
|
||||
@@ -4087,9 +4184,10 @@ async def admin_create_mcp_server(request: Request) -> JSONResponse:
|
||||
|
||||
# Check max servers
|
||||
existing = storage.list_mcp_servers()
|
||||
if len(existing) >= _MCP_MAX_SERVERS:
|
||||
max_servers = _get_mcp_max_servers(request)
|
||||
if len(existing) >= max_servers:
|
||||
return JSONResponse(
|
||||
{"error": f"Maximum {_MCP_MAX_SERVERS} servers"},
|
||||
{"error": f"Maximum {max_servers} servers"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -4291,10 +4389,10 @@ async def admin_delete_mcp_server(request: Request) -> JSONResponse:
|
||||
async def _notify_nodes_mcp_reload(request: Request) -> dict[str, Any]:
|
||||
"""Tell all nodes to re-read the mcp_servers DB table and reconcile."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes, _ = collector.get_nodes(sort_by="activity", limit=1000, offset=0)
|
||||
nodes = collector.get_all_nodes()
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_NODE_FAN_OUT_LIMIT)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
|
||||
node_id = node.get("node_id", "")
|
||||
@@ -4370,6 +4468,7 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
errors: list[str] = []
|
||||
audit_uid, ip = _audit_context(request)
|
||||
current_count = len(storage.list_mcp_servers())
|
||||
max_servers = _get_mcp_max_servers(request)
|
||||
|
||||
for srv_name, cfg in servers.items():
|
||||
srv_name = str(srv_name).strip()[:64]
|
||||
@@ -4379,7 +4478,7 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
if storage.get_mcp_server_by_name(srv_name):
|
||||
skipped.append(srv_name)
|
||||
continue
|
||||
if current_count >= _MCP_MAX_SERVERS:
|
||||
if current_count >= max_servers:
|
||||
errors.append(f"{srv_name}: max servers reached")
|
||||
break
|
||||
|
||||
@@ -4781,8 +4880,9 @@ def main() -> None:
|
||||
help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)",
|
||||
)
|
||||
|
||||
from turnstone.core.config import apply_config
|
||||
from turnstone.core.config import add_config_arg, apply_config
|
||||
|
||||
add_config_arg(parser)
|
||||
apply_config(parser, ["console", "redis", "auth"])
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -4818,13 +4918,13 @@ def main() -> None:
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
collector_token = collector_token_mgr.token
|
||||
log.info("console.collector_jwt_minted")
|
||||
log.info("console.collector_token_manager_created")
|
||||
|
||||
collector = ClusterCollector(
|
||||
broker=broker,
|
||||
poll_interval=args.poll_interval,
|
||||
auth_token=collector_token,
|
||||
auth_token=collector_token if collector_token_mgr is None else "",
|
||||
token_manager=collector_token_mgr,
|
||||
)
|
||||
collector.start()
|
||||
|
||||
@@ -4862,8 +4962,7 @@ def main() -> None:
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
proxy_token = proxy_token_mgr.token
|
||||
log.info("console.proxy_jwt_minted")
|
||||
log.info("console.proxy_token_manager_created")
|
||||
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
|
||||
@@ -4875,7 +4974,7 @@ def main() -> None:
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=auth_storage,
|
||||
proxy_auth_token=proxy_token,
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
)
|
||||
|
||||
@@ -2057,10 +2057,12 @@ var _settingsSectionOrder = [
|
||||
"session",
|
||||
"tools",
|
||||
"server",
|
||||
"cluster",
|
||||
"mcp",
|
||||
"ratelimit",
|
||||
"health",
|
||||
"judge",
|
||||
"skills",
|
||||
"memory",
|
||||
];
|
||||
|
||||
@@ -2070,10 +2072,12 @@ function _settingsSectionLabel(section) {
|
||||
session: "Session",
|
||||
tools: "Tools",
|
||||
server: "Server",
|
||||
cluster: "Cluster",
|
||||
mcp: "MCP",
|
||||
ratelimit: "Rate Limiting",
|
||||
health: "Health",
|
||||
judge: "Judge",
|
||||
skills: "Skills",
|
||||
memory: "Memory",
|
||||
};
|
||||
return labels[section] || section;
|
||||
|
||||
@@ -768,7 +768,7 @@ function _renderGovSkills(items) {
|
||||
t.resource_count +
|
||||
" res</span>";
|
||||
}
|
||||
var editDisabled = t.readonly ? " disabled" : "";
|
||||
var editLabel = t.readonly ? "view" : "edit";
|
||||
var deleteDisabled = "";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
@@ -794,9 +794,9 @@ function _renderGovSkills(items) {
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
'<button class="admin-btn-action" data-edit-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'"' +
|
||||
editDisabled +
|
||||
">edit</button>" +
|
||||
'">' +
|
||||
editLabel +
|
||||
"</button>" +
|
||||
'<button class="admin-btn-danger" data-delete-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'" data-tmpl-name="' +
|
||||
@@ -870,6 +870,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("skill-description").value = "";
|
||||
document.getElementById("skill-tags").value = "";
|
||||
document.getElementById("skill-author").value = "";
|
||||
document.getElementById("skill-version").value = "";
|
||||
document.getElementById("skill-license").value = "";
|
||||
document.getElementById("skill-compatibility").value = "";
|
||||
document.getElementById("skill-activation").value = "named";
|
||||
document.getElementById("ctm-content").value = "";
|
||||
document.getElementById("ctm-variables").textContent = "(none)";
|
||||
@@ -888,11 +891,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("csk-allowed-tools").value = "";
|
||||
document.getElementById("csk-allowed-tools").disabled = false;
|
||||
document.getElementById("csk-enabled").checked = true;
|
||||
document
|
||||
.getElementById("csk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("csk-auto-approve").onchange = function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("create-template-error").style.display = "none";
|
||||
// Clear resource list
|
||||
_pendingResources = [];
|
||||
@@ -949,31 +950,38 @@ function submitCreateTemplate() {
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
document.getElementById("ctm-submit").disabled = true;
|
||||
var csVersion = (document.getElementById("skill-version").value || "").trim();
|
||||
var createBody = {
|
||||
name: name,
|
||||
category: document.getElementById("ctm-category").value,
|
||||
description: (
|
||||
document.getElementById("skill-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("skill-author").value || "").trim(),
|
||||
license: (document.getElementById("skill-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("skill-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("skill-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("ctm-default").checked,
|
||||
model: document.getElementById("csk-model").value.trim(),
|
||||
auto_approve: document.getElementById("csk-auto-approve").checked,
|
||||
temperature: csTemp ? parseFloat(csTemp) : null,
|
||||
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
|
||||
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
|
||||
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
|
||||
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(csAllowedArr),
|
||||
enabled: document.getElementById("csk-enabled").checked,
|
||||
};
|
||||
if (csVersion) createBody.version = csVersion;
|
||||
authFetch("/v1/api/admin/skills", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
category: document.getElementById("ctm-category").value,
|
||||
description: (
|
||||
document.getElementById("skill-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("skill-author").value || "").trim(),
|
||||
activation: document.getElementById("skill-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("ctm-default").checked,
|
||||
model: document.getElementById("csk-model").value.trim(),
|
||||
auto_approve: document.getElementById("csk-auto-approve").checked,
|
||||
temperature: csTemp ? parseFloat(csTemp) : null,
|
||||
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
|
||||
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
|
||||
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
|
||||
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(csAllowedArr),
|
||||
enabled: document.getElementById("csk-enabled").checked,
|
||||
}),
|
||||
body: JSON.stringify(createBody),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
@@ -1052,6 +1060,9 @@ function showEditTemplateModal(tmplId) {
|
||||
}
|
||||
document.getElementById("etm-tags").value = tagsDisplay;
|
||||
document.getElementById("etm-author").value = tmpl.author || "";
|
||||
document.getElementById("etm-version").value = tmpl.version || "";
|
||||
document.getElementById("etm-license").value = tmpl.license || "";
|
||||
document.getElementById("etm-compatibility").value = tmpl.compatibility || "";
|
||||
document.getElementById("etm-activation").value = tmpl.activation || "named";
|
||||
document.getElementById("etm-content").value = tmpl.content;
|
||||
_updateVarsDisplay("etm-content", "etm-variables");
|
||||
@@ -1086,11 +1097,9 @@ function showEditTemplateModal(tmplId) {
|
||||
document.getElementById("esk-allowed-tools").disabled =
|
||||
tmpl.auto_approve || false;
|
||||
document.getElementById("esk-enabled").checked = tmpl.enabled !== false;
|
||||
document
|
||||
.getElementById("esk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("esk-auto-approve").onchange = function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("edit-template-error").style.display = "none";
|
||||
// Scan report section
|
||||
var scanSection = document.getElementById("etm-scan-section");
|
||||
@@ -1180,12 +1189,91 @@ function showEditTemplateModal(tmplId) {
|
||||
});
|
||||
};
|
||||
}
|
||||
// Reset collapsible state before applying readonly rules (prevents state leak
|
||||
// when switching between readonly and editable skills in the same session)
|
||||
var allDetails = document.querySelectorAll(
|
||||
"#edit-template-box .admin-details",
|
||||
);
|
||||
for (var d = 0; d < allDetails.length; d++) allDetails[d].open = false;
|
||||
|
||||
// --- Readonly mode for imported skills ---
|
||||
var isReadonly = tmpl.readonly || false;
|
||||
var editTitle = document.getElementById("edit-template-title");
|
||||
if (editTitle)
|
||||
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
|
||||
// Origin badge — show provenance for installed skills
|
||||
var originBadge = document.getElementById("etm-origin-badge");
|
||||
if (originBadge) {
|
||||
if (isReadonly && tmpl.source_url) {
|
||||
originBadge.textContent = "Installed from \u00a0" + tmpl.source_url;
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else if (isReadonly && tmpl.origin && tmpl.origin !== "manual") {
|
||||
originBadge.textContent = "Installed skill";
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else {
|
||||
originBadge.style.display = "none";
|
||||
}
|
||||
}
|
||||
var submitBtn = document.getElementById("etm-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.style.display = "";
|
||||
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
|
||||
}
|
||||
// Spec/content fields: locked for installed skills (preserve source fidelity)
|
||||
[
|
||||
"etm-name",
|
||||
"etm-category",
|
||||
"etm-description",
|
||||
"etm-tags",
|
||||
"etm-author",
|
||||
"etm-version",
|
||||
"etm-license",
|
||||
"etm-compatibility",
|
||||
"etm-activation",
|
||||
"etm-content",
|
||||
"etm-default",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = isReadonly;
|
||||
});
|
||||
// Runtime config fields: always editable (local settings, not part of SKILL.md spec)
|
||||
[
|
||||
"esk-model",
|
||||
"esk-temperature",
|
||||
"esk-reasoning-effort",
|
||||
"esk-max-tokens",
|
||||
"esk-token-budget",
|
||||
"esk-agent-max-turns",
|
||||
"esk-auto-approve",
|
||||
"esk-enabled",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = false;
|
||||
});
|
||||
// esk-allowed-tools follows auto_approve state, not readonly state
|
||||
var allowedToolsEl = document.getElementById("esk-allowed-tools");
|
||||
if (allowedToolsEl) allowedToolsEl.disabled = tmpl.auto_approve || false;
|
||||
var cancelBtn = document.querySelector("#edit-template-box .modal-cancel");
|
||||
if (cancelBtn) cancelBtn.textContent = isReadonly ? "Close" : "Cancel";
|
||||
// Auto-expand Runtime Config collapsible for installed skills so config is visible
|
||||
if (isReadonly) {
|
||||
var details = document.querySelectorAll(
|
||||
"#edit-template-box .admin-details",
|
||||
);
|
||||
for (var d = 0; d < details.length; d++) details[d].open = true;
|
||||
}
|
||||
// --- Skill Resources ---
|
||||
var resSection = document.getElementById("etm-resources-section");
|
||||
if (resSection) {
|
||||
_loadSkillResources(tmplId, tmpl.readonly || false);
|
||||
_loadSkillResources(tmplId, isReadonly);
|
||||
}
|
||||
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
|
||||
// Focus management
|
||||
if (isReadonly) {
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
} else {
|
||||
document.getElementById("etm-name").focus();
|
||||
}
|
||||
}
|
||||
|
||||
function hideEditTemplateModal() {
|
||||
@@ -1466,31 +1554,38 @@ function submitEditTemplate() {
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
document.getElementById("etm-submit").disabled = true;
|
||||
var esVersion = (document.getElementById("etm-version").value || "").trim();
|
||||
var updateBody = {
|
||||
name: document.getElementById("etm-name").value.trim(),
|
||||
category: document.getElementById("etm-category").value,
|
||||
description: (
|
||||
document.getElementById("etm-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("etm-author").value || "").trim(),
|
||||
license: (document.getElementById("etm-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("etm-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("etm-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("etm-default").checked,
|
||||
model: document.getElementById("esk-model").value.trim(),
|
||||
auto_approve: document.getElementById("esk-auto-approve").checked,
|
||||
temperature: esTemp ? parseFloat(esTemp) : null,
|
||||
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
|
||||
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
|
||||
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
|
||||
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(esAllowedArr),
|
||||
enabled: document.getElementById("esk-enabled").checked,
|
||||
};
|
||||
if (esVersion) updateBody.version = esVersion;
|
||||
authFetch("/v1/api/admin/skills/" + id, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById("etm-name").value.trim(),
|
||||
category: document.getElementById("etm-category").value,
|
||||
description: (
|
||||
document.getElementById("etm-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("etm-author").value || "").trim(),
|
||||
activation: document.getElementById("etm-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("etm-default").checked,
|
||||
model: document.getElementById("esk-model").value.trim(),
|
||||
auto_approve: document.getElementById("esk-auto-approve").checked,
|
||||
temperature: esTemp ? parseFloat(esTemp) : null,
|
||||
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
|
||||
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
|
||||
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
|
||||
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(esAllowedArr),
|
||||
enabled: document.getElementById("esk-enabled").checked,
|
||||
}),
|
||||
body: JSON.stringify(updateBody),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
|
||||
@@ -848,61 +848,100 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
|
||||
<!-- Create Skill Modal -->
|
||||
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
|
||||
<h2 id="create-template-title">Create Skill</h2>
|
||||
<div id="create-template-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="skill-description">Description</label>
|
||||
<textarea id="skill-description" rows="2" placeholder="Brief description for discovery"></textarea>
|
||||
<label for="skill-tags">Tags</label>
|
||||
<input id="skill-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="skill-author">Author</label>
|
||||
<input id="skill-author" type="text" placeholder="Author name">
|
||||
<label for="skill-activation">Activation</label>
|
||||
<select id="skill-activation">
|
||||
<option value="named">Named</option>
|
||||
<option value="default">Default (auto-apply)</option>
|
||||
<option value="search">Search (BM25 discoverable)</option>
|
||||
</select>
|
||||
<label for="ctm-content">Content <span class="label-hint">system message text, use {{model}}, {{ws_id}}, {{node_id}} for placeholders</span></label>
|
||||
<textarea id="ctm-content" rows="6" placeholder="You are a code reviewer using {{model}}..."></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="ctm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Identity</h3>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. code-review" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="skill-description">Description</label>
|
||||
<textarea id="skill-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Manifest</h3>
|
||||
<label for="skill-tags">Tags</label>
|
||||
<input id="skill-tags" type="text" placeholder="python, review, quality">
|
||||
<label for="skill-author">Author</label>
|
||||
<input id="skill-author" type="text" placeholder="Author name">
|
||||
<label for="skill-version">Version</label>
|
||||
<input id="skill-version" type="text" placeholder="1.0.0">
|
||||
<label for="skill-license">License</label>
|
||||
<select id="skill-license">
|
||||
<option value="">— not specified —</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="Apache-2.0">Apache-2.0</option>
|
||||
<option value="GPL-2.0">GPL-2.0</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="LGPL-2.1">LGPL-2.1</option>
|
||||
<option value="LGPL-3.0">LGPL-3.0</option>
|
||||
<option value="AGPL-3.0">AGPL-3.0</option>
|
||||
<option value="BSD-2-Clause">BSD-2-Clause</option>
|
||||
<option value="BSD-3-Clause">BSD-3-Clause</option>
|
||||
<option value="ISC">ISC</option>
|
||||
<option value="MPL-2.0">MPL-2.0</option>
|
||||
<option value="Unlicense">Unlicense</option>
|
||||
<option value="Proprietary">Proprietary</option>
|
||||
</select>
|
||||
<label for="skill-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="skill-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Deployment</h3>
|
||||
<label for="skill-activation">Activation <span class="label-hint">how models discover this skill</span></label>
|
||||
<select id="skill-activation">
|
||||
<option value="named">Named — explicit /skill invocation</option>
|
||||
<option value="default">Default — auto-applied to every session</option>
|
||||
<option value="search">Search — BM25 discoverable</option>
|
||||
</select>
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Apply to new workstreams by default</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-spec-col skill-spec-col-content">
|
||||
<div class="skill-spec-section skill-spec-section-content">
|
||||
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">system message — {{model}}, {{ws_id}}, {{node_id}}</span></h3>
|
||||
<textarea id="ctm-content" class="skill-content-area" placeholder="You are a code reviewer using {{model}}..."></textarea>
|
||||
<div class="skill-vars-row">
|
||||
<span class="skill-vars-label">Variables</span>
|
||||
<div id="ctm-variables" class="skill-vars-display label-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="admin-details">
|
||||
<summary>Session Config <span class="label-hint">optional — applied when skill is selected for a workstream</span></summary>
|
||||
<label for="csk-model">Model</label>
|
||||
<input id="csk-model" type="text" placeholder="Default model">
|
||||
<label for="csk-temperature">Temperature</label>
|
||||
<input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
|
||||
<label for="csk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="csk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<label for="csk-max-tokens">Max Tokens</label>
|
||||
<input id="csk-max-tokens" type="number" min="1" placeholder="System default">
|
||||
<label for="csk-token-budget">Token Budget</label>
|
||||
<input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited">
|
||||
<label for="csk-agent-max-turns">Agent Max Turns</label>
|
||||
<input id="csk-agent-max-turns" type="number" min="1" placeholder="System default">
|
||||
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
|
||||
<div class="skill-config-grid">
|
||||
<div><label for="csk-model">Model</label><input id="csk-model" type="text" placeholder="Default model"></div>
|
||||
<div><label for="csk-temperature">Temperature</label><input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
|
||||
<div>
|
||||
<label for="csk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="csk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="csk-max-tokens">Max Tokens</label><input id="csk-max-tokens" type="number" min="1" placeholder="System default"></div>
|
||||
<div><label for="csk-token-budget">Token Budget</label><input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
|
||||
<div><label for="csk-agent-max-turns">Agent Max Turns</label><input id="csk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
|
||||
</div>
|
||||
<label class="admin-checkbox"><input id="csk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="csk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
|
||||
</details>
|
||||
<details class="admin-details">
|
||||
<summary>Resources <span class="label-hint">optional bundled files (scripts, references, assets)</span></summary>
|
||||
<summary>Resources <span class="label-hint">bundled files (scripts, references, assets)</span></summary>
|
||||
<div id="ctm-resources-list" role="list" aria-live="polite" aria-label="Pending resources"></div>
|
||||
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
|
||||
<label for="ctm-res-path">Path</label>
|
||||
@@ -923,55 +962,95 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
|
||||
<!-- Edit Skill Modal -->
|
||||
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
|
||||
<h2 id="edit-template-title">Edit Skill</h2>
|
||||
<div id="etm-origin-badge" class="skill-origin-badge" style="display:none"></div>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="etm-id" type="hidden">
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-description">Description</label>
|
||||
<textarea id="etm-description" rows="2" placeholder="Brief description for discovery"></textarea>
|
||||
<label for="etm-tags">Tags</label>
|
||||
<input id="etm-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="etm-author">Author</label>
|
||||
<input id="etm-author" type="text" placeholder="Author name">
|
||||
<label for="etm-activation">Activation</label>
|
||||
<select id="etm-activation">
|
||||
<option value="named">Named</option>
|
||||
<option value="default">Default (auto-apply)</option>
|
||||
<option value="search">Search (BM25 discoverable)</option>
|
||||
</select>
|
||||
<label for="etm-content">Content</label>
|
||||
<textarea id="etm-content" rows="6"></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="etm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Identity</h3>
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-description">Description</label>
|
||||
<textarea id="etm-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Manifest</h3>
|
||||
<label for="etm-tags">Tags</label>
|
||||
<input id="etm-tags" type="text" placeholder="python, review, quality">
|
||||
<label for="etm-author">Author</label>
|
||||
<input id="etm-author" type="text" placeholder="Author name">
|
||||
<label for="etm-version">Version</label>
|
||||
<input id="etm-version" type="text" placeholder="1.0.0">
|
||||
<label for="etm-license">License</label>
|
||||
<select id="etm-license">
|
||||
<option value="">— not specified —</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="Apache-2.0">Apache-2.0</option>
|
||||
<option value="GPL-2.0">GPL-2.0</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="LGPL-2.1">LGPL-2.1</option>
|
||||
<option value="LGPL-3.0">LGPL-3.0</option>
|
||||
<option value="AGPL-3.0">AGPL-3.0</option>
|
||||
<option value="BSD-2-Clause">BSD-2-Clause</option>
|
||||
<option value="BSD-3-Clause">BSD-3-Clause</option>
|
||||
<option value="ISC">ISC</option>
|
||||
<option value="MPL-2.0">MPL-2.0</option>
|
||||
<option value="Unlicense">Unlicense</option>
|
||||
<option value="Proprietary">Proprietary</option>
|
||||
</select>
|
||||
<label for="etm-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="etm-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Deployment</h3>
|
||||
<label for="etm-activation">Activation <span class="label-hint">how models discover this skill</span></label>
|
||||
<select id="etm-activation">
|
||||
<option value="named">Named — explicit /skill invocation</option>
|
||||
<option value="default">Default — auto-applied to every session</option>
|
||||
<option value="search">Search — BM25 discoverable</option>
|
||||
</select>
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Apply to new workstreams by default</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-spec-col skill-spec-col-content">
|
||||
<div class="skill-spec-section skill-spec-section-content">
|
||||
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">{{model}}, {{ws_id}}, {{node_id}}</span></h3>
|
||||
<textarea id="etm-content" class="skill-content-area"></textarea>
|
||||
<div class="skill-vars-row">
|
||||
<span class="skill-vars-label">Variables</span>
|
||||
<div id="etm-variables" class="skill-vars-display label-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="admin-details">
|
||||
<summary>Session Config <span class="label-hint">applied when skill is selected for a workstream</span></summary>
|
||||
<label for="esk-model">Model</label>
|
||||
<input id="esk-model" type="text" placeholder="Default model">
|
||||
<label for="esk-temperature">Temperature</label>
|
||||
<input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
|
||||
<label for="esk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="esk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<label for="esk-max-tokens">Max Tokens</label>
|
||||
<input id="esk-max-tokens" type="number" min="1" placeholder="System default">
|
||||
<label for="esk-token-budget">Token Budget</label>
|
||||
<input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited">
|
||||
<label for="esk-agent-max-turns">Agent Max Turns</label>
|
||||
<input id="esk-agent-max-turns" type="number" min="1" placeholder="System default">
|
||||
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
|
||||
<div class="skill-config-grid">
|
||||
<div><label for="esk-model">Model</label><input id="esk-model" type="text" placeholder="Default model"></div>
|
||||
<div><label for="esk-temperature">Temperature</label><input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
|
||||
<div>
|
||||
<label for="esk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="esk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="esk-max-tokens">Max Tokens</label><input id="esk-max-tokens" type="number" min="1" placeholder="System default"></div>
|
||||
<div><label for="esk-token-budget">Token Budget</label><input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
|
||||
<div><label for="esk-agent-max-turns">Agent Max Turns</label><input id="esk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
|
||||
</div>
|
||||
<label class="admin-checkbox"><input id="esk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="esk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="esk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
|
||||
@@ -1166,6 +1166,14 @@
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
.admin-modal input:disabled, .admin-modal select:disabled, .admin-modal textarea:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-highlight);
|
||||
border-color: var(--border);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
|
||||
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
.admin-modal textarea { resize: vertical; min-height: 40px; }
|
||||
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
@@ -1199,6 +1207,138 @@
|
||||
.admin-details summary .label-hint { font-weight: 400; }
|
||||
.admin-details label:first-of-type { margin-top: 4px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Skill Spec Modal — two-column manifest layout
|
||||
Left: Identity / Manifest / Deployment | Right: Skill Content
|
||||
========================================================================== */
|
||||
.admin-modal-skill { padding: 28px 28px 24px; }
|
||||
|
||||
.skill-spec-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.55fr;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-spec-col-meta {
|
||||
border-right: 1px solid var(--border);
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.skill-spec-col-content {
|
||||
padding-left: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-spec-section { margin-bottom: 14px; }
|
||||
.skill-spec-section:last-child { margin-bottom: 0; }
|
||||
|
||||
/* h3 used for screen-reader heading structure; reset UA defaults */
|
||||
h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.skill-spec-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
padding-bottom: 5px;
|
||||
margin: 14px 0 6px;
|
||||
border-bottom: 1px solid var(--accent-dim);
|
||||
}
|
||||
.skill-spec-section:first-child .skill-spec-heading { margin-top: 0; }
|
||||
.skill-spec-heading .label-hint {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.skill-spec-section-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-content-area {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11.5px !important;
|
||||
line-height: 1.65 !important;
|
||||
}
|
||||
|
||||
.skill-vars-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.skill-vars-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-vars-display { font-size: 11px; }
|
||||
|
||||
.skill-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px 14px;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
.skill-config-grid > div { min-width: 0; }
|
||||
|
||||
/* Origin badge — shown for remotely installed (readonly) skills */
|
||||
.skill-origin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cyan);
|
||||
background: rgba(103, 232, 249, 0.07);
|
||||
border: 1px solid rgba(103, 232, 249, 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 14px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-origin-badge::before {
|
||||
content: "\2193";
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.skill-spec-body { grid-template-columns: 1fr; }
|
||||
.skill-spec-col-meta {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skill-spec-col-content { padding-left: 0; }
|
||||
.skill-config-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.modal-buttons { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
|
||||
@@ -7,14 +7,15 @@ call after mutations to create a persistent audit trail.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def record_audit(
|
||||
|
||||
+13
-7
@@ -18,11 +18,9 @@ always accessible without authentication.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -40,7 +38,9 @@ if TYPE_CHECKING:
|
||||
|
||||
from turnstone.core.oidc import OIDCConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -1010,7 +1010,7 @@ async def handle_auth_status(request: Request) -> Response:
|
||||
users = storage.list_users()
|
||||
has_users = len(users) > 0
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("Failed to check user existence for auth status", exc_info=True)
|
||||
|
||||
# OIDC configuration
|
||||
oidc_config = getattr(request.app.state, "oidc_config", None)
|
||||
@@ -1079,8 +1079,10 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
except Exception:
|
||||
log.error("Failed to assign admin role to first user %s — aborting setup", user_id)
|
||||
# Roll back the user creation so setup can be retried
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
storage.delete_user(user_id)
|
||||
except Exception:
|
||||
log.error("Failed to roll back user %s during setup abort", user_id, exc_info=True)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to assign admin role. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
@@ -1092,8 +1094,10 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
|
||||
log.error(
|
||||
"First user %s has no permissions after role assignment — aborting setup", user_id
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
storage.delete_user(user_id)
|
||||
except Exception:
|
||||
log.error("Failed to roll back user %s during setup abort", user_id, exc_info=True)
|
||||
return JSONResponse(
|
||||
{"error": "Failed to load permissions. Ensure migrations have run."},
|
||||
status_code=503,
|
||||
@@ -1229,8 +1233,10 @@ async def handle_oidc_callback(request: Request, audience: str) -> Response:
|
||||
return RedirectResponse("/?oidc_error=Too+many+login+attempts", status_code=302)
|
||||
|
||||
# Lazy cleanup of expired pending states
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
storage.cleanup_expired_oidc_states(300)
|
||||
except Exception:
|
||||
log.debug("OIDC state cleanup failed", exc_info=True)
|
||||
|
||||
def _record_oidc_failure() -> None:
|
||||
if login_limiter is not None:
|
||||
|
||||
+64
-10
@@ -1,28 +1,59 @@
|
||||
"""Unified configuration for turnstone.
|
||||
|
||||
Loads ``~/.config/turnstone/config.toml`` and applies values as argparse defaults.
|
||||
Precedence: CLI args > env vars > config file > hardcoded defaults.
|
||||
Loads config.toml and applies values as argparse defaults.
|
||||
Precedence: CLI args > config file > hardcoded defaults.
|
||||
|
||||
Config file resolution:
|
||||
1. ``--config PATH`` CLI flag (via ``add_config_arg`` pre-parser)
|
||||
2. ``$TURNSTONE_CONFIG`` environment variable
|
||||
3. ``~/.config/turnstone/config.toml`` (default)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
CONFIG_DIR = Path("~/.config/turnstone").expanduser()
|
||||
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
||||
_DEFAULT_CONFIG_PATH = CONFIG_DIR / "config.toml"
|
||||
|
||||
# Resolved config path — set by set_config_path() or $TURNSTONE_CONFIG
|
||||
_config_path: Path | None = None
|
||||
|
||||
# Cache: None = not loaded yet, {} = loaded but empty/missing
|
||||
_cache: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _resolve_config_path() -> Path:
|
||||
"""Return the effective config file path."""
|
||||
if _config_path is not None:
|
||||
return _config_path
|
||||
env = os.environ.get("TURNSTONE_CONFIG", "").strip()
|
||||
if env:
|
||||
return Path(env).expanduser()
|
||||
return _DEFAULT_CONFIG_PATH
|
||||
|
||||
|
||||
def set_config_path(path: str) -> None:
|
||||
"""Override the config file path.
|
||||
|
||||
Invalidates the cache so subsequent ``load_config()`` calls re-read
|
||||
from the new path. Typically called from ``add_config_arg()``.
|
||||
"""
|
||||
global _config_path, _cache
|
||||
_config_path = Path(path).expanduser()
|
||||
_cache = None # invalidate cache so next load_config() re-reads
|
||||
|
||||
|
||||
def load_config(section: str | None = None) -> dict[str, Any]:
|
||||
"""Load config.toml and return the full dict or a specific section.
|
||||
|
||||
@@ -32,11 +63,12 @@ def load_config(section: str | None = None) -> dict[str, Any]:
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = {}
|
||||
if CONFIG_PATH.is_file():
|
||||
cfg_path = _resolve_config_path()
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
_cache = tomllib.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
_cache = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
log.warning("Failed to parse %s: %s", CONFIG_PATH, exc)
|
||||
log.warning("Failed to parse %s: %s", cfg_path, exc)
|
||||
if section:
|
||||
result = _cache.get(section, {})
|
||||
return result if isinstance(result, dict) else {}
|
||||
@@ -73,6 +105,7 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"search": "tool_search",
|
||||
"search_threshold": "tool_search_threshold",
|
||||
"search_max_results": "tool_search_max_results",
|
||||
"web_search_backend": "web_search_backend",
|
||||
},
|
||||
"server": {
|
||||
"host": "host",
|
||||
@@ -156,8 +189,6 @@ def get_tavily_key() -> str | None:
|
||||
|
||||
Precedence: config.toml [api] tavily_key -> $TAVILY_API_KEY
|
||||
"""
|
||||
import os
|
||||
|
||||
global _tavily_key, _tavily_key_loaded
|
||||
if _tavily_key_loaded:
|
||||
return _tavily_key
|
||||
@@ -202,6 +233,29 @@ def apply_config(parser: argparse.ArgumentParser, sections: list[str]) -> None:
|
||||
parser.set_defaults(**defaults)
|
||||
|
||||
|
||||
def add_config_arg(parser: argparse.ArgumentParser) -> None:
|
||||
"""Add ``--config`` to *parser* and resolve the path before returning.
|
||||
|
||||
Uses a separate pre-parser (``add_help=False``) so ``--help`` on the
|
||||
main parser still works and shows config-derived defaults.
|
||||
"""
|
||||
import argparse as _ap
|
||||
import sys
|
||||
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="Path to config.toml (default: $TURNSTONE_CONFIG or ~/.config/turnstone/config.toml)",
|
||||
)
|
||||
# Pre-parse only --config without intercepting --help
|
||||
pre = _ap.ArgumentParser(add_help=False)
|
||||
pre.add_argument("--config", default=None)
|
||||
pre_args, _ = pre.parse_known_args(sys.argv[1:])
|
||||
if pre_args.config:
|
||||
set_config_path(pre_args.config)
|
||||
|
||||
|
||||
def warn_migrated_settings() -> None:
|
||||
"""Log warnings for config.toml keys that are now managed by ConfigStore.
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ ConfigStore) — it is a standalone tool, not a cluster node.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.settings_registry import (
|
||||
SETTINGS,
|
||||
deserialize_value,
|
||||
@@ -33,7 +33,7 @@ from turnstone.core.settings_registry import (
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Subprocess environment scrubbing.
|
||||
|
||||
Builds a sanitized copy of ``os.environ`` that strips secrets
|
||||
(API keys, tokens, passwords) while preserving variables needed
|
||||
for normal tool operation (PATH, HOME, locale, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# Env var names that are always preserved regardless of pattern matching.
|
||||
_SAFE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"SHELL",
|
||||
"LANG",
|
||||
"TERM",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"EDITOR",
|
||||
"VISUAL",
|
||||
"COLORTERM",
|
||||
"COLUMNS",
|
||||
"LINES",
|
||||
"PWD",
|
||||
"OLDPWD",
|
||||
"HOSTNAME",
|
||||
"LOGNAME",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"SSH_AUTH_SOCK",
|
||||
"GPG_AGENT_INFO",
|
||||
"SHLVL",
|
||||
"MANWIDTH",
|
||||
"MAN_KEEP_FORMATTING",
|
||||
"LESS",
|
||||
"LESSOPEN",
|
||||
"LESSCLOSE",
|
||||
"LESSPIPE",
|
||||
"LESSCHARSET",
|
||||
}
|
||||
)
|
||||
|
||||
# Prefixes that are always preserved (locale, XDG, etc.).
|
||||
_SAFE_PREFIXES: tuple[str, ...] = ("LC_", "XDG_")
|
||||
|
||||
# Suffixes that cause a variable to be scrubbed (e.g. *_KEY, *_TOKEN).
|
||||
# Suffix matching avoids false positives on MONKEYTYPE, KEYBOARD_LAYOUT, etc.
|
||||
_SECRET_SUFFIXES: tuple[str, ...] = (
|
||||
"_KEY",
|
||||
"_SECRET",
|
||||
"_TOKEN",
|
||||
"_PASSWORD",
|
||||
"_CREDENTIAL",
|
||||
"_CREDENTIALS",
|
||||
)
|
||||
|
||||
# Exact names that are always scrubbed (even if they don't match patterns).
|
||||
_EXPLICIT_SCRUB: frozenset[str] = frozenset(
|
||||
{
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"TURNSTONE_JWT_SECRET",
|
||||
"TURNSTONE_AUTH_TOKEN",
|
||||
"TURNSTONE_DISCORD_TOKEN",
|
||||
"TURNSTONE_GITHUB_TOKEN",
|
||||
"TURNSTONE_OIDC_CLIENT_SECRET",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"GCP_SERVICE_ACCOUNT_KEY",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"DATABASE_URL",
|
||||
"TURNSTONE_DB_URL",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_secret(name: str) -> bool:
|
||||
"""Return True if *name* looks like a secret variable."""
|
||||
if name in _EXPLICIT_SCRUB:
|
||||
return True
|
||||
upper = name.upper()
|
||||
return any(upper.endswith(sfx) for sfx in _SECRET_SUFFIXES)
|
||||
|
||||
|
||||
def _is_safe(name: str) -> bool:
|
||||
"""Return True if *name* should always be preserved."""
|
||||
if name in _SAFE_NAMES:
|
||||
return True
|
||||
return any(name.startswith(pfx) for pfx in _SAFE_PREFIXES)
|
||||
|
||||
|
||||
def scrubbed_env(
|
||||
extra: dict[str, str] | None = None,
|
||||
passthrough: list[str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return a copy of ``os.environ`` with secrets removed.
|
||||
|
||||
Args:
|
||||
extra: Additional variables to merge on top (e.g. ``MANWIDTH``).
|
||||
passthrough: Explicit variable names to preserve even if they
|
||||
match secret patterns (operator override).
|
||||
"""
|
||||
passthrough_set = frozenset(passthrough) if passthrough else frozenset()
|
||||
env: dict[str, str] = {}
|
||||
for name, value in os.environ.items():
|
||||
if name in passthrough_set or _is_safe(name):
|
||||
env[name] = value
|
||||
elif _is_secret(name):
|
||||
continue
|
||||
else:
|
||||
env[name] = value
|
||||
if extra:
|
||||
env.update(extra)
|
||||
return env
|
||||
@@ -3,15 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai import OpenAI
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class CircuitState(enum.Enum):
|
||||
@@ -149,11 +150,44 @@ class BackendHealthMonitor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _probe_loop(self) -> None:
|
||||
"""Background: probe backend every interval."""
|
||||
"""Background: probe backend every interval.
|
||||
|
||||
An initial jitter (derived from the PID) staggers probes across
|
||||
cluster nodes so they don't all hit the LLM backend at once.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Deterministic per-process jitter: spread across half the interval
|
||||
jitter = ((os.getpid() * 2654435761) & 0x7FFFFFFF) / 0x7FFFFFFF * (self._probe_interval / 2)
|
||||
self._stop_event.wait(jitter)
|
||||
while not self._stop_event.is_set():
|
||||
self._stop_event.wait(self._probe_interval)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
# When circuit is OPEN, only probe after cooldown expires.
|
||||
with self._lock:
|
||||
if self._state == CircuitState.OPEN:
|
||||
elapsed = time.monotonic() - self._last_state_change
|
||||
remaining = self._cooldown - elapsed
|
||||
if remaining > 0:
|
||||
# Wait precisely for cooldown rather than skipping
|
||||
# a full probe_interval (which could overshoot).
|
||||
self._lock.release()
|
||||
try:
|
||||
self._stop_event.wait(remaining)
|
||||
finally:
|
||||
self._lock.acquire()
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
# Transition to HALF_OPEN for the probe. The background
|
||||
# probe itself is the single HALF_OPEN request — keep
|
||||
# _half_open_permit False so concurrent user requests
|
||||
# are blocked until the probe completes.
|
||||
self._state = CircuitState.HALF_OPEN
|
||||
self._half_open_permit = False
|
||||
self._last_state_change = time.monotonic()
|
||||
log.info("Circuit breaker HALF_OPEN: cooldown elapsed, probing")
|
||||
self._update_metrics()
|
||||
success = self._probe_once()
|
||||
if success:
|
||||
self.record_success()
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
@@ -20,12 +19,14 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from turnstone.core.providers._protocol import LLMProvider
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
@@ -975,7 +976,7 @@ class IntentJudge:
|
||||
"""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, h_verdict)
|
||||
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)
|
||||
@@ -990,7 +991,6 @@ class IntentJudge:
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
heuristic: IntentVerdict,
|
||||
) -> IntentVerdict | None:
|
||||
"""Run LLM judge for a single tool call. Returns verdict or None."""
|
||||
start = time.monotonic()
|
||||
|
||||
@@ -149,6 +149,24 @@ def configure_logging(
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _ensure_stdlib_factory() -> None:
|
||||
"""Ensure structlog routes through stdlib even before configure_logging().
|
||||
|
||||
Without this, ``structlog.get_logger()`` defaults to ``PrintLogger``
|
||||
which bypasses stdlib handlers (and pytest caplog). Calling
|
||||
``configure_logging()`` later overwrites this minimal config.
|
||||
"""
|
||||
cfg = structlog.get_config()
|
||||
if not isinstance(cfg.get("logger_factory"), structlog.stdlib.LoggerFactory):
|
||||
structlog.configure(
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
)
|
||||
|
||||
|
||||
_ensure_stdlib_factory()
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""Return a structlog bound logger backed by the stdlib."""
|
||||
result: structlog.stdlib.BoundLogger = structlog.get_logger(name)
|
||||
|
||||
@@ -22,7 +22,6 @@ import asyncio
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
@@ -41,8 +40,9 @@ from mcp.client.stdio import stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from turnstone.core.config import load_config
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = logging.getLogger("turnstone.mcp")
|
||||
log = get_logger("turnstone.mcp")
|
||||
|
||||
_DEFAULT_REFRESH_INTERVAL: float = 14400 # 4 hours
|
||||
|
||||
@@ -162,9 +162,11 @@ class MCPClientManager:
|
||||
future = asyncio.run_coroutine_threadsafe(self._connect_all(), self._loop)
|
||||
self._connected.wait(timeout=30)
|
||||
# Surface any exception from _connect_all (unlikely — per-server errors are caught)
|
||||
if future.done() and future.exception():
|
||||
self._error = str(future.exception())
|
||||
log.error("MCP initialization error: %s", self._error)
|
||||
if future.done() and not future.cancelled():
|
||||
exc = future.exception()
|
||||
if exc:
|
||||
self._error = str(exc)
|
||||
log.error("MCP initialization error: %s", self._error)
|
||||
|
||||
async def _connect_all(self) -> None:
|
||||
"""Connect to every configured server (runs on the background loop)."""
|
||||
@@ -224,7 +226,9 @@ class MCPClientManager:
|
||||
log.warning("MCP server '%s' has no command configured", name)
|
||||
await stack.aclose()
|
||||
return
|
||||
env = {**os.environ, **cfg.get("env", {})}
|
||||
from turnstone.core.env import scrubbed_env
|
||||
|
||||
env = scrubbed_env(extra=cfg.get("env", {}))
|
||||
params = StdioServerParameters(
|
||||
command=command,
|
||||
args=cfg.get("args", []),
|
||||
@@ -1415,7 +1419,7 @@ def create_mcp_client(
|
||||
if rows:
|
||||
db_names = {r["name"] for r in rows}
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("Failed to load DB-managed MCP servers", exc_info=True)
|
||||
|
||||
servers = load_mcp_config(config_path, storage=storage)
|
||||
if not servers:
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -400,6 +401,17 @@ def resolve_install_config(
|
||||
raise MCPRegistryError(f"Required URL variable '{var_name}' not provided")
|
||||
url = url.replace(placeholder, value)
|
||||
|
||||
# Validate URL after substitution to prevent SSRF-style redirection
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise MCPRegistryError(
|
||||
f"Invalid URL scheme '{parsed.scheme}' after variable substitution"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise MCPRegistryError("Invalid URL (hostname is missing) after variable substitution")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise MCPRegistryError("URLs with embedded credentials are not allowed in MCP remotes")
|
||||
|
||||
# Build headers dict (required keys only — values provided by user at install time)
|
||||
headers: dict[str, str] = {}
|
||||
for h in remote.headers:
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
All functions maintain their existing signatures for consumers (session.py,
|
||||
server.py, cli.py). The actual storage implementation lives in
|
||||
``turnstone.core.storage``.
|
||||
|
||||
The no-raise contract is preserved — callers never see exceptions from this
|
||||
module. All failures are logged so storage issues are visible in logs
|
||||
rather than silently swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def normalize_key(key: str) -> str:
|
||||
"""Normalize a memory key for consistent lookup."""
|
||||
@@ -37,7 +43,7 @@ def save_message(
|
||||
tool_calls: str | None = None,
|
||||
) -> None:
|
||||
"""Log a message to the conversations table."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().save_message(
|
||||
ws_id,
|
||||
role,
|
||||
@@ -48,6 +54,8 @@ def save_message(
|
||||
provider_data,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to save message for ws=%s role=%s", ws_id, role, exc_info=True)
|
||||
|
||||
|
||||
def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
@@ -55,6 +63,7 @@ def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return get_storage().load_messages(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load messages for ws=%s", ws_id, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -70,22 +79,28 @@ def register_workstream(
|
||||
skill_version: int = 0,
|
||||
) -> None:
|
||||
"""Persist a new workstream (no-op if already exists)."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().register_workstream(
|
||||
ws_id, node_id, name, state, skill_id=skill_id, skill_version=skill_version
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to register workstream ws=%s", ws_id, exc_info=True)
|
||||
|
||||
|
||||
def update_workstream_state(ws_id: str, state: str) -> None:
|
||||
"""Update a workstream's state."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().update_workstream_state(ws_id, state)
|
||||
except Exception:
|
||||
log.warning("Failed to update workstream state ws=%s state=%s", ws_id, state, exc_info=True)
|
||||
|
||||
|
||||
def update_workstream_name(ws_id: str, name: str) -> None:
|
||||
"""Update a workstream's display name."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().update_workstream_name(ws_id, name)
|
||||
except Exception:
|
||||
log.warning("Failed to update workstream name ws=%s", ws_id, exc_info=True)
|
||||
|
||||
|
||||
def list_workstreams(node_id: str | None = None, limit: int = 100) -> list[Any]:
|
||||
@@ -93,6 +108,7 @@ def list_workstreams(node_id: str | None = None, limit: int = 100) -> list[Any]:
|
||||
try:
|
||||
return get_storage().list_workstreams(node_id, limit)
|
||||
except Exception:
|
||||
log.warning("Failed to list workstreams", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -101,6 +117,7 @@ def list_workstreams_with_history(limit: int = 20) -> list[Any]:
|
||||
try:
|
||||
return get_storage().list_workstreams_with_history(limit)
|
||||
except Exception:
|
||||
log.warning("Failed to list workstreams with history", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -109,6 +126,7 @@ def delete_workstream(ws_id: str) -> bool:
|
||||
try:
|
||||
return get_storage().delete_workstream(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to delete workstream ws=%s", ws_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -120,6 +138,7 @@ def prune_workstreams(
|
||||
try:
|
||||
orphans, stale = get_storage().prune_workstreams(retention_days)
|
||||
except Exception:
|
||||
log.warning("Failed to prune workstreams", exc_info=True)
|
||||
return (0, 0)
|
||||
|
||||
if log_fn and (orphans or stale):
|
||||
@@ -140,6 +159,7 @@ def resolve_workstream(alias_or_id: str) -> str | None:
|
||||
try:
|
||||
return get_storage().resolve_workstream(alias_or_id)
|
||||
except Exception:
|
||||
log.warning("Failed to resolve workstream alias=%s", alias_or_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -148,8 +168,10 @@ def resolve_workstream(alias_or_id: str) -> str | None:
|
||||
|
||||
def save_workstream_config(ws_id: str, config: dict[str, str]) -> None:
|
||||
"""Persist workstream configuration key/value pairs."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().save_workstream_config(ws_id, config)
|
||||
except Exception:
|
||||
log.warning("Failed to save workstream config ws=%s", ws_id, exc_info=True)
|
||||
|
||||
|
||||
def load_workstream_config(ws_id: str) -> dict[str, str]:
|
||||
@@ -157,6 +179,7 @@ def load_workstream_config(ws_id: str) -> dict[str, str]:
|
||||
try:
|
||||
return get_storage().load_workstream_config(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to load workstream config ws=%s", ws_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -168,6 +191,7 @@ def get_skill_by_name(name: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return get_storage().get_prompt_template_by_name(name)
|
||||
except Exception:
|
||||
log.warning("Failed to get skill name=%s", name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -176,14 +200,23 @@ def list_default_skills(org_id: str = "") -> list[dict[str, Any]]:
|
||||
try:
|
||||
return get_storage().list_default_templates(org_id)
|
||||
except Exception:
|
||||
log.warning("Failed to list default skills", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def list_skills_by_activation(activation: str) -> list[dict[str, Any]]:
|
||||
def list_skills_by_activation(
|
||||
activation: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
limit: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return skills filtered by activation value, ordered by name."""
|
||||
try:
|
||||
return get_storage().list_skills_by_activation(activation)
|
||||
return get_storage().list_skills_by_activation(
|
||||
activation, enabled_only=enabled_only, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to list skills by activation=%s", activation, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -195,6 +228,7 @@ def set_workstream_alias(ws_id: str, alias: str) -> bool:
|
||||
try:
|
||||
return get_storage().set_workstream_alias(ws_id, alias)
|
||||
except Exception:
|
||||
log.warning("Failed to set alias ws=%s alias=%s", ws_id, alias, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -203,13 +237,16 @@ def get_workstream_display_name(ws_id: str) -> str | None:
|
||||
try:
|
||||
return get_storage().get_workstream_display_name(ws_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get display name ws=%s", ws_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
"""Set or update the auto-generated title for a workstream."""
|
||||
with contextlib.suppress(Exception):
|
||||
try:
|
||||
get_storage().update_workstream_title(ws_id, title)
|
||||
except Exception:
|
||||
log.warning("Failed to update title ws=%s", ws_id, exc_info=True)
|
||||
|
||||
|
||||
# -- Conversation search -------------------------------------------------------
|
||||
@@ -220,6 +257,7 @@ def search_history(query: str, limit: int = 20) -> list[Any]:
|
||||
try:
|
||||
return get_storage().search_history(query, limit)
|
||||
except Exception:
|
||||
log.warning("Failed to search history", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -228,6 +266,7 @@ def search_history_recent(limit: int = 20) -> list[Any]:
|
||||
try:
|
||||
return get_storage().search_history_recent(limit)
|
||||
except Exception:
|
||||
log.warning("Failed to search recent history", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -273,6 +312,7 @@ def save_structured_memory(
|
||||
return existing["memory_id"], old_content
|
||||
return "", None
|
||||
except Exception:
|
||||
log.warning("Failed to save structured memory name=%s", name, exc_info=True)
|
||||
return "", None
|
||||
|
||||
|
||||
@@ -282,6 +322,7 @@ def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "
|
||||
try:
|
||||
return get_storage().delete_structured_memory(name, scope, scope_id)
|
||||
except Exception:
|
||||
log.warning("Failed to delete structured memory name=%s", name, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -290,6 +331,7 @@ def delete_structured_memory_by_id(memory_id: str) -> bool:
|
||||
try:
|
||||
return get_storage().delete_structured_memory_by_id(memory_id)
|
||||
except Exception:
|
||||
log.warning("Failed to delete structured memory id=%s", memory_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -305,6 +347,7 @@ def list_structured_memories(
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to list structured memories", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -321,9 +364,31 @@ def search_structured_memories(
|
||||
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to search structured memories", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def touch_structured_memories(keys: list[tuple[str, str, str]]) -> int:
|
||||
"""Batch-touch memories (bump last_accessed, increment access_count).
|
||||
|
||||
Each key is ``(name, scope, scope_id)``. Duplicates are removed so each
|
||||
distinct memory is touched at most once. Returns count of rows updated.
|
||||
"""
|
||||
if not keys:
|
||||
return 0
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
unique: list[tuple[str, str, str]] = []
|
||||
for k in keys:
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
unique.append(k)
|
||||
try:
|
||||
return get_storage().touch_structured_memories(unique)
|
||||
except Exception:
|
||||
log.warning("Failed to touch structured memories", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def count_structured_memories(mem_type: str = "", scope: str = "", scope_id: str = "") -> int:
|
||||
"""Count structured memories with optional type/scope filter."""
|
||||
try:
|
||||
@@ -331,4 +396,5 @@ def count_structured_memories(mem_type: str = "", scope: str = "", scope_id: str
|
||||
mem_type=mem_type, scope=scope, scope_id=scope_id
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to count structured memories", exc_info=True)
|
||||
return 0
|
||||
|
||||
@@ -7,15 +7,15 @@ resilience when the primary model is unreachable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from turnstone.core.config import load_config
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.providers import LLMProvider, create_client, create_provider
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -278,7 +278,9 @@ def detect_model(
|
||||
client: Any,
|
||||
log_fn: Any = print,
|
||||
provider: str = "openai",
|
||||
) -> tuple[str, int | None]:
|
||||
*,
|
||||
fatal: bool = True,
|
||||
) -> tuple[str | None, int | None]:
|
||||
"""Auto-detect the model and context window from the API's models endpoint.
|
||||
|
||||
Returns ``(model_id, context_window)`` where *context_window* is
|
||||
@@ -289,13 +291,25 @@ def detect_model(
|
||||
For local single-model servers (vLLM, llama.cpp), uses the first model.
|
||||
|
||||
Calls ``log_fn`` for informational messages (defaults to ``print``).
|
||||
Raises ``SystemExit`` on failure.
|
||||
|
||||
When *fatal* is ``True`` (default), raises ``SystemExit`` on failure.
|
||||
When ``False``, returns ``(None, None)`` so the server can start in
|
||||
degraded mode (useful for cluster deployments where the LLM backend
|
||||
may not be available at startup).
|
||||
"""
|
||||
try:
|
||||
models = client.models.list()
|
||||
# Use a short timeout for startup detection — the default OpenAI client
|
||||
# timeout is 600s read which blocks the main thread for minutes when the
|
||||
# backend is unreachable (TCP SYN dropped → kernel retransmit timeout).
|
||||
# Disable retries (default 2) to avoid compounding the delay.
|
||||
fast_client = client.with_options(timeout=10.0, max_retries=0)
|
||||
models = fast_client.models.list()
|
||||
if not models.data:
|
||||
log_fn("Error: No models found at server. Use --model to specify.")
|
||||
raise SystemExit(1)
|
||||
if fatal:
|
||||
log_fn("Error: No models found at server. Use --model to specify.")
|
||||
raise SystemExit(1)
|
||||
log_fn("Warning: No models found at server — starting in degraded mode.")
|
||||
return None, None
|
||||
|
||||
all_ids = [x.id for x in models.data]
|
||||
selected_id = _select_best_model(all_ids, provider)
|
||||
@@ -321,6 +335,10 @@ def detect_model(
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
log_fn(f"Error: Could not connect to server: {e}")
|
||||
log_fn("Is the model server running? Start it or use --base-url to point elsewhere.")
|
||||
raise SystemExit(1) from e
|
||||
if fatal:
|
||||
log_fn(f"Error: Could not connect to server: {e}")
|
||||
log_fn("Is the model server running? Start it or use --base-url to point elsewhere.")
|
||||
raise SystemExit(1) from e
|
||||
log_fn(f"Warning: Could not connect to LLM backend: {e}")
|
||||
log_fn("Starting in degraded mode — requests will fail until backend is reachable.")
|
||||
return None, None
|
||||
|
||||
+66
-2
@@ -10,10 +10,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import logging
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -21,7 +22,9 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Sentinel password hash for OIDC-provisioned users.
|
||||
# Not a valid bcrypt hash -- verify_password() always rejects it.
|
||||
@@ -212,6 +215,61 @@ def load_oidc_config() -> OIDCConfig:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_localhost(hostname: str) -> bool:
|
||||
"""Return True if *hostname* refers to the loopback interface."""
|
||||
return hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".localhost")
|
||||
|
||||
|
||||
def validate_issuer_url(url: str) -> None:
|
||||
"""Validate an OIDC issuer URL to prevent SSRF.
|
||||
|
||||
Rejects:
|
||||
- Non-HTTPS URLs (except localhost for development)
|
||||
- URLs with embedded credentials (userinfo)
|
||||
- Hostnames that resolve to private/internal/loopback IP addresses
|
||||
|
||||
Raises :class:`OIDCError` on validation failure.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
# Require a hostname.
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise OIDCError(f"OIDC issuer URL has no hostname: {url}")
|
||||
|
||||
# Reject embedded credentials — redact userinfo from error message.
|
||||
if parsed.username or parsed.password:
|
||||
raise OIDCError("OIDC issuer URL must not contain embedded credentials (userinfo)")
|
||||
|
||||
# Require HTTPS (allow HTTP only for localhost development).
|
||||
if parsed.scheme != "https":
|
||||
if parsed.scheme == "http" and _is_localhost(hostname):
|
||||
pass # Allow http://localhost for dev
|
||||
else:
|
||||
raise OIDCError(f"OIDC issuer URL must use HTTPS (got {parsed.scheme}://): {url}")
|
||||
|
||||
# Resolve hostname and reject non-globally-routable addresses.
|
||||
try:
|
||||
addr_infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise OIDCError(f"OIDC issuer hostname cannot be resolved: {hostname}") from exc
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addr_infos:
|
||||
try:
|
||||
addr = ipaddress.ip_address(sockaddr[0])
|
||||
except ValueError as exc:
|
||||
raise OIDCError(
|
||||
f"OIDC issuer hostname resolved to invalid IP {sockaddr[0]!r}: {hostname}"
|
||||
) from exc
|
||||
if not addr.is_global and not _is_localhost(hostname):
|
||||
raise OIDCError(f"OIDC issuer URL resolves to non-public address ({addr}): {url}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -225,6 +283,12 @@ async def discover_oidc(config: OIDCConfig) -> OIDCConfig:
|
||||
if not config.issuer:
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
try:
|
||||
validate_issuer_url(config.issuer)
|
||||
except OIDCError as exc:
|
||||
log.warning("OIDC issuer URL rejected: %s", exc)
|
||||
return dataclasses.replace(config, enabled=False)
|
||||
|
||||
url = config.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user