Compare commits

...

27 Commits

Author SHA1 Message Date
Patrick Buckley b180770eff chore: bump version to 1.0.0 2026-04-02 17:09:31 -07:00
Patrick Buckley 9d2e11f2be chore: update classifier to Production/Stable for 1.0 2026-04-02 17:09:10 -07:00
Patrick Buckley 57080f4615 chore: release infrastructure for dual-track stable/experimental (#282)
* chore: release infrastructure for dual-track stable/experimental

CI/CD changes for the 1.0 release:

- Gate PyPI publish and Docker publish on CI success via workflow_run
- Add docker-publish.yml: builds and pushes to GHCR with smart tagging
  (stable gets :X.Y.Z/:X.Y/:stable/:latest, pre-release gets :experimental)
- Add stable/* and v* tags to CI and docker-scan triggers
- Remove stale [mq] extra and types-redis from CI (Redis MQ deleted)
- Remove stale redis from Renovate package rules

Release tooling:
- scripts/release.sh: bump version, uv lock, commit, tag (with --push)
- docs/releasing.md: documents stable/experimental workflow

Docker:
- Add /workspace mount point (WORKSPACE_MOUNT env var, defaults to empty volume)
- Update .env.example: remove stale Redis/auth-token refs, add workspace/model/discord

README:
- Remove beta warning, add hero image and release tracks table

* fix: derive release tag from git instead of workflow_run.head_branch

Use git tag --points-at HEAD after checkout to resolve the release
tag instead of relying on workflow_run.head_branch, which may not
reliably be the tag name for tag-triggered CI runs. Both publish
and docker-publish workflows now skip cleanly when no v* tag exists
at the checked-out commit.
2026-04-02 17:08:07 -07:00
Patrick Buckley 45f27fb2a7 fix: replay plan review prompt on SSE reconnection (#281)
* fix: replay plan review prompt on SSE reconnection

Plan approval prompts were lost when a user navigated to the server
web UI from the console dashboard (triggering a new SSE connection).
Tool approvals stored pending state in _pending_approval and replayed
it on reconnection, but plan reviews used fire-and-forget _enqueue
with no persistent state.

Mirror the _pending_approval pattern: store _pending_plan_review
before blocking, replay it in events_sse for new SSE clients, and
clear it on resolution. Without this fix, plan reviews silently
timed out after 1 hour and were treated as approval.

* test: add plan review SSE replay regression tests

Covers pending state lifecycle: stored during on_plan_review, cleared
on resolve_plan, available for SSE reconnection replay.
2026-04-02 16:47:51 -07:00
Patrick Buckley ebc8e75285 fix(sdk): add token_factory param to sync TurnstoneServer and TurnstoneConsole (#280)
The async variants accepted token_factory for auto-rotating JWTs via
ServiceTokenManager, but the sync wrappers did not expose or forward
the parameter. External SDK users calling the sync clients with
token_factory got a TypeError.
2026-04-02 15:42:17 -07:00
Patrick Buckley 485af92f7f fix(console): top-align admin grid rows to fix badge/input drift (#279)
Settings rows and admin table rows used align-items: center, which
caused inputs and source badges to drift away from their labels when
descriptions wrapped to multiple lines. Switch to align-items: start
so controls stay next to their label names regardless of row height.

Add 2px top margin on settings toggles to pixel-align with text input
top padding in start-aligned rows.
2026-04-02 15:20:29 -07:00
Patrick Buckley 664d44c109 fix(examples): rewrite mcp-cluster-ops to use console SDK for cluster… (#278)
* fix(examples): rewrite mcp-cluster-ops to use console SDK for cluster routing

The example MCP server was broken after the direct HTTP transport
refactor — it used TurnstoneServer (single-node) for cluster ops that
require TurnstoneConsole (cluster gateway). Rewrites dispatch flow to:
route via console → SSE stream from node → cleanup via console.

- Switch from TurnstoneServer to TurnstoneConsole for node listing and
  workstream routing (TURNSTONE_CONSOLE_URL replaces TURNSTONE_SERVER_URL)
- Add proper workstream lifecycle: create via routing proxy, stream from
  node, close in finally block with leak-safe ws_id guard
- Catch dispatch exceptions in run_on_node for structured JSON errors
- Extract _extract_node_ids helper, remove dead n.get("id") fallback
- Normalise _console_kwargs to always include token key
- Rewrite tests against Console+Server mocks (36 → 44 tests)

* fix(examples): paginate node listing and clarify auth in README

Address Copilot review feedback on #278:
- _list_nodes_sync now paginates via offset/limit loop so clusters
  with >100 nodes are fully discovered
- README step 2 now mentions token passthrough for authenticated clusters
- New test_paginates_large_clusters verifies multi-page fetch (45 tests)
2026-04-02 15:12:41 -07:00
renovate[bot] 3cf9485169 chore(deps): update dependency mermaid to v11.14.0 (#276)
* chore(deps): update dependency mermaid to v11.14.0

* chore: download vendored JS files

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-01 19:46:24 -07:00
renovate[bot] d43b9d1647 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.3 (#275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 19:46:14 -07:00
renovate[bot] ea8d9d1798 chore(deps): lock file maintenance (#277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 19:46:06 -07:00
Patrick Buckley d9aa50dca9 chore: trivy ignore 5 transitive npm CVEs (minimatch, picomatch, tar) 2026-04-01 19:42:03 -07:00
Patrick Buckley 6f89d0cc13 chore: bump version to 0.9.10 2026-04-01 19:40:17 -07:00
Patrick Buckley 62d2a0fe6a fix: remove non-auth support from bootstrap wizard (#274)
* fix: remove non-auth support from bootstrap wizard

Auth is now mandatory for all deployments. Remove the
TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN
required in the wizard's system prompt.

* fix: remove auth disable support from runtime and infra

Remove AuthConfig.enabled field — auth is always on. Drop
TURNSTONE_AUTH_ENABLED env var, config toggle, and the
check_request bypass. Update compose.yaml, Helm chart,
Terraform, docs, and tests to match.

* feat: deprecate config tokens, require JWT secret, prefer JWT auth

Phase 1 of config-token removal:

- load_jwt_secret() now exits with error if no secret is configured
  (was: silently auto-generated ephemeral secret)
- _authenticate_token() logs deprecation warning on config token use
- CLI /cluster commands use ServiceTokenManager when JWT secret is set
- turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set
- Update bootstrap wizard, docker.md, security.md to mark
  TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required
- Console test fixtures use auth token + headers (auth always enforced)

* feat: add service scope for inter-service JWT auth

Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens
bypass require_permission() RBAC checks, replacing the old
empty-user-id bypass that config tokens relied on.

All ServiceTokenManager instances that need admin access now include
"service" in their scopes (console proxy, channel gateway, CLI,
admin CLI). Read-only services (collector, notification) unchanged.

* feat: phase 2 config token deprecation

- SDK doc examples now show API tokens (ts_) instead of config tokens
- Remove _get_config_token() from admin CLI (dead code)
- Block config token exchange in handle_auth_login — only password
  and API token login allowed
- Update login tests to use password-based auth instead of config
  token exchange

* feat: phase 3 — remove config tokens entirely

Complete removal of config-file token authentication:

- Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch
  branch, and config token loading from load_auth_config()
- Remove auth_config parameter from _authenticate_token() and
  check_request() — callers updated throughout
- Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts,
  Terraform, turnstone.example.toml
- Remove --auth-token CLI flags from turnstone, turnstone-admin,
  and turnstone-console
- Simplify console main() — always use ServiceTokenManager
  (no fallback to static tokens)
- Delete config-token-specific tests, rewrite check_request and
  integration tests to use JWT auth with proper audience claims
- Remove all config token references from docs (security.md,
  docker.md, sdk.md, console.md, architecture.md, bootstrap prompt)

* fix: address code review findings

- Fix 33 broken tests: add JWT auth to test_api_versioning,
  test_console_routing_proxy, test_tls_admin, test_tls_manager,
  test_server_live (jwt_secret + audience-scoped auth headers)
- Add TestRequirePermissionServiceScope: 4 tests covering the
  service scope RBAC bypass path
- Remove stale comments referencing config tokens in auth.py and
  console/server.py
- Remove dead proxy_auth_token parameter from console create_app()
  and static token fallback in _proxy_auth_headers()
- Remove TURNSTONE_AUTH_TOKEN from env.py scrub list

* fix: address Copilot review — JWT audience, compose require secret

- CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager
  (console validates audience, JWTs without it were rejected)
- Admin CLI tls-list: same audience fix
- compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset
- SDK console: fix default port from 8081 to 8090

* test: add auth enforcement tests for TLS admin endpoints

5 new tests: unauthenticated requests return 401 (list, renew,
delete), read-only-scoped requests return 403 (renew, delete).
Closes the TLS auth enforcement test gap noted in PROGRESS.md.

* fix: address remaining Copilot review feedback

- Fix token_source="config" → "test" in TLS test fixtures
- Fix AuthResult.token_source docstring to include service origins
- Require TURNSTONE_JWT_SECRET in cluster compose profile (:?)
- Helm: add auth.jwtSecret + auth.existingSecret values, wire
  TURNSTONE_JWT_SECRET into secret.yaml and both deployments
- Terraform: replace auth_token with jwt_secret variable + secret,
  remove orphaned auth_token resources and IAM reference
- Remove [[auth.tokens]] from security.md config example

* fix: address full code review — 10 findings

Critical:
- Terraform: replace concat(common_env, auth_env) with common_env
  (auth_env local was removed but still referenced)
- Channel gateway: remove hmac static token auth from _check_auth(),
  use JWT-only validation. Remove --auth-token CLI arg from channel
- Rebalancer: add token_manager support so migration requests carry
  JWT auth (was sending unauthenticated POST to /internal/migrate)

Major:
- Guard _permissions_to_scopes() against "service" privilege
  escalation from DB role permissions
- Remove dead AuthConfig class, load_auth_config(), and all
  auth_config parameters from create_app() signatures
- Helm: inject JWT secret for both inline and existingSecret paths

Minor:
- Remove dead auth_token param from ClusterCollector
- Remove empty TestLoadAuthConfig class
- Short JWT secret now exits instead of warning
- Compose: add generation command comment above JWT_SECRET
- Clean stale config token references from 6 doc files
- Clean stale AUTH_TOKEN reference from bootstrap wizard prompt

* fix: remove remaining stale config token references from docs

- channels.md: remove --auth-token from options table
- oidc.md: remove "config-file tokens still work" claim
- security.md: remove config token section, fix JWT secret docs
  (now required/exits, no ephemeral fallback), remove hmac from
  ASCII diagram, remove --auth-token reference
2026-04-01 19:38:24 -07:00
Patrick Buckley 5df37f83a7 fix: populate model in _last_usage so usage-by-model records correctly (#273)
* fix: populate model in _last_usage so usage-by-model records correctly

_last_usage was built purely from UsageInfo token counts, never
including a "model" key.  server.py's on_status() fell back to
model="" for every record_usage_event call, so GROUP BY model
collapsed all rows into a single empty-key bucket.

* fix: inject model at emission time, preserve dict[str, int] typing

Address Copilot review: keep _last_usage as dict[str, int] for type
safety, inject "model" from self.model when passing to on_status().
This also fixes stale model after /model switch since the value is
read fresh each time.
2026-04-01 13:21:48 -07:00
Patrick Buckley 651c4d98cd fix: MCP tools not surfacing after Sync to Nodes, update Anthropic to… (#272)
* fix: MCP tools not surfacing after Sync to Nodes, update Anthropic tool search

Three fixes:

1. session_factory closure captured mcp_client=None when no --mcp-config
   was passed at startup. internal_mcp_reload created a new MCPClientManager
   on app.state but the factory never saw it. New workstreams got 0 MCP tools.
   Fix: mutable _mcp_ref list shared between factory and reload handler.

2. Anthropic dropped the date suffix from tool_search_tool_bm25_20251119
   and now requires name == type. Updated constant and tool definition.

3. Add diagnostic logging around API errors (provider, model, base_url,
   message counts, full exception chain) and workstream resume (pre/post
   provider state, alias resolution warnings).

Also adds Node.js 24 LTS to Dockerfile via multi-stage copy for npx-based
MCP servers.

* fix: address Copilot review — set_storage on reload, sanitize log output

- Call mcp_mgr.set_storage(storage) when internal_mcp_reload creates a
  new MCPClientManager so prompt sync works for post-startup servers
- Strip query params from base_url before logging (may contain API keys
  in some vLLM deployments)
- Split API error logging: concise warning (type names only) + separate
  debug with exc_info=True for full traceback when needed

* chore: remove DDG MCP sidecar, web_search uses built-in ddgs client

The DuckDuckGo MCP server container is redundant — the built-in
DuckDuckGoClient (via ddgs package, included in all extras) auto-detects
when no Tavily key is configured. Removes the ddg-search service,
ddgCluster profile, and mcp-ddg.json config file.
2026-04-01 12:42:09 -07:00
Patrick Buckley e901e859c7 fix: materialize skill resources to disk for subprocess access (#271)
* fix: materialize skill resources to disk for subprocess access

Skill-bundled scripts stored in skill_resources were loaded into memory
but never written to disk, causing FileNotFoundError when the model
tried to execute them. Write resources to a per-workstream temp directory
on skill load, expose via SKILL_RESOURCES_DIR env var and PATH, clean up
on skill change or session close.

* fix: pre-flight validation warns when skill references missing resources

Scan rendered skill content for path references (scripts/foo.py, etc.)
and compare against bundled skill_resources. Warn via on_info if any
referenced paths are not bundled, so operators see the gap at skill
activation rather than at runtime FileNotFoundError.

* fix: address PR #271 review feedback

- Fix trailing colon in PATH when $PATH is empty (cwd-on-PATH risk)
- Move try/except inside per-resource loop so one bad write doesn't
  abort all resources
- Explicit encoding="utf-8" for deterministic writes across locales

* fix: address PR #271 review round 2

- Normalize available paths in _validate_skill_resources() to match
  referenced paths (both sides use os.path.normpath now)
- Fix flaky traversal test: assert inside base dir, not escaped path
2026-03-31 22:34:24 -07:00
Patrick Buckley 200dcfeac5 chore: trivy ignore CVE-2026-4046 (glibc iconv DoS, fix deferred) 2026-03-31 18:01:33 -07:00
Patrick Buckley 8c414feba2 chore: bump version to 0.9.9 2026-03-31 17:45:34 -07:00
Patrick Buckley d7cea053b6 fix: prevent cross-workstream SSE event contamination in WebUI (#270)
Multiple browser tabs open to the same server could see workstream
names, states, and content mixed up between workstreams when creating,
closing, and switching tabs rapidly.

Root causes and fixes:
- Global SSE ws_created events were never handled — other tabs never
  learned about new workstreams, causing blank names and stale tab bars
- SSE reconnection assigned all stale panes to the first workstream
  instead of deduplicating; now uses two-pass assignment with tracking
- switchTab left the old EventSource open while reassigning pane.wsId,
  creating a window for events to leak; now disconnects SSE first
- Per-workstream events carried no ws_id — server now stamps ws_id on
  all events via _enqueue (shallow copy); client handleEvent drops
  events with mismatched ws_id as defense-in-depth
- Plan dialog used pane.wsId at resolve time (could drift after tab
  switch); now captures ws_id when the dialog opens
- Global ws_closed could reassign panes before per-ws SSE finished
  draining; now disconnects per-ws SSE immediately on close
2026-03-31 17:43:25 -07:00
Patrick Buckley c45e98462b fix: prompt policy endpoints used non-existent admin.prompt_policies permission
The 5 prompt policy admin endpoints required "admin.prompt_policies"
but the builtin-admin role only grants "admin.policies". Changed to
match the existing permission used by tool policy endpoints.
2026-03-31 17:43:01 -07:00
Patrick Buckley e17cbe35a5 fix: harden Discord bot against gateway disconnects and SSE failures (#269)
* fix: harden Discord bot against gateway disconnects and SSE failures

- Isolate Discord API failures from SSE stream — _on_ws_event exceptions
  no longer kill the SSE connection and cause missed events
- Fix broken exponential backoff on 4xx/5xx (delay was reset on every
  attempt); skip aiter_sse() on error responses
- Add read timeout (90s) to SSE httpx client so half-open TCP
  connections are detected and recovered
- Re-resolve node URL on each SSE reconnect attempt
- Add on_resumed handler to recover SSE tasks that died during brief
  gateway disconnects (on_ready is not called on session resume)
- Sync slash commands only on first on_ready to avoid Discord rate limits

* fix: SSE backoff on 4xx/5xx and retrieve dead task exceptions

- Replace `continue` with raise+catch so 4xx/5xx errors hit the
  exponential backoff path instead of tight-looping
- Retrieve task exceptions in _purge_dead_sse_tasks to suppress
  "Task exception was never retrieved" warnings and log the cause
2026-03-31 17:28:59 -07:00
Patrick Buckley fd47c23177 chore: bump version to 0.9.8 2026-03-31 16:36:48 -07:00
Patrick Buckley 9fe988b1be fix: bootstrap DATABASE_URL scheme, Docker fd exhaustion, proxy error… (#268)
* fix: bootstrap DATABASE_URL scheme, Docker fd exhaustion, proxy error handling

- Bootstrap system prompt now generates postgresql+psycopg:// URLs
  (required by SQLAlchemy 2.0 + psycopg3)
- Dockerfile splits bytecode compilation into a separate step to avoid
  exhausting file descriptors during uv sync (os error 24)
- Bootstrap OpenAI completion path guards against non-spec responses
  from proxies (Open WebUI, LiteLLM) with actionable error messages

* fix: address Copilot review — unique tool_call IDs, robust compileall path

- Tool call ID fallback uses random hex instead of sequential index
  to avoid cross-turn collisions with non-spec proxies
- compileall targets .venv/ (not .venv/lib/) for layout portability
2026-03-31 16:30:32 -07:00
Patrick Buckley 3ce66960bc feat: modular system message composition with admin prompt policies (… (#267)
* feat: modular system message composition with admin prompt policies (#267)

Replace the monolithic persona+tools block in _init_system_messages()
with a modular composition harness (turnstone/prompts/). System messages
are now assembled from five typed layers: BASE (persona), ENV (client
surface — web/cli/chat), CONTEXT (datetime, timezone, username), TOOLS
(usage patterns), and POLICIES (behavioral rules with tool gating).

Prompt policies are admin-managed via a new Prompts tab in the Governance
group (CRUD with modal forms, tool gating, priority ordering, enable/disable).
DB policies override file-based defaults by name; file-based policies serve
as deployment defaults. Migration 031 adds the prompt_policies table.

ClientType is threaded end-to-end from channel adapters through the SDK,
HTTP API, WorkstreamManager, and session factory to ChatSession. Discord
sessions now receive chat-optimized formatting (no tables, no Mermaid,
concise output) instead of the web UI's rich markdown instructions.

* fix: address CI failures and Copilot review feedback

- Add client_type param to CLI session_factory (mypy protocol match)
- Add prompt policy CRUD to PostgreSQL backend (test-postgres CI)
- Fix ClientType resolution: compare against enum values, not members
- Fix null client_type coercion (body.get returns None, not "")
- Use local time with astimezone() instead of UTC with local tz name
- Sanitize tool_gate in update endpoint (coerce null to empty string)
2026-03-31 15:25:55 -07:00
Patrick Buckley 8a852a12e3 remove poll-interval from compose.yml 2026-03-31 13:46:09 -07:00
Patrick Buckley 9a518657a3 feat: replace console HTTP polling with persistent SSE streams (#266)
* feat: replace console HTTP polling with persistent SSE streams

Console collector now subscribes to each server node's /v1/api/events/global
SSE stream for real-time state updates instead of polling /v1/api/dashboard
and /health every 15 seconds.

Server changes:
- Emit ws_created/ws_closed events on global queue from create/close handlers
- Add node_snapshot on SSE connect (workstreams, health, aggregate)
- Add ?expected_node_id= identity verification (409 on mismatch)
- Add health_changed callback to BackendHealthMonitor circuit breaker
- Add periodic aggregate emitter thread (10s)

Console collector changes:
- Single asyncio event loop on one thread multiplexes all SSE connections
  (scales to 1000+ nodes vs thread-per-node)
- Discovery loop spawns/cancels async SSE tasks per node
- Snapshot reconciliation on connect, delta application for live events
- Fix ws_state→cluster_state event type mismatch
- Remove polling code (poll_interval, max_poll_workers, --poll-interval CLI)

SDK changes:
- Add NodeSnapshotEvent, HealthChangedEvent, AggregateEvent dataclasses
- Add stream_node_events() method (async + sync)

* fix: address review feedback on node event streams

- Fix stop() to let SSE manager exit naturally instead of force-stopping
  the event loop (ensures finally cleanup runs)
- Guard against empty/invalid SSE data from ping frames
- Treat missing node_id as identity mismatch (409) when expected_node_id
  is provided
- Fix stale docstring on _update_metrics
2026-03-31 11:28:39 -07:00
Patrick Buckley c424176c73 feat: show thinking indicators, tool calls, and results in Discord th… (#265)
* feat: show thinking indicators, tool calls, and results in Discord threads

Discord threads now surface real-time activity during multi-tool chains
instead of appearing idle. ThinkingStart/Stop events display a transient
italic status message. ToolInfoEvent sends a per-tool "running" embed
that ToolResultEvent edits in-place with the result (FIFO matching by
tool name, fallback to new message). Includes backtick-injection escaping
in tool output. Visibility respects auto-approve config so tool calls
always appear somewhere.

* fix: address review feedback on Discord action visibility

Delete thinking messages in unsubscribe/stale-route cleanup (not just
pop state). Sanitize tool-call previews (escape backticks, strip
mentions). Fix format_tool_result docstring re ellipsis line count.
Add regression test for triple-backtick escaping.

* fix: disable approval buttons on server-side resolution (timeout)

Handle ApprovalResolvedEvent in _on_ws_event to disable buttons and
grey out the approval embed when the server resolves the approval
externally (timeout, auto-approve from another client). Extract
disable_message_buttons helper from views.py so it works on a plain
Message (not just an Interaction).

* fix: reply with guidance when user DMs the bot directly

Non-reply DMs were silently ignored. Now sends a message directing the
user to /ask or @mention in a server channel.

* fix: address round 2 review feedback

- Show error items (policy-denied) in ToolInfoEvent unconditionally
- Match ToolResultEvent to ToolInfoEvent by call_id (deterministic),
  fall back to name-based FIFO when call_id is absent
- Escape triple backticks before truncating in format_tool_result so
  the 500-char limit holds after expansion

* fix: edit thinking message in-place instead of delete-and-recreate

ThinkingStopEvent now preserves the message for the next event to reuse.
ContentEvent seeds StreamingMessage with the thinking message so the
first flush edits it. ToolInfoEvent edits the thinking message into the
first tool embed. Eliminates the visible delete → gap → new message
flicker during thinking → tool call transitions.

* feat: separate tool call and result into distinct Discord messages

ToolInfoEvent sends a "running" embed (light grey, tool name + preview).
ToolResultEvent marks it "Done"/"Error" (color + title update) and sends
the result as a separate message. This gives clear lifecycle tracing in
chat-style threads where verbosity aids readability.

* fix: show running embed for all tools and remove redundant name prefix

ToolInfoEvent now shows a running embed for every tool regardless of
needs_approval — the running indicator and approval dialog serve
different purposes. Removes the needs_approval/auto_approve filter
that caused missing running embeds when tools were approved via
"Always Approve" or server-side auto-approve.

Also drops the redundant **name** prefix from format_tool_result since
the embed title already carries the tool name.

* fix: concise logging for SSE connection failures

Catch httpx.ConnectError/ConnectTimeout separately from the generic
exception handler. Logs url and error string instead of the full
httpx/httpcore stack trace, which is noise for expected transient
connection failures during node restarts.

* fix: address round 3 review feedback

- Pop _pending_approval_msgs on button click so ApprovalResolvedEvent
  doesn't double-update the embed title (e.g. "Approved - Approved")
- Remove unused name/is_error params from format_tool_result — embed
  title carries the name, embed color carries the error status
- Fix _disable_buttons docstring to mention title update
2026-03-31 09:28:19 -07:00
107 changed files with 8965 additions and 4784 deletions
+34 -14
View File
@@ -1,29 +1,49 @@
# =============================================================================
# Turnstone Environment Variables
# Copy to .env and adjust values for your deployment
# Copy to .env and adjust values for your deployment.
#
# Usage:
# Single node: docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# =============================================================================
# -- LLM Backend --------------------------------------------------------------
LLM_BASE_URL=http://host.docker.internal:8000/v1
OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-... # Set instead for Anthropic provider
# TAVILY_API_KEY=tvly-... # For web search fallback (local models only)
OPENAI_API_KEY=dummy
# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic
# TAVILY_API_KEY=tvly-... # Web search fallback (local models only)
# MODEL=# Override default model alias
# -- Database (production profile) --------------------------------------------
# -- Authentication (required) ------------------------------------------------
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex
# -- Database ------------------------------------------------------------------
# Single-node default is SQLite (zero config). Set these for PostgreSQL:
# DB_BACKEND=postgresql
# POSTGRES_USER=turnstone
# POSTGRES_PASSWORD=changeme
# DATABASE_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone
# -- Redis ---------------------------------------------------------------------
# REDIS_PASSWORD=
# REDIS_PORT=6379
# -- Authentication ------------------------------------------------------------
# TURNSTONE_AUTH_ENABLED=true
# TURNSTONE_AUTH_TOKEN=your-secret-token
# TURNSTONE_JWT_SECRET=python -c "import secrets; print(secrets.token_hex(32))"
# -- Ports ---------------------------------------------------------------------
# SERVER_PORT=8080
# CONSOLE_PORT=8090
# -- Workspace -----------------------------------------------------------------
# Bind-mount a host directory into the container at /workspace.
# The model can read/write files here. Default: empty Docker volume.
# WORKSPACE_MOUNT=/path/to/your/project
# -- Agent behavior ------------------------------------------------------------
# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only)
# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file
# -- Discord channel gateway ---------------------------------------------------
# TURNSTONE_DISCORD_TOKEN=
# TURNSTONE_DISCORD_GUILD=0
# -- Cluster (profile: cluster) -----------------------------------------------
# These are set per-node in compose.yaml; only override for custom topologies.
# TURNSTONE_NODE_ID=node-1
# TURNSTONE_ADVERTISE_URL=http://server-1:8080
+1 -2
View File
@@ -83,7 +83,7 @@
{
"description": "Infrastructure dependencies",
"groupName": "Infrastructure",
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
"matchPackageNames": ["structlog", "croniter", "discord.py"],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": ["patch"]
@@ -101,7 +101,6 @@
"matchPackageNames": [
"ruff",
"mypy",
"types-redis",
"pytest",
"pytest-cov",
"pre-commit"
+7 -6
View File
@@ -2,9 +2,10 @@ name: CI
on:
push:
branches: [main]
branches: [main, "stable/*"]
tags: ["v*"]
pull_request:
branches: [main]
branches: [main, "stable/*"]
jobs:
lint:
@@ -25,8 +26,8 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install mypy types-redis
- run: pip install -e ".[mq]"
- run: pip install mypy
- run: pip install -e ".[all]"
- run: mypy turnstone/
test:
@@ -39,7 +40,7 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[test,mq]"
- run: pip install -e ".[test]"
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
if: always()
@@ -68,7 +69,7 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.14"
- run: pip install -e ".[test,mq,postgres]"
- run: pip install -e ".[test,postgres]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
+75
View File
@@ -0,0 +1,75 @@
name: Publish Docker Image
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve release tag
id: tag
run: |
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
if [ -z "$TAG" ]; then
echo "No v* tag at HEAD — skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
if: steps.tag.outputs.skip == 'false'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute Docker tags
if: steps.tag.outputs.skip == 'false'
id: tags
env:
REF: ${{ steps.tag.outputs.tag }}
run: |
VERSION="${REF#v}"
FULL="${REGISTRY}/${IMAGE_NAME}"
FULL="${FULL,,}"
if echo "$VERSION" | grep -qE '(a|b|rc)[0-9]+$'; then
TAGS="${FULL}:${VERSION},${FULL}:experimental"
else
MINOR="${VERSION%.*}"
TAGS="${FULL}:${VERSION},${FULL}:${MINOR},${FULL}:stable,${FULL}:latest"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
if: steps.tag.outputs.skip == 'false'
- name: Build and push
if: steps.tag.outputs.skip == 'false'
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -2,7 +2,7 @@ name: Docker Security Scan
on:
push:
branches: [main]
branches: [main, "stable/*"]
schedule:
- cron: "0 6 * * 1" # Weekly Monday 06:00 UTC
+27 -3
View File
@@ -1,8 +1,9 @@
name: Publish to PyPI
on:
push:
tags: ["v*"]
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: write
@@ -10,20 +11,43 @@ permissions:
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Resolve release tag
id: tag
run: |
TAG=$(git tag --points-at HEAD | grep '^v' | head -1)
if [ -z "$TAG" ]; then
echo "No v* tag at HEAD — skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
if: steps.tag.outputs.skip == 'false'
with:
python-version: "3.14"
- run: pip install build
if: steps.tag.outputs.skip == 'false'
- run: python -m build
if: steps.tag.outputs.skip == 'false'
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
if: steps.tag.outputs.skip == 'false'
- name: Create GitHub Release
if: steps.tag.outputs.skip == 'false'
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, '-') }}
prerelease: ${{ contains(steps.tag.outputs.tag, 'a') || contains(steps.tag.outputs.tag, 'b') || contains(steps.tag.outputs.tag, 'rc') }}
+21
View File
@@ -17,3 +17,24 @@ CVE-2026-27135
# Affects libsystemd0, libudev1
# https://avd.aquasec.com/nvd/cve-2026-29111
CVE-2026-29111
# glibc iconv() DoS — fix_deferred, no patched libc in Debian 13 yet
# Affects libc-bin, libc6
# https://avd.aquasec.com/nvd/cve-2026-4046
CVE-2026-4046
# minimatch ReDoS — transitive npm dep (MCP server), no direct exposure
# https://avd.aquasec.com/nvd/cve-2026-27903
CVE-2026-27903
# https://avd.aquasec.com/nvd/cve-2026-27904
CVE-2026-27904
# picomatch ReDoS — transitive npm dep, no direct exposure
# https://avd.aquasec.com/nvd/cve-2026-33671
CVE-2026-33671
# node-tar path traversal — transitive npm dep, not used to extract untrusted archives
# https://avd.aquasec.com/nvd/cve-2026-29786
CVE-2026-29786
# https://avd.aquasec.com/nvd/cve-2026-31802
CVE-2026-31802
+15 -6
View File
@@ -8,7 +8,7 @@ FROM python:3.14-slim
LABEL org.opencontainers.image.title="turnstone" \
org.opencontainers.image.description="Multi-node AI orchestration platform"
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /usr/local/bin/uv
# Remove the slim image's man page exclusion so man-db has actual content
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
@@ -18,23 +18,29 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco
libpq5 git curl jq man-db manpages procps file \
&& rm -rf /var/lib/apt/lists/*
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:24-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Non-root user
RUN useradd --create-home --shell /bin/bash turnstone
WORKDIR /app
# Compile bytecode for faster startup
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 all
--no-compile --extra all
# Install the project itself
COPY turnstone/ turnstone/
RUN uv sync --frozen --no-dev \
--extra all
--no-compile --extra all
# Compile bytecode in a separate step (avoids fd exhaustion during install)
RUN python -m compileall -q .venv turnstone/
# Add venv to PATH so entry points are found
ENV PATH="/app/.venv/bin:$PATH"
@@ -49,6 +55,9 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /data
RUN chown turnstone:turnstone /data
# Workspace mount point — bind-mount a host directory here
RUN mkdir -p /workspace && chown turnstone:turnstone /workspace
USER turnstone
ENTRYPOINT ["entrypoint.sh"]
+12 -1
View File
@@ -7,10 +7,21 @@
Multi-node AI orchestration platform. Deploy tool-using AI agents across a cluster of servers with direct HTTP routing, interactive interfaces, and enterprise governance.
> **Beta — Use at your own risk.** APIs, configuration formats, and database schemas may change between versions without migration paths.
<p align="center">
<img src="docs/assets/hero.png" alt="Turnstone console — multi-workstream AI orchestration with mermaid diagrams" width="960"/>
</p>
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.
### Release Tracks
| Track | Install | Docker | Description |
|-------|---------|--------|-------------|
| **Stable** | `pip install turnstone` | `ghcr.io/turnstonelabs/turnstone:stable` | Production-grade. Bugfixes only. |
| **Experimental** | `pip install turnstone --pre` | `ghcr.io/turnstonelabs/turnstone:experimental` | New features. May have rough edges. |
See [docs/releasing.md](docs/releasing.md) for the full release process.
## What it does
Turnstone gives LLMs tools — shell, files, search, web, planning — and orchestrates multi-turn conversations where the model investigates, acts, and reports.
+12 -55
View File
@@ -6,7 +6,6 @@
# Single node: docker compose --profile production up
# Production (PG): DB_BACKEND=postgresql docker compose --profile production up
# 10-node cluster: docker compose --profile cluster up
# Cluster + DDG: docker compose --profile ddgCluster up
# =============================================================================
name: turnstone
@@ -17,6 +16,7 @@ networks:
volumes:
turnstone-data:
workspace:
postgres-data:
services:
@@ -28,7 +28,6 @@ services:
profiles:
- production
- cluster
- ddgCluster
command:
- postgres
- -c
@@ -82,15 +81,14 @@ services:
- "${SERVER_PORT:-8080}:8080"
volumes:
- turnstone-data:/data
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment:
- LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-dummy}
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-}
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- MODEL=${MODEL:-}
- MCP_CONFIG=${MCP_CONFIG:-}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
@@ -105,9 +103,6 @@ services:
postgres:
condition: service_healthy
required: false
ddg-search:
condition: service_healthy
required: false
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
@@ -127,13 +122,11 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
ports:
- "${CONSOLE_PORT:-8090}:8090"
environment:
- TURNSTONE_AUTH_ENABLED=${TURNSTONE_AUTH_ENABLED:-}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-sqlite}
- TURNSTONE_DB_URL=${DATABASE_URL:-}
- TURNSTONE_CONSOLE_URL=http://console:8090
@@ -158,7 +151,6 @@ services:
profiles:
- production
- cluster
- ddgCluster
command:
- sh
- -c
@@ -169,8 +161,8 @@ services:
environment:
- TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-}
- TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0}
- TURNSTONE_AUTH_TOKEN=${TURNSTONE_AUTH_TOKEN:-}
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
- TURNSTONE_DB_BACKEND=${DB_BACKEND:-postgresql}
- TURNSTONE_DB_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}
- TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091
@@ -182,39 +174,6 @@ services:
required: false
restart: unless-stopped
# -------------------------------------------------------------------
# ddg-search — DuckDuckGo Search MCP server (HTTP transport)
# Provides web search + content fetch tools to turnstone via MCP.
# No API key required.
#
# Start with: MCP_CONFIG=/etc/turnstone/mcp-ddg.json \
# docker compose --profile ddgCluster up
# -------------------------------------------------------------------
ddg-search:
image: python:3.14-slim
profiles:
- ddgCluster
command:
- sh
- -c
- >-
pip install --no-cache-dir duckduckgo-mcp-server &&
python -c "from mcp.server.transport_security import TransportSecuritySettings; import duckduckgo_mcp_server.server as s; s.safe_search=s.SafeSearchMode.OFF; s.mcp.settings.host='0.0.0.0'; s.mcp.settings.port=3000; s.mcp.settings.transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False); s.mcp.run(transport='streamable-http')"
networks:
- turnstone-net
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('0.0.0.0',3000),2); s.close()\""]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
restart: unless-stopped
# ===================================================================
# 10-node cluster (profile: cluster)
#
@@ -229,7 +188,7 @@ services:
server-1: &cluster-server
image: turnstone:local
build: { context: ., dockerfile: Dockerfile }
profiles: [cluster, ddgCluster]
profiles: [cluster]
command:
- sh
- -c
@@ -244,15 +203,14 @@ services:
$${MCP_CONFIG:+--mcp-config $$MCP_CONFIG}
volumes:
- turnstone-data:/data
- ./docker/mcp-ddg.json:/etc/turnstone/mcp-ddg.json:ro
- ${WORKSPACE_MOUNT:-workspace}:/workspace
environment: &cluster-server-env
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1}
OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-}
TURNSTONE_AUTH_ENABLED: ${TURNSTONE_AUTH_ENABLED:-}
TURNSTONE_AUTH_TOKEN: ${TURNSTONE_AUTH_TOKEN:-}
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:-}
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}
MODEL: ${MODEL:-}
MCP_CONFIG: ${MCP_CONFIG:-}
TURNSTONE_DB_BACKEND: ${DB_BACKEND:-postgresql}
@@ -263,7 +221,6 @@ services:
networks: [turnstone-net]
depends_on:
postgres: { condition: service_healthy }
ddg-search: { condition: service_healthy, required: false }
healthcheck:
test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"]
interval: 10s
@@ -36,13 +36,13 @@ spec:
- secretRef:
name: {{ include "turnstone.llm.secretName" . }}
optional: true
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
env:
- name: TURNSTONE_AUTH_TOKEN
- name: TURNSTONE_JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_JWT_SECRET
{{- end }}
readinessProbe:
httpGet:
@@ -41,12 +41,12 @@ spec:
env:
- name: TURNSTONE_DB_URL
value: "postgresql+psycopg://$(TURNSTONE_DB_USER):$(POSTGRES_PASSWORD)@$(TURNSTONE_DB_HOST):$(TURNSTONE_DB_PORT)/$(TURNSTONE_DB_NAME)"
{{- if and .Values.auth.enabled .Values.auth.existingSecret }}
- name: TURNSTONE_AUTH_TOKEN
{{- if or .Values.auth.existingSecret .Values.auth.jwtSecret }}
- name: TURNSTONE_JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.auth.existingSecret }}
key: TURNSTONE_AUTH_TOKEN
name: {{ include "turnstone.auth.secretName" . }}
key: TURNSTONE_JWT_SECRET
{{- end }}
readinessProbe:
httpGet:
+2 -2
View File
@@ -15,7 +15,7 @@ data:
{{- else if and (not .Values.postgresql.enabled) .Values.database.external.password }}
POSTGRES_PASSWORD: {{ .Values.database.external.password | b64enc | quote }}
{{- end }}
{{- if and .Values.auth.enabled .Values.auth.token (not .Values.auth.existingSecret) }}
TURNSTONE_AUTH_TOKEN: {{ .Values.auth.token | b64enc | quote }}
{{- if and .Values.auth.jwtSecret (not .Values.auth.existingSecret) }}
TURNSTONE_JWT_SECRET: {{ .Values.auth.jwtSecret | b64enc | quote }}
{{- end }}
{{- end }}
+2 -3
View File
@@ -59,10 +59,9 @@ llm:
apiKey: ""
existingSecret: ""
# -- Authentication
# -- Authentication (always enabled, JWT secret required)
auth:
enabled: false
token: ""
jwtSecret: ""
existingSecret: ""
# -- Ingress configuration
+1 -1
View File
@@ -40,8 +40,8 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" {
[
aws_secretsmanager_secret.openai_api_key.arn,
aws_secretsmanager_secret.db_password.arn,
aws_secretsmanager_secret.jwt_secret.arn,
],
var.auth_token != "" ? [aws_secretsmanager_secret.auth_token[0].arn] : [],
)
},
]
+16 -20
View File
@@ -41,20 +41,26 @@ locals {
},
]
auth_env = var.auth_token != "" ? [
{ name = "TURNSTONE_AUTH_ENABLED", value = "true" },
] : []
auth_secrets = var.auth_token != "" ? [
auth_secrets = [
{
name = "TURNSTONE_AUTH_TOKEN"
valueFrom = aws_secretsmanager_secret_version.auth_token[0].arn
name = "TURNSTONE_JWT_SECRET"
valueFrom = aws_secretsmanager_secret_version.jwt_secret.arn
},
] : []
]
}
# ---------- Secrets Manager ----------
resource "aws_secretsmanager_secret" "jwt_secret" {
name = "${var.name_prefix}-${var.environment}-jwt-secret"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "jwt_secret" {
secret_id = aws_secretsmanager_secret.jwt_secret.id
secret_string = var.jwt_secret
}
resource "aws_secretsmanager_secret" "openai_api_key" {
name = "${var.name_prefix}-${var.environment}-openai-api-key"
tags = local.common_tags
@@ -65,17 +71,7 @@ resource "aws_secretsmanager_secret_version" "openai_api_key" {
secret_string = var.openai_api_key
}
resource "aws_secretsmanager_secret" "auth_token" {
count = var.auth_token != "" ? 1 : 0
name = "${var.name_prefix}-${var.environment}-auth-token"
tags = local.common_tags
}
resource "aws_secretsmanager_secret_version" "auth_token" {
count = var.auth_token != "" ? 1 : 0
secret_id = aws_secretsmanager_secret.auth_token[0].id
secret_string = var.auth_token
}
resource "aws_secretsmanager_secret" "db_password" {
name = "${var.name_prefix}-${var.environment}-db-password"
@@ -140,7 +136,7 @@ resource "aws_ecs_task_definition" "server" {
{ containerPort = 8080, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
@@ -209,7 +205,7 @@ resource "aws_ecs_task_definition" "console" {
{ containerPort = 8090, protocol = "tcp" },
]
environment = concat(local.common_env, local.auth_env)
environment = local.common_env
secrets = concat(local.common_secrets, local.auth_secrets)
logConfiguration = {
@@ -90,11 +90,10 @@ variable "name_prefix" {
default = "turnstone"
}
variable "auth_token" {
description = "Optional authentication token for the Turnstone API. Empty string disables auth."
variable "jwt_secret" {
description = "JWT signing secret for Turnstone auth (required, min 32 characters)."
type = string
sensitive = true
default = ""
}
variable "certificate_arn" {
-7
View File
@@ -1,7 +0,0 @@
{
"mcpServers": {
"ddg": {
"url": "http://ddg-search:3000/mcp"
}
}
}
+3 -4
View File
@@ -56,7 +56,7 @@ console.log(result.content);
## Authentication
When auth is enabled (`[auth].enabled = true` or `TURNSTONE_AUTH_ENABLED=1`), all API endpoints except public paths require a valid token.
Auth is always enabled. All API endpoints except public paths require a valid token.
### Sending Credentials
@@ -65,15 +65,14 @@ Include a token in one of two ways:
- **Bearer header**: `Authorization: Bearer <token>`
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
The server accepts three token types:
The server accepts two token types:
| Type | Format | Example |
|------|--------|---------|
| JWT | Base64 segments separated by dots | `eyJhbG...` |
| API token | `ts_` prefix + 64 hex chars | `ts_a1b2c3d4...` |
| Config token | Arbitrary string from `config.toml` | `my-secret-token` |
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD. Config tokens are a simple option for single-node deployments.
JWTs are the recommended credential for browser sessions. API tokens are suitable for programmatic access and CI/CD.
### `POST /v1/api/auth/login`
+24 -28
View File
@@ -74,7 +74,7 @@ turnstone/
_sync.py Background event loop for sync wrappers
_types.py TurnResult + TurnstoneAPIError
console/
collector.py ClusterCollector — aggregates state from all nodes via HTTP
collector.py ClusterCollector — aggregates state from all nodes via SSE
scheduler.py TaskScheduler — background cron/at scheduler, dispatches via HTTP
server.py Cluster dashboard HTTP server + SSE + CLI entry point
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
@@ -1016,13 +1016,10 @@ limits using a token-bucket algorithm. Each IP gets a `TokenBucket` with
Turnstone supports three authentication mechanisms, unified behind an
`AuthResult` dataclass that carries `user_id`, `scopes`, and `token_source`:
1. **Config-file tokens**static secrets in `config.toml` `[[auth.tokens]]`
or the `TURNSTONE_AUTH_TOKEN` env var. Validated in-memory via
`hmac.compare_digest`. Map to scopes through their role (`read` or `full`).
2. **API tokens** — database-backed, prefixed `ts_`, stored as SHA-256 hashes
1. **API tokens**database-backed, prefixed `ts_`, stored as SHA-256 hashes
in the `api_tokens` table. Can be exchanged for JWTs via
`POST /v1/api/auth/login`.
3. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
2. **JWTs** — short-lived HMAC-SHA256 session tokens (default 24h) issued after
successful credential validation. Contain `sub` (user_id), `scopes`, and
`src` (origin) in claims.
@@ -1046,9 +1043,8 @@ Three hierarchical scopes control endpoint access:
2. **Token extraction**`Authorization: Bearer <token>` header first, then
`turnstone_auth` cookie as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token; otherwise config-file token.
4. **Validation** — JWT signature check, API token hash lookup in storage, or
config-token hmac comparison.
indicates API token.
4. **Validation** — JWT signature check or API token hash lookup in storage.
5. **Scope check**`required_scope(method, path)` determines the minimum
scope; the request is rejected with 403 if the token lacks it.
6. **Context propagation** — on success, `ctx_user_id` is set so structured
@@ -1201,31 +1197,31 @@ bell + status line to stderr to alert the user.
### Cluster Console
```
Monitoring (3 daemon threads) Control + Proxy (async Starlette)
Monitoring (2 daemon threads) Control + Proxy (async Starlette)
+------------------+ +----------------------------+
| Event subscriber | | POST /v1/api/cluster/ |
| SSE on | | workstreams/new |
| /events/glob | | → POST to target server |
| Node discovery | | POST /v1/api/cluster/ |
| Service registry | | workstreams/new |
| every 60 seconds | | → POST to target server |
+------------------+ +----------------------------+
| Node discovery | | GET /node/{node_id}/ |
| Service registry | | → httpx.AsyncClient |
| every 15 seconds | | proxy to server_url |
+------------------+ | GET /node/{id}/v1/api/events |
| Poll loop | | → SSE stream proxy |
| GET /v1/api/dash | | POST /node/{id}/v1/api/send |
| GET /health | | → forwarded to server |
| ThreadPoolExec | +----------------------------+
+------------------+
| SSE manager | | GET /node/{node_id}/ |
| asyncio loop | | → httpx.AsyncClient |
| 1 task per node | | proxy to server_url |
| /events/global | | GET /node/{id}/v1/api/events |
| snapshot+deltas | | → SSE stream proxy |
+------------------+ | POST /node/{id}/v1/api/send |
| → forwarded to server |
+----------------------------+
```
The console HTTP layer is a Starlette/ASGI app served by uvicorn. The SSE
endpoint uses `EventSourceResponse` with the same listener queue pattern as
the main server. `ClusterCollector`'s background threads (event subscriber,
node discovery, poll loop) use `ThreadPoolExecutor`
for parallel HTTP polling. The poll loop diffs workstream IDs between poll
cycles and fans out synthetic `ws_created`/`ws_closed` SSE events for any
changes, ensuring browser clients stay in sync even when real-time cluster
events are missed.
the main server. `ClusterCollector` runs two daemon threads: a discovery loop
that queries the service registry every 60 seconds, and an SSE manager that
runs a single asyncio event loop multiplexing persistent SSE connections to
all nodes via `GET /v1/api/events/global`. Each node delivers a full snapshot
on connect followed by real-time delta events — state changes, health
transitions, and aggregate metrics arrive sub-second instead of on a 15-second
poll cycle.
The console has two write-path capabilities:
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75c1832b6079e8628f4bbf4ce98d37880c4de133636b7555e3869990b046ddc6
size 567704
+5 -6
View File
@@ -193,7 +193,6 @@ Plan review requests are displayed as a blue embed with:
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
| `--auth-token` | `TURNSTONE_CHANNEL_AUTH_TOKEN` | — | Static auth token for `/v1/api/notify` (alternative to JWT) |
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
@@ -321,11 +320,11 @@ The `services` table schema:
### Security
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
requires authentication. Configure either `TURNSTONE_JWT_SECRET`
(the server mints JWTs with `aud: turnstone-channel` automatically)
or a static token via `--auth-token`. If neither is set, the
gateway fails closed and rejects all requests with 401. Server JWTs
(`aud: turnstone-server`) are rejected.
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
server can mint JWTs with `aud: turnstone-channel` automatically.
If the secret is not set, the gateway fails closed and rejects all
requests with 401. Server JWTs (`aud: turnstone-server`) are
rejected.
- **Rate limit** — maximum 5 notifications per turn. The counter only
increments on successful delivery, so failures don't consume the
budget.
+7 -11
View File
@@ -1,6 +1,6 @@
# Cluster Dashboard (turnstone-console)
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table, polls each node's HTTP API for workstream data, and receives real-time state changes via HTTP polling.
`turnstone-console` is a cluster management service that provides cluster-wide visibility and control across all turnstone nodes. It discovers nodes via the `services` database table and subscribes to each node's SSE event stream for real-time workstream, health, and metric updates.
The console also supports **workstream creation** (dispatched via HTTP proxy to target nodes) and a **reverse proxy** that serves each node's server UI through the console port — so users only need network access to the console, not to individual server nodes.
@@ -21,7 +21,7 @@ turnstone-console ──────┤
Data flows in two directions:
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It periodically polls each node's `GET /v1/api/dashboard` for full workstream snapshots and `GET /health` for node health.
- **Inbound (monitoring):** The console discovers nodes via the `services` database table (nodes register on startup and send periodic heartbeats). It opens a persistent SSE connection to each node's `GET /v1/api/events/global` endpoint, receiving a full snapshot on connect followed by real-time delta events (state changes, health transitions, aggregate metrics).
- **Outbound (control):** The console proxies workstream creation requests to target nodes via HTTP.
- **Proxy (pass-through):** The console reverse-proxies each node's server UI at `/node/{node_id}/`, forwarding HTTP and SSE traffic so the browser never contacts server nodes directly.
@@ -30,8 +30,7 @@ Data flows in two directions:
| Source | Method | Direction | Data |
|--------|--------|-----------|------|
| `services` table | Database query | Read | Node discovery (node_id, server_url, started) |
| Node HTTP API | `GET {server_url}/v1/api/dashboard` | Read | Full workstream list with tokens, context, activity |
| Node HTTP API | `GET {server_url}/health` | Read | Node health status |
| Node SSE | `GET {server_url}/v1/api/events/global` | Stream | Snapshot on connect, then real-time delta events (state, health, aggregate) |
| Node HTTP API | `POST {server_url}/v1/api/workstreams/new` | Write | Workstream creation |
| Node HTTP API | `GET/POST {server_url}/*` | Proxy | Server UI, API requests, SSE streams |
@@ -41,9 +40,9 @@ Data flows in two directions:
The collector (`turnstone/console/collector.py`) maintains an in-memory snapshot of all nodes and workstreams. Two daemon threads handle data acquisition:
1. **Node discovery** — queries the `services` database table every 15 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners.
1. **Node discovery** — queries the `services` database table every 60 seconds. Adds newly discovered nodes, removes expired ones (stale heartbeats), emits `node_joined` / `node_lost` events to SSE listeners, and spawns/cancels SSE tasks for new/lost nodes.
2. **Poll loop** — fetches `GET /v1/api/dashboard` and `GET /health` from each known node every 10 seconds. Uses `ThreadPoolExecutor(max_workers=50)` for parallelism. Each poll replaces the node's workstream list with the authoritative server data.
2. **SSE manager** — a single asyncio event loop on one thread multiplexes persistent SSE connections to all discovered nodes via `GET /v1/api/events/global`. Each connection receives a `node_snapshot` on connect (workstreams, health, aggregate) followed by real-time delta events (`ws_state`, `ws_created`, `ws_closed`, `ws_rename`, `health_changed`, `aggregate`). On disconnect, the node is marked unreachable and the connection is retried with exponential backoff (1s30s). An `?expected_node_id=` query parameter provides identity verification against IP reuse (server returns 409 on mismatch).
A `get_snapshot()` method builds the full cluster state under a single lock acquisition — overview aggregates and per-node workstream lists in one atomic read. This is served both as a REST endpoint and as the initial SSE event on client connect.
@@ -54,7 +53,7 @@ All reads and writes to the node/workstream map are protected by a single `threa
### Scale Considerations
- **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
- **1,000 nodes** connected via persistent SSE — a single asyncio event loop multiplexes all connections with negligible overhead. Ensure `ulimit -n` >= 4096 for fd headroom
- **Filtering and pagination** run in-memory on the full workstream list — sub-millisecond at this scale
- **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
@@ -629,8 +628,6 @@ CLI flags for `turnstone-console`:
|------|---------|-------------|
| `--host` | `0.0.0.0` | Bind host |
| `--port` | `8090` | HTTP port |
| `--poll-interval` | `10` | Node polling interval (seconds) |
| `--auth-token` | `$TURNSTONE_AUTH_TOKEN` | Bearer token for server node communication and proxy |
| `--log-level` | `INFO` | Log level |
Config file (`~/.config/turnstone/config.toml`):
@@ -640,7 +637,6 @@ Config file (`~/.config/turnstone/config.toml`):
host = "0.0.0.0"
port = 8090
url = "http://localhost:8090" # used by CLI /cluster commands
poll_interval = 10
```
---
@@ -652,7 +648,7 @@ poll_interval = 10
turnstone-server --port 8080
# Start cluster console (one instance)
turnstone-console --port 8090 --auth-token "$TURNSTONE_AUTH_TOKEN"
turnstone-console --port 8090
```
Open `http://localhost:8090` for the cluster dashboard. Create workstreams via the "+ new" button. Click any workstream to open the proxied server UI — no direct access to server ports required.
+55 -34
View File
@@ -10,55 +10,76 @@ participant "ClusterCollector" as CC
participant "Node-A\n(server)" as NodeA
participant "Node-B\n(server)" as NodeB
== Thread 1: HTTP Polling (every 10s) ==
== Thread 1: Node Discovery (every 60s) ==
CC -> CC : Iterate registered nodes
CC -> CC : list_services("server",\nmax_age_seconds=120)
activate CC #C8E6C9
CC -> NodeA : GET /v1/api/dashboard
activate NodeA
NodeA --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
deactivate NodeA
CC -> NodeA : GET /health
activate NodeA
NodeA --> CC : {status:"ok", version:"0.9.2",\nmodel:"...", workstreams:{...}}
deactivate NodeA
CC -> NodeB : GET /v1/api/dashboard
activate NodeB
NodeB --> CC : {workstreams: [...],\naggregate: {total_tokens, ...}}
deactivate NodeB
CC -> CC : Diff old vs new workstream IDs
CC -> CC : Replace NodeSnapshot["nodeA"]\n.workstreams, .health, .aggregate
CC -> CC : _fanout(ws_created) for\nnewly appeared workstreams
CC -> CC : _fanout(ws_closed) for\nremoved workstreams
note right of CC
Poll-diff fanout ensures
browser SSE clients learn
about workstreams that
appeared between polls.
end note
CC -> CC : New node? → spawn SSE task\nLost node? → cancel SSE task
CC -> CC : _fanout(node_joined)\n_fanout(node_lost)
deactivate CC
== Thread 2: SSE Manager (asyncio event loop) ==
note over CC
Single asyncio event loop multiplexes
one persistent SSE connection per node.
Scales to 1000+ nodes.
end note
CC -> NodeA : GET /v1/api/events/global\n?expected_node_id=nodeA
activate NodeA
activate CC #BBDEFB
NodeA --> CC : data: {"type":"node_snapshot",\n"node_id":"nodeA",\n"workstreams":[...],\n"health":{...},\n"aggregate":{...}}
note right of CC
Snapshot populates NodeSnapshot
in-memory state. Reconciles
against stale data (emits
ws_created/ws_closed diffs).
end note
loop real-time delta events
NodeA --> CC : data: {"type":"ws_state",\n"ws_id":"ws1","state":"running"}
CC -> CC : Update NodeSnapshot\n_fanout(cluster_state)
end
alt health transition
NodeA --> CC : data: {"type":"health_changed",\n"circuit_state":"open"}
CC -> CC : Update node.health
end
alt periodic aggregate (every 10s)
NodeA --> CC : data: {"type":"aggregate",\n"total_tokens":50000}
CC -> CC : Update node.aggregate
end
deactivate CC
deactivate NodeA
alt SSE disconnect
CC -> CC : Mark node unreachable\nReconnect with backoff\n(1s → 30s cap)
end
alt identity mismatch (409 or snapshot node_id differs)
CC -> CC : Mark node unreachable\nStop reconnecting to this URL
end
== Browser SSE Stream ==
Browser -> Server : GET /v1/api/cluster/events
activate Server
Server -> CC : get_snapshot()
Server -> CC : get_snapshot_and_register(queue)
note right : Atomic: snapshot + listener\nregistration under both locks\n→ no event gap
CC --> Server : ClusterSnapshot\n(full current state)
Server -> CC : register_listener(queue)
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)
loop continuous (incremental updates)
CC -> Server : event via listener queue\n(from polling thread)
CC -> Server : event via listener queue\n(from SSE manager thread)
Server -> Browser : data: {"type":"cluster_state",...}\n\n
end
@@ -81,7 +102,7 @@ Server --> Browser : JSON response
Browser -> Server : GET /v1/api/cluster/overview
Server -> CC : get_overview()
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.2"]}
CC --> Server : {nodes: 2, workstreams: 12,\nstates: {running:5, ...},\naggregate: {total_tokens: 50000},\nversion_drift: false, versions: ["0.9.7"]}
Server --> Browser : JSON response
Browser -> Server : GET /v1/api/cluster/nodes?sort=activity
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14824b81fa87f9a29e9b182b54132d3e438dd83f880b20b111b4bf51bc4b39d1
size 317947
oid sha256:040f7d9ec7d676da40b9487e0825caf2c1574cbdd9f16d0998d90e0c2e4f8861
size 360309
+3 -4
View File
@@ -69,15 +69,14 @@ All configuration is via environment variables in `.env` (copy from `.env.exampl
| Variable | Default | Description |
|----------|---------|-------------|
| `CONSOLE_PORT` | `8090` | Host port mapping |
| `CONSOLE_POLL_INTERVAL` | `10` | Node polling interval (seconds) |
### Auth
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_AUTH_ENABLED` | — | Set to `1` to require authentication |
| `TURNSTONE_AUTH_TOKEN` | — | Config-file token for server/console (backward compat, works alongside JWT) |
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required when using user identity / JWT auth) |
| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) |
### Database
+5 -5
View File
@@ -38,7 +38,7 @@ are set.
| `TURNSTONE_OIDC_PROVIDER_NAME` | No | `SSO` | Display name for the login button (e.g. "Google", "Okta") |
| `TURNSTONE_OIDC_ROLE_CLAIM` | No | — | ID token claim containing role/group values (see [Role Mapping](#role-mapping)) |
| `TURNSTONE_OIDC_ROLE_MAP` | No | — | Mapping from claim values to Turnstone role IDs (see [Role Mapping](#role-mapping)) |
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens and config-file tokens still work. |
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | No | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Recommended when running behind a reverse proxy. When unset, derived from the request Host header. |
OIDC is enabled when all three required fields (issuer, client ID, client
@@ -246,10 +246,10 @@ password) before OIDC is enabled. The setup wizard always works
regardless of this setting because it is only available when zero users
exist in the database.
API token login (`POST /v1/api/auth/login` with a `ts_` token) and
config-file tokens (`Authorization: Bearer tok_xxx`) continue to work
regardless of this setting. OIDC-only mode affects password-based
authentication only.
API token login (`POST /v1/api/auth/login` with a `ts_` token)
continues to work regardless of this setting. JWTs and API tokens are
the supported authentication methods. OIDC-only mode affects
password-based authentication only.
---
+80
View File
@@ -0,0 +1,80 @@
# Release Process
Turnstone uses two parallel release tracks published from a single PyPI package.
## Release Tracks
| Track | Versions | Branch | Docker tags | PyPI install |
|-------|----------|--------|-------------|--------------|
| **Stable** | `1.0.0`, `1.0.1` | `stable/1.0` | `:1.0.1`, `:1.0`, `:stable`, `:latest` | `pip install turnstone` |
| **Experimental** | `1.1.0a1`, `1.1.0a2` | `main` | `:1.1.0a1`, `:experimental` | `pip install turnstone --pre` |
- **Stable** receives bugfixes only. Production-grade.
- **Experimental** receives new features. May be rough around the edges.
- When experimental matures, it is promoted to stable. The previous stable branch stops receiving patches.
## Version Scheme
[PEP 440](https://peps.python.org/pep-0440/) pre-release suffixes on a single package:
- `1.0.0` — stable release
- `1.1.0a1` — alpha (experimental)
- `1.1.0b1` — beta (experimental, more stable)
- `1.1.0rc1` — release candidate (experimental, nearly stable)
- `1.1.0` — promoted to stable
## Releasing an Experimental Version (from main)
```bash
scripts/release.sh 1.1.0a2 --push
```
This bumps `pyproject.toml` + `turnstone/__init__.py`, regenerates `uv.lock`, commits, tags `v1.1.0a2`, and pushes. CI runs, then publish + Docker workflows fire automatically.
## Releasing a Stable Patch (from stable/X.Y)
```bash
git checkout stable/1.0
git cherry-pick <commit-hash> # bugfix from main
scripts/release.sh 1.0.2 --push
```
## Promoting Experimental to Stable
When `main` is ready for a stable release:
```bash
# 1. Tag the stable release on main
scripts/release.sh 1.1.0 --push
# 2. Create the stable maintenance branch from that tag
git branch stable/1.1 v1.1.0
git push origin stable/1.1
# 3. Start the next experimental cycle on main
scripts/release.sh 1.2.0a1 --push
```
The previous `stable/1.0` branch stops receiving patches at this point.
## CI/CD Pipeline
All releases are gated on CI success:
1. `git push` with `v*` tag triggers **CI** (lint, typecheck, test, test-postgres, lock-check, security audit)
2. On CI success, **Publish to PyPI** fires via `workflow_run`
3. On CI success, **Publish Docker Image** fires via `workflow_run`
Pre-release tags (`a`, `b`, `rc` suffixes) produce:
- PyPI: pre-release version (not installed by default)
- GitHub Release: marked as pre-release
- Docker: `:experimental` alias + exact version tag
Stable tags produce:
- PyPI: stable version (default `pip install`)
- GitHub Release: full release
- Docker: `:stable`, `:latest`, `:X.Y`, `:X.Y.Z` tags
## Dependency Updates
Renovate targets `main` (experimental) only. Stable branches receive manual dependency updates via cherry-pick when security-relevant.
+2 -2
View File
@@ -332,6 +332,6 @@ client.login(token="ts_abc123...")
- `client.logout()` clears the stored JWT from the client.
- If a request returns 401, the SDK raises `TurnstoneAPIError` — the caller is responsible for re-authenticating.
### Backward Compatibility
### Token Types
The config-file token (`TURNSTONE_AUTH_TOKEN`) still works as a simple Bearer token for environments that do not use the user/JWT system. When the server receives a non-JWT Bearer token, it falls back to the legacy token check.
The SDK accepts any Bearer token — JWTs (from `ServiceTokenManager` or login) and API tokens (`ts_` prefix) are both supported. Use `token_factory` for auto-rotating JWTs or a static `token` for API tokens.
+11 -54
View File
@@ -8,23 +8,6 @@ credentials while individual server nodes validate JWTs locally.
## Token Types
### Config-file tokens
Static tokens defined in `config.toml` or the `TURNSTONE_AUTH_TOKEN`
environment variable. Validated in-memory using `hmac.compare_digest`
(timing-safe). Each token maps to a role that determines its scopes.
```toml
[[auth.tokens]]
value = "tok_legacy"
role = "full" # full → {read, write, approve}
```
Role mappings: `"read"``{read}`, `"full"``{read, write, approve}`.
Config tokens are sent directly as `Authorization: Bearer tok_legacy`
on every request. No JWT exchange is needed.
### API tokens
Database-backed tokens prefixed with `ts_`. Created via the admin CLI
@@ -149,15 +132,6 @@ The API token is hashed, looked up in the database, and exchanged for a
JWT with the token's scopes. This is the recommended flow for SDKs and
automated clients that need cookie-based sessions.
### Config-file tokens (direct)
Config tokens are validated per-request via `hmac.compare_digest`. No
login exchange is needed — include the token as a `Bearer` header:
```
Authorization: Bearer tok_legacy
```
### First-time setup
When no users exist in the database:
@@ -276,7 +250,7 @@ Setting `TURNSTONE_OIDC_PASSWORD_ENABLED=false` hides the password
form on the login page and blocks password-based login at the API
level. The setup wizard always works regardless of this setting — the
first admin user is created with a password before OIDC is relevant.
API tokens and config-file tokens are unaffected by this setting.
API tokens are unaffected by this setting.
#### Known limitations
@@ -297,8 +271,6 @@ and classifies the token:
1. **Contains `.`** → JWT → validate HS256 signature and expiry
2. **Starts with `ts_`** → API token → SHA-256 hash, database lookup
3. **Otherwise** → config-file token → `hmac.compare_digest` against
each configured token
If a session cookie is present and no `Authorization` header is sent,
the cookie value is treated as a JWT (step 1).
@@ -332,16 +304,10 @@ deployments.
| Signing secret | `[auth] jwt_secret` | `TURNSTONE_JWT_SECRET` | Auto-generated ephemeral (warning logged) |
| Expiry | `[auth] jwt_expiry_hours` | — | 24 hours |
| Algorithm | — | — | HS256 (not configurable) |
| Minimum secret length | — | — | 32 characters (warning if shorter) |
| Minimum secret length | — | — | 32 characters (exits if shorter) |
All service nodes that need to validate JWTs must share the same signing
secret. If no secret is configured, an ephemeral key is generated at
startup and a warning is logged — JWTs will not survive restarts or work
across nodes.
The console **requires** `TURNSTONE_JWT_SECRET` when no `--auth-token`
is provided. It exits with an error if the secret is missing, since
ephemeral secrets would silently break inter-service communication.
All services require `TURNSTONE_JWT_SECRET` and exit at startup if it is
missing or shorter than 32 characters.
---
@@ -442,16 +408,15 @@ Console (cluster-wide) Server (per-node)
┌──────────────────────┐ ┌──────────────────────┐
│ User/Token CRUD (DB) │ │ JWT validation only │
│ Login: creds → JWT │ │ (shared signing key) │
│ Admin API endpoints │ │ Config tokens: hmac
│ Storage: users, │ │ No auth DB needed
│ Admin API endpoints │ │ No auth DB needed
│ Storage: users, │ │
│ api_tokens tables │ │ │
└──────────────────────┘ └──────────────────────┘
```
The console owns the credential database and handles all user/token
CRUD. Individual server nodes only need the JWT signing secret to
validate session tokens. Config-file tokens are validated locally
without any database.
validate session tokens.
### Proxy auth forwarding
@@ -478,8 +443,7 @@ distinguish proxied requests from direct logins in audit logs.
When no user context is available (auth disabled, or internal requests),
the proxy falls back to a `ServiceTokenManager` with service identity
`console-proxy` and full scopes. If `--auth-token` is provided, that
static token is used as a final fallback.
`console-proxy` and full scopes.
### Service-to-service authentication
@@ -518,22 +482,17 @@ channel gateway endpoint, and vice versa.
```toml
[auth]
enabled = true
jwt_secret = "your-secret-key-here"
jwt_expiry_hours = 24
[[auth.tokens]]
value = "tok_legacy"
role = "full"
```
### Environment variables
Auth is always enabled. `TURNSTONE_JWT_SECRET` is required.
| Variable | Description |
|----------|-------------|
| `TURNSTONE_AUTH_ENABLED=1` | Enable authentication |
| `TURNSTONE_AUTH_TOKEN=tok_xxx` | Register a config-file token with `full` access |
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (must match across nodes) |
| `TURNSTONE_JWT_SECRET=xxx` | JWT signing secret (required, must match across nodes) |
| `TURNSTONE_CORS_ORIGINS=` | CORS allowed origins (comma-separated; empty = same-origin only) |
---
@@ -571,8 +530,6 @@ and browsers enforce same-origin policy.
## Security Properties
- **Timing-safe comparison** for config-file tokens via
`hmac.compare_digest` — no timing side-channel.
- **Hash-based lookup** for API tokens — the database stores only
SHA-256 hashes, eliminating timing attacks on token comparison.
- **Local JWT validation** — no network call or database query needed
+1 -1
View File
@@ -105,7 +105,7 @@ turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
# List issued certs
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
turnstone-admin tls-list --console-url http://console:8080
```
### Console URL Discovery
+1 -1
View File
@@ -593,7 +593,7 @@ current turn and letting it search for them on demand.
Tool search uses the best available mechanism for each provider:
1. **Anthropic (native)** -- Models that support it receive `defer_loading: true`
on deferred tool definitions plus the `tool_search_tool_bm25_20251119` server-side
on deferred tool definitions plus the `tool_search_tool_bm25` server-side
search tool. Anthropic's API handles search and expansion transparently.
2. **OpenAI GPT-5.4+ (native)** -- Models with hosted tool search receive
+11 -7
View File
@@ -1,10 +1,14 @@
# MCP Cluster Ops
An MCP server that exposes tools for executing commands across a [Turnstone](https://github.com/turnstonelabs/turnstone) cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
An MCP server that exposes tools for executing commands across a Turnstone cluster. Serves as a reference implementation for both MCP server patterns and Turnstone SDK usage.
## How it works
This server uses Turnstone's SDK client (`TurnstoneServer`) to dispatch shell commands to specific nodes via HTTP. Remote agents execute the command and the raw bash output is captured directly from the `ToolResultEvent` stream — bypassing the costly "agent reads output → re-generates output as completion tokens" round-trip.
This server uses the Turnstone console SDK (`TurnstoneConsole`) for node discovery and routing, and `TurnstoneServer` for per-node SSE streaming. The dispatch flow for each command is:
1. **Route**`TurnstoneConsole.route_create_workstream(target_node=..., auto_approve=True)` creates a workstream pinned to the target node via the console's hash-ring routing proxy, returning `ws_id` and `node_url`.
2. **Execute**`TurnstoneServer(node_url, token=...)` connects directly to the node's SSE stream using the same `TURNSTONE_API_TOKEN`. `send_and_wait(prompt, ws_id)` runs the command and the raw bash output is captured from the `ToolResultEvent` — bypassing the costly "agent reads output then re-generates output as completion tokens" round-trip.
3. **Cleanup**`TurnstoneConsole.route_close(ws_id)` closes the workstream.
Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time is bounded by the slowest node rather than the sum.
@@ -19,7 +23,7 @@ Multi-node dispatches run in parallel via `asyncio.gather`, so total wall time i
## Prerequisites
- A running Turnstone cluster (at least one `turnstone-server`)
- A running Turnstone cluster with at least one `turnstone-server` and a `turnstone-console`
- Python 3.11+
## Installation
@@ -35,8 +39,8 @@ pip install -e ./examples/mcp-cluster-ops
| Variable | Default | Description |
|----------|---------|-------------|
| `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL |
| `TURNSTONE_API_TOKEN` | _(none)_ | API token for authentication |
| `TURNSTONE_CONSOLE_URL` | `http://localhost:8090` | Console URL for node discovery and routing |
| `TURNSTONE_API_TOKEN` | _(none)_ | API token / JWT for authentication |
| `MCP_CLUSTER_OPS_TIMEOUT` | `120` | Default command timeout (seconds, clamped 5-3600) |
| `MCP_CLUSTER_OPS_MAX_OUTPUT` | `8192` | Max output bytes per node (0 = unlimited) |
| `MCP_CLUSTER_OPS_MAX_NODES` | `32` | Max concurrent node dispatches |
@@ -51,7 +55,7 @@ pip install -e ./examples/mcp-cluster-ops
command = "mcp-cluster-ops"
[mcp.servers.cluster-ops.env]
TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
TURNSTONE_CONSOLE_URL = "http://console.example.com:8090"
```
**JSON** (via `--mcp-config`):
@@ -62,7 +66,7 @@ TURNSTONE_SERVER_URL = "http://turnstone.example.com:8080"
"cluster-ops": {
"command": "mcp-cluster-ops",
"env": {
"TURNSTONE_SERVER_URL": "http://turnstone.example.com:8080"
"TURNSTONE_CONSOLE_URL": "http://console.example.com:8090"
}
}
}
@@ -1,7 +1,8 @@
"""MCP server for Turnstone cluster operations.
Exposes tools to execute commands on specific nodes in a Turnstone cluster.
Uses the SDK client (``TurnstoneServer``) for direct node targeting via HTTP.
Uses the SDK console client (``TurnstoneConsole``) for node discovery and
routing, and ``TurnstoneServer`` for per-node SSE streaming.
Usage::
@@ -14,12 +15,12 @@ Configure in ``~/.config/turnstone/config.toml``::
command = "mcp-cluster-ops"
[mcp.servers.cluster-ops.env]
TURNSTONE_SERVER_URL = "http://localhost:8080"
TURNSTONE_CONSOLE_URL = "http://localhost:8090"
Environment variables
---------------------
TURNSTONE_SERVER_URL Server URL (default: http://localhost:8080)
TURNSTONE_API_TOKEN API token for authentication (default: none)
TURNSTONE_CONSOLE_URL Console URL (default: http://localhost:8090)
TURNSTONE_API_TOKEN API token / JWT for authentication (default: none)
MCP_CLUSTER_OPS_TIMEOUT Default command timeout in seconds (default: 120)
MCP_CLUSTER_OPS_MAX_OUTPUT Max output bytes per node (default: 8192, 0=unlimited)
@@ -43,7 +44,7 @@ from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from mcp.server.fastmcp import Context, FastMCP
from turnstone.sdk import TurnResult, TurnstoneServer
from turnstone.sdk import TurnResult, TurnstoneConsole, TurnstoneServer
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -66,15 +67,12 @@ _MAX_TIMEOUT = 3600
# ---------------------------------------------------------------------------
def _server_kwargs() -> dict[str, Any]:
"""Build TurnstoneServer connection kwargs from environment variables."""
kwargs: dict[str, Any] = {
"base_url": os.environ.get("TURNSTONE_SERVER_URL", "http://localhost:8080"),
def _console_kwargs() -> dict[str, Any]:
"""Build TurnstoneConsole connection kwargs from environment variables."""
return {
"base_url": os.environ.get("TURNSTONE_CONSOLE_URL", "http://localhost:8090"),
"token": os.environ.get("TURNSTONE_API_TOKEN", ""),
}
token = os.environ.get("TURNSTONE_API_TOKEN")
if token:
kwargs["token"] = token
return kwargs
def _exec_prompt(command: str) -> str:
@@ -141,6 +139,11 @@ def _validate_command(command: str) -> str | None:
return None
def _extract_node_ids(nodes: list[dict[str, Any]]) -> list[str]:
"""Extract unique, non-empty node IDs from a list of node dicts."""
return list(dict.fromkeys(n["node_id"].strip() for n in nodes if n.get("node_id", "").strip()))
def _format_node_result(
node_id: str,
result: TurnResult,
@@ -165,12 +168,12 @@ def _format_node_result(
# ---------------------------------------------------------------------------
# Core dispatch functions (testable with mocked TurnstoneServer)
# Core dispatch functions (testable with mocked SDK clients)
# ---------------------------------------------------------------------------
def _exec_on_node_sync(
server_kw: dict[str, Any],
console_kw: dict[str, Any],
node_id: str,
command: str,
timeout: float,
@@ -178,22 +181,35 @@ def _exec_on_node_sync(
"""Dispatch *command* to *node_id* and block until complete.
Runs inside ``asyncio.to_thread`` so it does not block the event loop.
Each call creates its own ``TurnstoneServer`` client to avoid state
conflicts between concurrent dispatches.
Flow:
1. Create a workstream on the target node via the console routing proxy
2. Connect directly to the node's SSE stream to send + collect output
3. Close the workstream via the routing proxy
"""
prompt = _exec_prompt(command)
with TurnstoneServer(**server_kw) as client:
result = client.send_and_wait(
message=prompt,
target_node=node_id,
auto_approve=True,
timeout=timeout,
)
ws_id = ""
with TurnstoneConsole(**console_kw) as console:
try:
route_resp = console.route_create_workstream(
target_node=node_id,
auto_approve=True,
)
ws_id = route_resp["ws_id"]
node_url: str = route_resp["node_url"]
with TurnstoneServer(
base_url=node_url,
token=console_kw["token"],
) as server:
result = server.send_and_wait(prompt, ws_id, timeout=timeout)
finally:
if ws_id:
console.route_close(ws_id)
return node_id, result
async def _dispatch_parallel(
server_kw: dict[str, Any],
console_kw: dict[str, Any],
node_ids: list[str],
command: str,
timeout: float,
@@ -204,7 +220,7 @@ async def _dispatch_parallel(
Total wall time is bounded by the slowest node.
"""
tasks = [
asyncio.to_thread(_exec_on_node_sync, server_kw, nid, command, timeout) for nid in node_ids
asyncio.to_thread(_exec_on_node_sync, console_kw, nid, command, timeout) for nid in node_ids
]
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
@@ -220,16 +236,22 @@ async def _dispatch_parallel(
return results
def _list_nodes_sync(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
"""List active cluster nodes (blocking)."""
with TurnstoneServer(**server_kw) as client:
nodes: list[dict[str, Any]] = client.list_nodes()
return nodes
def _list_nodes_sync(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
"""List active cluster nodes (blocking), paginating if needed."""
page_size = 100
nodes: list[dict[str, Any]] = []
with TurnstoneConsole(**console_kw) as console:
while True:
resp = console.nodes(limit=page_size, offset=len(nodes))
nodes.extend(n.model_dump() for n in resp.nodes)
if len(nodes) >= resp.total or not resp.nodes:
break
return nodes
async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
async def _list_nodes_impl(console_kw: dict[str, Any]) -> list[dict[str, Any]]:
"""List active cluster nodes."""
return await asyncio.to_thread(_list_nodes_sync, server_kw)
return await asyncio.to_thread(_list_nodes_sync, console_kw)
# ---------------------------------------------------------------------------
@@ -239,9 +261,9 @@ async def _list_nodes_impl(server_kw: dict[str, Any]) -> list[dict[str, Any]]:
@asynccontextmanager
async def _lifespan(server: FastMCP[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]:
"""Lifespan context — stores server connection kwargs for tool handlers."""
kw = _server_kwargs()
yield {"server_kwargs": kw}
"""Lifespan context — stores console connection kwargs for tool handlers."""
kw = _console_kwargs()
yield {"console_kwargs": kw}
mcp = FastMCP(
@@ -263,8 +285,8 @@ async def list_nodes(ctx: Context[Any, Any, Any]) -> str:
Call this before dispatching work to discover available node IDs.
Returns a JSON array of node metadata objects.
"""
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
nodes = await _list_nodes_impl(server_kw)
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
nodes = await _list_nodes_impl(console_kw)
return json.dumps(nodes, indent=2)
@@ -291,13 +313,16 @@ async def run_on_node(
if cmd_err:
return json.dumps({"error": cmd_err})
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
log.info("run_on_node node=%s cmd=%r", node_id, command)
_, result = await asyncio.to_thread(
_exec_on_node_sync, server_kw, node_id, command, _clamp_timeout(timeout)
)
try:
_, result = await asyncio.to_thread(
_exec_on_node_sync, console_kw, node_id, command, _clamp_timeout(timeout)
)
except Exception as exc:
return json.dumps({"node": node_id, "ok": False, "error": str(exc)}, indent=2)
formatted = _format_node_result(node_id, result, max_output)
return json.dumps(formatted, indent=2)
@@ -323,7 +348,7 @@ async def run_on_nodes(
if cmd_err:
return json.dumps({"error": cmd_err})
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
clean_ids = list(dict.fromkeys(nid.strip() for nid in node_ids if nid.strip()))
@@ -336,7 +361,7 @@ async def run_on_nodes(
log.info("run_on_nodes nodes=%s cmd=%r", clean_ids, command)
results = await _dispatch_parallel(
server_kw, clean_ids, command, _clamp_timeout(timeout), max_output
console_kw, clean_ids, command, _clamp_timeout(timeout), max_output
)
return json.dumps(results, indent=2)
@@ -361,18 +386,14 @@ async def run_on_all_nodes(
if cmd_err:
return json.dumps({"error": cmd_err})
server_kw: dict[str, Any] = ctx.request_context.lifespan_context["server_kwargs"]
console_kw: dict[str, Any] = ctx.request_context.lifespan_context["console_kwargs"]
max_output = _DEFAULT_MAX_OUTPUT
nodes = await _list_nodes_impl(server_kw)
nodes = await _list_nodes_impl(console_kw)
if not nodes:
return json.dumps({"error": "No active nodes found in cluster"})
node_ids = list(
dict.fromkeys(
nid.strip() for n in nodes if (nid := n.get("node_id") or n.get("id")) and nid.strip()
)
)
node_ids = _extract_node_ids(nodes)
if not node_ids:
return json.dumps({"error": "No nodes with identifiable IDs found"})
if len(node_ids) > _MAX_CONCURRENT_NODES:
@@ -381,7 +402,7 @@ async def run_on_all_nodes(
)
log.info("run_on_all_nodes nodes=%s cmd=%r", node_ids, command)
results = await _dispatch_parallel(
server_kw, node_ids, command, _clamp_timeout(timeout), max_output
console_kw, node_ids, command, _clamp_timeout(timeout), max_output
)
return json.dumps(results, indent=2)
@@ -7,6 +7,7 @@ from turnstone.sdk import TurnResult
from mcp_cluster_ops.server import (
_clamp_timeout,
_exec_prompt,
_extract_node_ids,
_extract_output,
_format_node_result,
_truncate,
@@ -190,3 +191,53 @@ class TestClampTimeout:
def test_negative(self):
assert _clamp_timeout(-1) == 5.0
# ---------------------------------------------------------------------------
# _extract_node_ids
# ---------------------------------------------------------------------------
class TestExtractNodeIds:
def test_normal(self):
nodes = [
{"node_id": "a", "server_url": "http://a:8080"},
{"node_id": "b", "server_url": "http://b:8080"},
]
assert _extract_node_ids(nodes) == ["a", "b"]
def test_deduplicates(self):
nodes = [
{"node_id": "a"},
{"node_id": "a"},
{"node_id": "b"},
]
assert _extract_node_ids(nodes) == ["a", "b"]
def test_strips_whitespace(self):
nodes = [{"node_id": " a "}, {"node_id": "b "}]
assert _extract_node_ids(nodes) == ["a", "b"]
def test_skips_empty(self):
nodes = [
{"node_id": "a"},
{"node_id": ""},
{"node_id": " "},
{"node_id": "b"},
]
assert _extract_node_ids(nodes) == ["a", "b"]
def test_skips_missing_key(self):
nodes = [
{"node_id": "a"},
{"server_url": "http://orphan:8080"},
{"node_id": "b"},
]
assert _extract_node_ids(nodes) == ["a", "b"]
def test_empty_list(self):
assert _extract_node_ids([]) == []
def test_all_empty_ids(self):
nodes = [{"node_id": ""}, {"node_id": " "}]
assert _extract_node_ids(nodes) == []
+184 -59
View File
@@ -1,11 +1,13 @@
"""Tests for MCP tool handlers with mocked TurnstoneServer."""
"""Tests for MCP tool handlers with mocked SDK clients."""
from __future__ import annotations
import asyncio
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.sdk import TurnResult
from mcp_cluster_ops.server import (
@@ -14,6 +16,22 @@ from mcp_cluster_ops.server import (
_list_nodes_impl,
)
_CONSOLE_KW: dict[str, Any] = {"base_url": "http://localhost:8090", "token": ""}
_CONSOLE_KW_AUTH: dict[str, Any] = {"base_url": "http://localhost:8090", "token": "tok_test"}
def _mock_console_ctx(mock_cls: MagicMock, mock_client: MagicMock) -> None:
"""Wire up a TurnstoneConsole mock as a context manager."""
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
def _mock_server_ctx(mock_cls: MagicMock, mock_server: MagicMock) -> None:
"""Wire up a TurnstoneServer mock as a context manager."""
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
# ---------------------------------------------------------------------------
# _list_nodes_impl
# ---------------------------------------------------------------------------
@@ -21,26 +39,71 @@ from mcp_cluster_ops.server import (
class TestListNodesImpl:
def test_returns_nodes(self):
nodes = [{"node_id": "a", "model": "gpt-5"}, {"node_id": "b", "model": "gpt-5"}]
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
mock_client = MagicMock()
mock_client.list_nodes.return_value = nodes
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_node_a = MagicMock()
mock_node_a.model_dump.return_value = {"node_id": "a", "server_url": "http://a:8080"}
mock_node_b = MagicMock()
mock_node_b.model_dump.return_value = {"node_id": "b", "server_url": "http://b:8080"}
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
assert result == nodes
mock_resp = MagicMock()
mock_resp.nodes = [mock_node_a, mock_node_b]
mock_resp.total = 2
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
mock_client = MagicMock()
mock_client.nodes.return_value = mock_resp
_mock_console_ctx(mock_cls, mock_client)
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
assert len(result) == 2
assert result[0]["node_id"] == "a"
assert result[1]["node_id"] == "b"
def test_empty_cluster(self):
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
mock_client = MagicMock()
mock_client.list_nodes.return_value = []
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
mock_resp = MagicMock()
mock_resp.nodes = []
mock_resp.total = 0
result = asyncio.run(_list_nodes_impl({"host": "localhost"}))
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
mock_client = MagicMock()
mock_client.nodes.return_value = mock_resp
_mock_console_ctx(mock_cls, mock_client)
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
assert result == []
def test_paginates_large_clusters(self):
"""Clusters with >100 nodes are fetched across multiple pages."""
def _make_node(nid: str) -> MagicMock:
m = MagicMock()
m.model_dump.return_value = {"node_id": nid}
return m
page1_nodes = [_make_node(f"n-{i}") for i in range(100)]
page2_nodes = [_make_node(f"n-{i}") for i in range(100, 150)]
page1_resp = MagicMock()
page1_resp.nodes = page1_nodes
page1_resp.total = 150
page2_resp = MagicMock()
page2_resp.nodes = page2_nodes
page2_resp.total = 150
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_cls:
mock_client = MagicMock()
mock_client.nodes.side_effect = [page1_resp, page2_resp]
_mock_console_ctx(mock_cls, mock_client)
result = asyncio.run(_list_nodes_impl(_CONSOLE_KW))
assert len(result) == 150
assert result[0]["node_id"] == "n-0"
assert result[149]["node_id"] == "n-149"
assert mock_client.nodes.call_count == 2
# Verify offset was passed correctly
mock_client.nodes.assert_any_call(limit=100, offset=0)
mock_client.nodes.assert_any_call(limit=100, offset=100)
# ---------------------------------------------------------------------------
# _exec_on_node_sync
@@ -50,35 +113,111 @@ class TestListNodesImpl:
class TestExecOnNodeSync:
def test_success(self):
turn_result = TurnResult(
ws_id="ws-123",
tool_results=[("bash", "hello world")],
)
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
mock_client = MagicMock()
mock_client.send_and_wait.return_value = turn_result
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
with (
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
):
mock_console = MagicMock()
mock_console.route_create_workstream.return_value = {
"ws_id": "ws-123",
"node_url": "http://node-1:8080",
"node_id": "node-1",
"name": "ws-123",
}
_mock_console_ctx(mock_console_cls, mock_console)
node_id, result = _exec_on_node_sync(
{"host": "localhost"}, "node-1", "echo hello", 60.0
)
mock_server = MagicMock()
mock_server.send_and_wait.return_value = turn_result
_mock_server_ctx(mock_server_cls, mock_server)
node_id, result = _exec_on_node_sync(_CONSOLE_KW_AUTH, "node-1", "echo hello", 60.0)
assert node_id == "node-1"
assert result.ok
mock_client.send_and_wait.assert_called_once()
call_kwargs = mock_client.send_and_wait.call_args
assert call_kwargs.kwargs["target_node"] == "node-1"
assert call_kwargs.kwargs["auto_approve"] is True
# Verify console created ws on the right node
mock_console.route_create_workstream.assert_called_once_with(
target_node="node-1",
auto_approve=True,
)
# Verify server connected to the node URL with the token
mock_server_cls.assert_called_once_with(
base_url="http://node-1:8080",
token="tok_test",
)
# Verify send_and_wait got the right ws_id
call_kwargs = mock_server.send_and_wait.call_args
assert call_kwargs.args[1] == "ws-123"
# Verify workstream was closed
mock_console.route_close.assert_called_once_with("ws-123")
def test_timeout(self):
turn_result = TurnResult(timed_out=True)
with patch("mcp_cluster_ops.server.TurnstoneServer") as mock_cls:
mock_client = MagicMock()
mock_client.send_and_wait.return_value = turn_result
mock_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
mock_cls.return_value.__exit__ = MagicMock(return_value=False)
turn_result = TurnResult(ws_id="ws-456", timed_out=True)
with (
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
):
mock_console = MagicMock()
mock_console.route_create_workstream.return_value = {
"ws_id": "ws-456",
"node_url": "http://node-1:8080",
"node_id": "node-1",
}
_mock_console_ctx(mock_console_cls, mock_console)
_, result = _exec_on_node_sync({"host": "localhost"}, "node-1", "sleep 9999", 1.0)
mock_server = MagicMock()
mock_server.send_and_wait.return_value = turn_result
_mock_server_ctx(mock_server_cls, mock_server)
_, result = _exec_on_node_sync(_CONSOLE_KW, "node-1", "sleep 9999", 1.0)
assert result.timed_out
assert not result.ok
# Workstream still closed even on timeout
mock_console.route_close.assert_called_once_with("ws-456")
def test_send_failure_still_closes_workstream(self):
"""Workstream must be closed even if send_and_wait raises."""
with (
patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls,
patch("mcp_cluster_ops.server.TurnstoneServer") as mock_server_cls,
):
mock_console = MagicMock()
mock_console.route_create_workstream.return_value = {
"ws_id": "ws-789",
"node_url": "http://node-1:8080",
"node_id": "node-1",
}
_mock_console_ctx(mock_console_cls, mock_console)
mock_server = MagicMock()
mock_server.send_and_wait.side_effect = ConnectionError("lost connection")
_mock_server_ctx(mock_server_cls, mock_server)
with contextlib.suppress(ConnectionError):
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
mock_console.route_close.assert_called_once_with("ws-789")
def test_malformed_route_response_no_leak(self):
"""If route response is missing ws_id, no route_close is attempted."""
with patch("mcp_cluster_ops.server.TurnstoneConsole") as mock_console_cls:
mock_console = MagicMock()
mock_console.route_create_workstream.return_value = {
# Missing "ws_id" and "node_url"
"node_id": "node-1",
}
_mock_console_ctx(mock_console_cls, mock_console)
with pytest.raises(KeyError):
_exec_on_node_sync(_CONSOLE_KW, "node-1", "echo hi", 60.0)
# route_close must NOT be called — ws_id was never assigned
mock_console.route_close.assert_not_called()
# ---------------------------------------------------------------------------
@@ -88,40 +227,32 @@ class TestExecOnNodeSync:
class TestDispatchParallel:
def test_parallel_success(self):
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
return (node_id, TurnResult(tool_results=[("bash", f"output-{node_id}")]))
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
return (
node_id,
TurnResult(tool_results=[("bash", f"output-{node_id}")]),
)
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["a", "b", "c"],
"echo hi",
60.0,
8192,
)
_dispatch_parallel(_CONSOLE_KW, ["a", "b", "c"], "echo hi", 60.0, 8192)
)
assert len(results) == 3
assert all(r["ok"] for r in results)
outputs = {r["node"]: r["output"] for r in results}
assert outputs["a"] == "output-a"
assert outputs["b"] == "output-b"
assert outputs["c"] == "output-c"
def test_partial_failure(self):
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
if node_id == "bad":
raise ConnectionError("connection refused")
return (node_id, TurnResult(tool_results=[("bash", "ok")]))
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["good", "bad"],
"echo hi",
60.0,
8192,
)
_dispatch_parallel(_CONSOLE_KW, ["good", "bad"], "echo hi", 60.0, 8192)
)
assert len(results) == 2
good = next(r for r in results if r["node"] == "good")
@@ -131,18 +262,12 @@ class TestDispatchParallel:
assert "connection refused" in bad["error"]
def test_all_fail(self):
def fake_exec(server_kw: Any, node_id: str, command: str, timeout: float) -> Any:
def fake_exec(console_kw: Any, node_id: str, command: str, timeout: float) -> Any:
raise RuntimeError(f"fail-{node_id}")
with patch("mcp_cluster_ops.server._exec_on_node_sync", side_effect=fake_exec):
results = asyncio.run(
_dispatch_parallel(
{"host": "localhost"},
["a", "b"],
"echo hi",
60.0,
8192,
)
_dispatch_parallel(_CONSOLE_KW, ["a", "b"], "echo hi", 60.0, 8192)
)
assert all(not r["ok"] for r in results)
assert "fail-a" in results[0]["error"]
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.9.7"
version = "1.0.0"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -12,7 +12,7 @@ requires-python = ">=3.11"
authors = [{name = "Patrick Buckley", email = "buckleypm@gmail.com"}]
keywords = ["ai", "chat", "llm", "agent", "tools", "openai"]
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
@@ -78,7 +78,7 @@ include = [
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.44/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.13.0/**/*",
"turnstone/shared_static/mermaid-11.14.0/**/*",
"turnstone/sdk/py.typed",
]
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Bump version, regenerate lockfile, commit, and tag.
#
# Usage:
# scripts/release.sh 1.0.0 # stable release
# scripts/release.sh 1.1.0a1 # experimental pre-release
# scripts/release.sh 1.0.1 --push # bump + push tag to origin
#
set -euo pipefail
VERSION="${1:?Usage: scripts/release.sh VERSION [--push]}"
PUSH="${2:-}"
# Validate PEP 440 version
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(a[0-9]+|b[0-9]+|rc[0-9]+)?$'; then
echo "error: invalid PEP 440 version: $VERSION" >&2
echo " examples: 1.0.0, 1.1.0a1, 1.0.1rc2" >&2
exit 1
fi
TAG="v${VERSION}"
# Check for clean working tree
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty — commit or stash first" >&2
exit 1
fi
# Check tag doesn't already exist
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "error: tag $TAG already exists" >&2
exit 1
fi
# Detect current version
CURRENT=$(grep -oP '(?<=^version = ")[^"]+' pyproject.toml)
echo "Bumping $CURRENT$VERSION"
# Update version in both files
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
sed -i "s/^__version__ = \".*\"/__version__ = \"$VERSION\"/" turnstone/__init__.py
# Regenerate lockfile
echo "Regenerating uv.lock..."
uv lock
# Commit and tag
git add pyproject.toml turnstone/__init__.py uv.lock
git commit -m "chore: bump version to $VERSION"
git tag "$TAG"
echo ""
echo "Created commit and tag $TAG"
if [ "$PUSH" = "--push" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Pushing $BRANCH + $TAG to origin..."
git push origin "$BRANCH" "$TAG"
else
echo "Run 'git push origin <branch> $TAG' to publish"
fi
+1 -1
View File
@@ -7,7 +7,7 @@
*
* const client = new TurnstoneServer({
* baseUrl: "http://localhost:8080",
* token: "tok_xxx",
* token: "ts_your_api_token",
* });
*
* const ws = await client.createWorkstream({ name: "demo" });
+37 -8
View File
@@ -6,6 +6,37 @@ from unittest.mock import MagicMock
import pytest
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _server_jwt() -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt(
user_id="test-versioning",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
def _console_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-versioning",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
_CONSOLE_AUTH_HEADERS = {"Authorization": f"Bearer {_console_jwt()}"}
class TestServerVersioning:
"""Test /v1/ routes and OpenAPI endpoints on the server."""
@@ -14,7 +45,6 @@ class TestServerVersioning:
def client(self):
from starlette.testclient import TestClient
from turnstone.core.auth import AuthConfig
from turnstone.server import create_app
mock_mgr = MagicMock()
@@ -26,19 +56,19 @@ class TestServerVersioning:
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_workstreams(self, client):
resp = client.get("/v1/api/workstreams")
resp = client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
assert resp.status_code == 200
assert "workstreams" in resp.json()
def test_unversioned_api_404(self, client):
resp = client.get("/api/workstreams")
resp = client.get("/api/workstreams", headers=_SERVER_AUTH_HEADERS)
assert resp.status_code == 404
def test_openapi_json(self, client):
@@ -72,7 +102,6 @@ class TestConsoleVersioning:
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
@@ -84,18 +113,18 @@ class TestConsoleVersioning:
}
app = create_app(
collector=collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
yield client
client.close()
def test_v1_cluster_overview(self, client):
resp = client.get("/v1/api/cluster/overview")
resp = client.get("/v1/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
assert resp.status_code == 200
def test_unversioned_api_404(self, client):
resp = client.get("/api/cluster/overview")
resp = client.get("/api/cluster/overview", headers=_CONSOLE_AUTH_HEADERS)
assert resp.status_code == 404
def test_openapi_json(self, client):
+263 -332
View File
@@ -9,12 +9,11 @@ import pytest
from turnstone.core.auth import (
WRITE_PATHS,
AuthConfig,
_extract_bearer,
_extract_cookie,
check_request,
create_jwt,
is_public_path,
load_auth_config,
make_clear_cookie,
make_set_cookie,
required_scope,
@@ -199,37 +198,6 @@ class TestRequiredScope:
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
# ---------------------------------------------------------------------------
# TestAuthConfig
# ---------------------------------------------------------------------------
class TestAuthConfig:
def test_check_valid_full_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
assert cfg.check("tok_full") == "full"
def test_check_valid_read_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full", "tok_read": "read"})
assert cfg.check("tok_read") == "read"
def test_check_invalid_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check("wrong") is None
def test_check_none_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check(None) is None
def test_check_empty_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
assert cfg.check("") is None
def test_check_no_tokens(self):
cfg = AuthConfig(enabled=True, tokens={})
assert cfg.check("anything") is None
# ---------------------------------------------------------------------------
# TestExtractBearer
# ---------------------------------------------------------------------------
@@ -353,167 +321,157 @@ class TestMakeClearCookie:
class TestCheckRequest:
"""Tests for the main check_request() entry point."""
@pytest.fixture()
def disabled(self):
return AuthConfig(enabled=False)
_SECRET = "test-jwt-secret-minimum-32-chars!"
@pytest.fixture()
def enabled(self):
return AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
)
def read_jwt(self):
return f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', self._SECRET)}"
def test_disabled_allows_all(self, disabled):
allowed, status, msg, _result = check_request(disabled, "POST", "/api/send", None)
@pytest.fixture()
def full_jwt(self):
return f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', self._SECRET)}"
def test_public_path_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/health", None)
assert allowed is True
assert status == 200
def test_disabled_allows_no_header(self, disabled):
allowed, status, msg, _result = check_request(disabled, "GET", "/api/workstreams", None)
def test_public_root_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/", None)
assert allowed is True
def test_public_path_no_token_ok(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/health", None)
assert allowed is True
assert status == 200
def test_public_root_no_token_ok(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/", None)
def test_public_static_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
assert allowed is True
def test_public_static_no_token_ok(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/static/style.css", None)
assert allowed is True
def test_api_no_token_401(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/api/workstreams", None)
def test_api_no_token_401(self):
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
assert allowed is False
assert status == 401
assert "Unauthorized" in msg
def test_api_invalid_token_401(self, enabled):
def test_api_invalid_token_401(self):
allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer wrong_token"
"GET", "/api/workstreams", "Bearer wrong_token"
)
assert allowed is False
assert status == 401
def test_api_read_token_ok(self, enabled):
def test_api_read_token_ok(self, read_jwt):
allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer tok_read"
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
)
assert allowed is True
assert status == 200
def test_api_full_token_ok(self, enabled):
def test_api_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
enabled, "GET", "/api/workstreams", "Bearer tok_full"
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
def test_write_read_token_403(self, enabled):
def test_write_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send", "Bearer tok_read"
"POST", "/api/send", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
assert "Forbidden" in msg
def test_write_full_token_ok(self, enabled):
def test_write_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send", "Bearer tok_full"
"POST", "/api/send", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
assert status == 200
def test_approve_read_token_403(self, enabled):
def test_approve_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/approve", "Bearer tok_read"
"POST", "/api/approve", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_proxy_write_read_token_403(self, enabled):
def test_proxy_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via proxy routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_read"
"POST", "/node/node-a/api/send", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_proxy_write_trailing_slash_read_token_403(self, enabled):
def test_proxy_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on proxy routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send/", "Bearer tok_read"
"POST", "/node/node-a/api/send/", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_direct_write_trailing_slash_read_token_403(self, enabled):
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/send/", "Bearer tok_read"
"POST", "/api/send/", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_proxy_write_full_token_ok(self, enabled):
def test_proxy_write_full_token_ok(self, full_jwt):
"""Full tokens pass through proxy write routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/api/send", "Bearer tok_full"
"POST", "/node/node-a/api/send", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
def test_proxy_v1_write_read_token_403(self, enabled):
def test_proxy_v1_write_read_token_403(self, read_jwt):
"""Read tokens cannot escalate to write ops via v1 proxy routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_read"
"POST", "/node/node-a/v1/api/send", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_proxy_v1_write_full_token_ok(self, enabled):
def test_proxy_v1_write_full_token_ok(self, full_jwt):
"""Full tokens pass through v1 proxy write routes."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/node/node-a/v1/api/send", "Bearer tok_full"
"POST", "/node/node-a/v1/api/send", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
def test_proxy_v1_cluster_ws_new_read_403(self, enabled):
def test_proxy_v1_cluster_ws_new_read_403(self, read_jwt):
"""Read tokens cannot create workstreams via v1 proxy."""
allowed, status, msg, _result = check_request(
enabled,
"POST",
"/node/node-a/v1/api/cluster/workstreams/new",
"Bearer tok_read",
read_jwt,
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
def test_proxy_read_endpoint_read_token_ok(self, enabled):
def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
"""Read tokens can access proxy read endpoints."""
allowed, status, msg, _result = check_request(
enabled, "GET", "/node/node-a/api/workstreams", "Bearer tok_read"
"GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
)
assert allowed is True
def test_console_create_ws_read_token_403(self, enabled):
def test_console_create_ws_read_token_403(self, read_jwt):
"""Read tokens cannot create workstreams."""
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/cluster/workstreams/new", "Bearer tok_read"
"POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
)
assert allowed is False
assert status == 403
def test_approve_full_token_ok(self, enabled):
def test_approve_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
enabled, "POST", "/api/approve", "Bearer tok_full"
"POST", "/api/approve", full_jwt, jwt_secret=self._SECRET
)
assert allowed is True
def test_no_auth_header_string(self, enabled):
allowed, status, msg, _result = check_request(enabled, "GET", "/api/dashboard", "")
def test_no_auth_header_string(self):
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
assert allowed is False
assert status == 401
@@ -526,70 +484,71 @@ class TestCheckRequest:
class TestCheckRequestWithCookie:
"""Tests for cookie-based auth fallback in check_request."""
@pytest.fixture()
def enabled(self):
return AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
)
_SECRET = "test-jwt-secret-minimum-32-chars!"
def test_cookie_fallback_when_no_bearer(self, enabled):
@pytest.fixture()
def read_jwt(self):
return create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
@pytest.fixture()
def full_jwt(self):
return create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
def test_cookie_fallback_when_no_bearer(self, read_jwt):
allowed, status, _, _r = check_request(
enabled,
"GET",
"/api/workstreams",
None,
cookie_header="turnstone_auth=tok_read",
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
)
assert allowed is True
assert status == 200
def test_bearer_takes_precedence_over_cookie(self, enabled):
# Bearer is full, cookie is read — Bearer should win
def test_bearer_takes_precedence_over_cookie(self, read_jwt, full_jwt):
allowed, status, _, _r = check_request(
enabled,
"POST",
"/api/send",
"Bearer tok_full",
cookie_header="turnstone_auth=tok_read",
f"Bearer {full_jwt}",
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
)
assert allowed is True
def test_invalid_cookie_401(self, enabled):
def test_invalid_cookie_401(self):
allowed, status, _, _r = check_request(
enabled,
"GET",
"/api/workstreams",
None,
cookie_header="turnstone_auth=wrong_token",
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 401
def test_cookie_read_on_write_403(self, enabled):
def test_cookie_read_on_write_403(self, read_jwt):
allowed, status, _, _r = check_request(
enabled,
"POST",
"/api/send",
None,
cookie_header="turnstone_auth=tok_read",
cookie_header=f"turnstone_auth={read_jwt}",
jwt_secret=self._SECRET,
)
assert allowed is False
assert status == 403
def test_cookie_full_on_write_ok(self, enabled):
def test_cookie_full_on_write_ok(self, full_jwt):
allowed, status, _, _r = check_request(
enabled,
"POST",
"/api/send",
None,
cookie_header="turnstone_auth=tok_full",
cookie_header=f"turnstone_auth={full_jwt}",
jwt_secret=self._SECRET,
)
assert allowed is True
def test_no_cookie_no_bearer_401(self, enabled):
def test_no_cookie_no_bearer_401(self):
allowed, status, _, _r = check_request(
enabled,
"GET",
"/api/workstreams",
None,
@@ -598,18 +557,16 @@ class TestCheckRequestWithCookie:
assert allowed is False
assert status == 401
def test_login_path_public(self, enabled):
def test_login_path_public(self):
allowed, status, _, _r = check_request(
enabled,
"POST",
"/api/auth/login",
None,
)
assert allowed is True
def test_logout_path_public(self, enabled):
def test_logout_path_public(self):
allowed, status, _, _r = check_request(
enabled,
"POST",
"/api/auth/logout",
None,
@@ -617,139 +574,6 @@ class TestCheckRequestWithCookie:
assert allowed is True
# ---------------------------------------------------------------------------
# TestLoadAuthConfig
# ---------------------------------------------------------------------------
class TestLoadAuthConfig:
"""Tests for load_auth_config with mocked config + env vars."""
def test_default_enabled(self):
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_auth_config()
assert cfg.enabled is True
assert cfg.tokens == {}
def test_explicit_disable(self):
with (
patch("turnstone.core.config.load_config", return_value={"enabled": False}),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert cfg.enabled is False
def test_env_disable(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "0"}, clear=True),
):
cfg = load_auth_config()
assert cfg.enabled is False
def test_config_file_tokens(self):
mock_cfg = {
"enabled": True,
"tokens": [
{"value": "tok_a", "role": "full"},
{"value": "tok_b", "role": "read"},
],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert cfg.enabled is True
assert cfg.tokens == {"tok_a": "full", "tok_b": "read"}
def test_env_var_enabled(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "1"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
def test_env_var_token(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
):
cfg = load_auth_config()
assert "tok_env" in cfg.tokens
assert cfg.tokens["tok_env"] == "full"
def test_config_plus_env_merge(self):
mock_cfg = {
"enabled": True,
"tokens": [{"value": "tok_cfg", "role": "read"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {"TURNSTONE_AUTH_TOKEN": "tok_env"}, clear=False),
):
cfg = load_auth_config()
assert cfg.tokens["tok_cfg"] == "read"
assert cfg.tokens["tok_env"] == "full"
def test_invalid_role_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": [
{"value": "tok_ok", "role": "full"},
{"value": "tok_bad", "role": "admin"},
],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert "tok_ok" in cfg.tokens
assert "tok_bad" not in cfg.tokens
def test_empty_value_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": [{"value": "", "role": "full"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert len(cfg.tokens) == 0
def test_non_dict_token_entry_skipped(self):
mock_cfg = {
"enabled": True,
"tokens": ["not_a_dict", {"value": "tok_ok", "role": "full"}],
}
with (
patch("turnstone.core.config.load_config", return_value=mock_cfg),
patch.dict(os.environ, {}, clear=True),
):
cfg = load_auth_config()
assert cfg.tokens == {"tok_ok": "full"}
def test_env_enabled_true(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "true"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
def test_env_enabled_yes(self):
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {"TURNSTONE_AUTH_ENABLED": "yes"}, clear=False),
):
cfg = load_auth_config()
assert cfg.enabled is True
# ---------------------------------------------------------------------------
# Integration tests — actual HTTP server with auth enabled
# ---------------------------------------------------------------------------
@@ -785,16 +609,22 @@ class TestServerAuth:
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
from turnstone.core.auth import JWT_AUD_SERVER
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
cls._read_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
}
cls._full_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_SERVER)}"
}
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
jwt_secret=cls._jwt_secret,
cors_origins=["*"],
)
cls.client = TestClient(app, raise_server_exceptions=False)
@@ -809,7 +639,6 @@ class TestServerAuth:
def test_metrics_no_token_passes_auth(self):
resp = self.client.get("/metrics")
# Public path — should never be 401/403
assert resp.status_code not in (401, 403)
def test_root_no_token_200(self):
@@ -826,23 +655,17 @@ class TestServerAuth:
assert "Unauthorized" in resp.json().get("error", "")
def test_api_workstreams_read_token_200(self):
resp = self.client.get(
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_read"},
)
resp = self.client.get("/v1/api/workstreams", headers=self._read_hdr)
assert resp.status_code == 200
def test_api_workstreams_full_token_200(self):
resp = self.client.get(
"/v1/api/workstreams",
headers={"Authorization": "Bearer tok_full"},
)
resp = self.client.get("/v1/api/workstreams", headers=self._full_hdr)
assert resp.status_code == 200
def test_api_send_read_token_403(self):
resp = self.client.post(
"/v1/api/send",
headers={"Authorization": "Bearer tok_read"},
headers=self._read_hdr,
json={"message": "hello", "ws_id": "x"},
)
assert resp.status_code == 403
@@ -851,10 +674,9 @@ class TestServerAuth:
def test_api_send_full_token_passes_auth(self):
resp = self.client.post(
"/v1/api/send",
headers={"Authorization": "Bearer tok_full"},
headers=self._full_hdr,
json={"message": "hello", "ws_id": "nonexistent"},
)
# Should get 404 (unknown workstream), not 401/403
assert resp.status_code not in (401, 403)
def test_api_send_no_token_401(self):
@@ -921,12 +743,18 @@ class TestConsoleAuth:
"aggregate": {"total_tokens": 100},
}
from turnstone.core.auth import JWT_AUD_CONSOLE
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
cls._read_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
}
cls._full_hdr = {
"Authorization": f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', cls._jwt_secret, audience=JWT_AUD_CONSOLE)}"
}
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
jwt_secret=cls._jwt_secret,
)
cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -947,17 +775,11 @@ class TestConsoleAuth:
assert resp.status_code == 401
def test_api_overview_read_token_200(self):
resp = self.test_client.get(
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_read"},
)
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._read_hdr)
assert resp.status_code == 200
def test_api_overview_full_token_200(self):
resp = self.test_client.get(
"/v1/api/cluster/overview",
headers={"Authorization": "Bearer tok_full"},
)
resp = self.test_client.get("/v1/api/cluster/overview", headers=self._full_hdr)
assert resp.status_code == 200
def test_invalid_token_401(self):
@@ -1003,16 +825,33 @@ class TestServerLogin:
mock_mgr.list_all.return_value = [mock_ws]
mock_mgr.max_workstreams = 10
# Mock storage with a test user for password login
from turnstone.core.auth import hash_password
mock_storage = MagicMock()
mock_storage.get_user_by_username.side_effect = lambda u: (
{
"user_id": "uid_test",
"username": "testuser",
"password_hash": hash_password("testpass"),
"display_name": "Test",
}
if u == "testuser"
else None
)
mock_storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
]
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
app = srv_mod.create_app(
workstreams=mock_mgr,
global_queue=queue.Queue(),
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
jwt_secret=cls._jwt_secret,
auth_storage=mock_storage,
)
cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -1020,36 +859,36 @@ class TestServerLogin:
def teardown_class(cls):
cls.test_client.close()
def test_login_valid_token_sets_cookie(self):
def test_login_config_token_rejected(self):
"""Config token exchange is no longer allowed."""
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_full"},
)
assert resp.status_code == 200
data = resp.json()
assert data["role"] == "full"
cookie = resp.headers.get("set-cookie", "")
assert "turnstone_auth=tok_full" in cookie
assert "HttpOnly" in cookie
assert resp.status_code == 401
def test_login_invalid_token_401(self):
def test_login_invalid_credentials_401(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "wrong"},
json={"username": "testuser", "password": "wrong"},
)
assert resp.status_code == 401
def test_login_no_auth_required(self):
# /v1/api/auth/login is public — shouldn't require auth itself
def test_login_password_ok(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_read"},
json={"username": "testuser", "password": "testpass"},
)
assert resp.status_code == 200
data = resp.json()
assert "jwt" in data
def test_cookie_auth_on_api(self):
# Login to get cookie (TestClient tracks cookies automatically)
login_resp = self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
login_resp = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert login_resp.status_code == 200
# Use cookie to access API — TestClient forwards cookies
@@ -1057,7 +896,10 @@ class TestServerLogin:
assert resp.status_code == 200
def test_logout_clears_cookie(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
# Logout
logout_resp = self.test_client.post("/v1/api/auth/logout")
@@ -1081,6 +923,7 @@ class TestConsoleLogin:
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import hash_password
_load_static()
@@ -1092,12 +935,26 @@ class TestConsoleLogin:
"aggregate": {"total_tokens": 100},
}
mock_storage = MagicMock()
mock_storage.get_user_by_username.side_effect = lambda u: (
{
"user_id": "uid_test",
"username": "testuser",
"password_hash": hash_password("testpass"),
"display_name": "Test",
}
if u == "testuser"
else None
)
mock_storage.list_user_roles.return_value = [
{"role_id": "builtin-admin", "scopes": "read,write,approve"}
]
cls._jwt_secret = "test-jwt-secret-minimum-32-chars!"
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(
enabled=True,
tokens={"tok_full": "full", "tok_read": "read"},
),
jwt_secret=cls._jwt_secret,
auth_storage=mock_storage,
)
cls.test_client = TestClient(app, raise_server_exceptions=False)
@@ -1105,28 +962,34 @@ class TestConsoleLogin:
def teardown_class(cls):
cls.test_client.close()
def test_login_valid_token(self):
def test_login_config_token_rejected(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "tok_read"},
)
assert resp.status_code == 401
def test_login_password_ok(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
assert resp.status_code == 200
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
def test_login_invalid_token(self):
resp = self.test_client.post(
"/v1/api/auth/login",
json={"token": "wrong"},
)
assert resp.status_code == 401
def test_cookie_auth_on_api(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 200
def test_logout_then_api_fails(self):
self.test_client.post("/v1/api/auth/login", json={"token": "tok_read"})
self.test_client.post(
"/v1/api/auth/login",
json={"username": "testuser", "password": "testpass"},
)
self.test_client.post("/v1/api/auth/logout")
resp = self.test_client.get("/v1/api/cluster/overview")
assert resp.status_code == 401
@@ -1385,25 +1248,29 @@ class TestIsSecureRequest:
class TestSecretStrength:
def test_short_secret_warns(self, caplog):
import logging
def test_short_secret_exits(self):
import turnstone.core.auth as auth_mod
from turnstone.core.auth import _MIN_SECRET_LENGTH
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
os.environ["TURNSTONE_JWT_SECRET"] = "short"
try:
with pytest.raises(SystemExit):
auth_mod.load_jwt_secret()
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
else:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
with caplog.at_level(logging.WARNING, logger="turnstone.core.auth"):
import turnstone.core.auth as auth_mod
def test_missing_secret_exits(self):
import turnstone.core.auth as auth_mod
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
os.environ["TURNSTONE_JWT_SECRET"] = "short"
try:
secret = auth_mod.load_jwt_secret()
assert secret == "short"
assert any(str(_MIN_SECRET_LENGTH) in r.message for r in caplog.records)
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
else:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {}, clear=True),
pytest.raises(SystemExit),
):
auth_mod.load_jwt_secret()
class TestCorsConfigurable:
@@ -1424,7 +1291,6 @@ class TestCorsConfigurable:
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(enabled=False),
)
client = TestClient(app)
resp = client.get("/health", headers={"Origin": "http://evil.com"})
@@ -1446,7 +1312,6 @@ class TestCorsConfigurable:
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(enabled=False),
cors_origins=["http://example.com"],
)
client = TestClient(app)
@@ -1502,3 +1367,69 @@ class TestOIDCPublicPaths:
def test_oidc_callback_is_public(self):
assert is_public_path("/api/auth/oidc/callback") is True
assert is_public_path("/v1/api/auth/oidc/callback") is True
# ---------------------------------------------------------------------------
# TestRequirePermissionServiceScope — service scope bypasses permission checks
# ---------------------------------------------------------------------------
class TestRequirePermissionServiceScope:
"""Verify require_permission() behaviour with the service scope."""
def _make_request(self, auth_result):
"""Build a mock Starlette request with the given AuthResult on state."""
request = MagicMock()
request.state.auth_result = auth_result
return request
def test_service_scope_bypasses_permission(self):
"""Service-scoped tokens bypass all permission checks (returns None)."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="svc-agent",
scopes=frozenset({"service"}),
token_source="jwt",
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is None # bypass — no 403
def test_without_service_scope_and_without_permission_returns_403(self):
"""Non-service tokens without the required permission get 403."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="regular-user",
scopes=frozenset({"read", "write"}),
token_source="jwt",
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is not None
assert result.status_code == 403
def test_without_service_scope_with_permission_returns_none(self):
"""Non-service tokens with the required permission pass."""
from turnstone.core.auth import AuthResult, require_permission
auth = AuthResult(
user_id="admin-user",
scopes=frozenset({"read", "write", "approve"}),
token_source="jwt",
permissions=frozenset({"admin.users"}),
)
request = self._make_request(auth)
result = require_permission(request, "admin.users")
assert result is None # granted — no 403
def test_no_auth_result_returns_401(self):
"""Missing auth_result on request state returns 401."""
from turnstone.core.auth import require_permission
request = MagicMock()
del request.state.auth_result # ensure attribute is absent
result = require_permission(request, "admin.users")
assert result is not None
assert result.status_code == 401
+37 -57
View File
@@ -7,7 +7,6 @@ import time
import pytest
from turnstone.core.auth import (
AuthConfig,
AuthResult,
_authenticate_token,
check_request,
@@ -203,24 +202,10 @@ class TestRequiredScope:
class TestAuthenticateToken:
def test_config_token_read(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
result = _authenticate_token("tok_read", cfg)
assert result is not None
assert result.scopes == frozenset({"read"})
assert result.token_source == "config"
def test_config_token_full(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
result = _authenticate_token("tok_full", cfg)
assert result is not None
assert result.scopes == frozenset({"read", "write", "approve"})
def test_jwt_token(self):
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)
result = _authenticate_token(jwt_tok, jwt_secret=secret)
assert result is not None
assert result.user_id == "user1"
assert result.token_source == "db"
@@ -243,8 +228,7 @@ class TestAuthenticateToken:
}
return None
cfg = AuthConfig(enabled=True)
result = _authenticate_token(raw, cfg, storage=MockStorage())
result = _authenticate_token(raw, storage=MockStorage())
assert result is not None
assert result.user_id == "user1"
assert result.has_scope("write")
@@ -266,13 +250,11 @@ class TestAuthenticateToken:
"expires": "2020-01-02T00:00:00",
}
cfg = AuthConfig(enabled=True)
result = _authenticate_token(raw, cfg, storage=MockStorage())
result = _authenticate_token(raw, storage=MockStorage())
assert result is None
def test_unknown_token(self):
cfg = AuthConfig(enabled=True, tokens={"tok": "full"})
result = _authenticate_token("unknown", cfg)
result = _authenticate_token("unknown")
assert result is None
@@ -282,76 +264,74 @@ class TestAuthenticateToken:
class TestCheckRequestScopes:
def test_config_read_on_write_403(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
allowed, status, msg, _ = check_request(cfg, "POST", "/api/send", "Bearer tok_read")
_SECRET = "test-secret-key-for-jwt-min-32b!"
def test_jwt_read_on_write_403(self):
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert not allowed
assert status == 403
assert "write" in msg
def test_config_read_on_approve_403(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
allowed, status, msg, _ = check_request(cfg, "POST", "/api/approve", "Bearer tok_read")
def test_jwt_read_on_approve_403(self):
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
"POST",
"/api/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert not allowed
assert status == 403
assert "approve" in msg
def test_config_full_on_approve_ok(self):
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
allowed, status, msg, result = check_request(cfg, "POST", "/api/approve", "Bearer tok_full")
def test_jwt_full_on_approve_ok(self):
jwt_tok = create_jwt("u1", frozenset({"read", "write", "approve"}), "test", self._SECRET)
allowed, status, msg, result = check_request(
"POST",
"/api/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert allowed
assert result is not None
assert result.has_scope("approve")
def test_jwt_with_scopes(self):
secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
cfg = AuthConfig(enabled=True)
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", self._SECRET)
allowed, status, msg, result = check_request(
cfg,
"POST",
"/api/send",
f"Bearer {jwt_tok}",
jwt_secret=secret,
jwt_secret=self._SECRET,
)
assert allowed
assert result is not None
assert result.user_id == "u1"
def test_jwt_insufficient_scope(self):
secret = "test-secret-key-for-jwt-min-32b!"
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
cfg = AuthConfig(enabled=True)
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", self._SECRET)
allowed, status, msg, _ = check_request(
cfg,
"POST",
"/api/send",
f"Bearer {jwt_tok}",
jwt_secret=secret,
jwt_secret=self._SECRET,
)
assert not allowed
assert status == 403
def test_admin_path_requires_approve(self):
cfg = AuthConfig(enabled=True, tokens={"tok_read": "read"})
jwt_tok = create_jwt("u1", frozenset({"read"}), "test", self._SECRET)
allowed, status, msg, _ = check_request(
cfg,
"GET",
"/v1/api/admin/users",
"Bearer tok_read",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
)
assert not allowed
assert status == 403
def test_backward_compat_role_full(self):
"""Config tokens with role='full' get all scopes."""
cfg = AuthConfig(enabled=True, tokens={"tok_full": "full"})
allowed, _, _, result = check_request(
cfg,
"GET",
"/v1/api/admin/users",
"Bearer tok_full",
)
assert allowed
assert result is not None
assert result.has_scope("approve")
+501 -5
View File
@@ -229,13 +229,15 @@ class TestMessageCog:
ts.router.send_message.assert_not_awaited()
def test_ignores_dms(self):
def test_dm_without_reference_sends_guidance(self):
cog, ts, _bot = self._make_cog()
msg = _make_message(guild=False)
dm_channel = AsyncMock()
msg = _make_message(guild=False, channel=dm_channel)
_run(cog._on_message(msg))
ts.router.send_message.assert_not_awaited()
dm_channel.send.assert_awaited_once()
def test_ignores_non_allowed_channels(self):
cog, ts, _bot = self._make_cog()
@@ -329,6 +331,8 @@ class TestWsEventFinalization:
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
@@ -358,6 +362,8 @@ class TestWsEventFinalization:
bot = MagicMock(spec=TurnstoneBot)
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
@@ -391,6 +397,8 @@ class TestApprovalVerdictDisplay:
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
@@ -509,6 +517,8 @@ class TestApprovalVerdictDisplay:
bot = MagicMock(spec=TurnstoneBot)
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {"ws-1": MagicMock()}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
@@ -533,6 +543,8 @@ class TestStreamEndBehavior:
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
@@ -702,8 +714,8 @@ class TestNotificationTracking:
# Should send feedback to the DM channel
dm_channel.send.assert_awaited_once_with("*This notification is no longer active.*")
def test_dm_without_reference_ignored(self):
"""DM without a message reference should be ignored."""
def test_dm_without_reference_sends_guidance(self):
"""DM without a message reference should reply with guidance."""
from turnstone.channels.discord.cog import MessageCog
bot = MagicMock()
@@ -718,11 +730,15 @@ class TestNotificationTracking:
bot.turnstone = ts
cog = MessageCog(bot)
msg = _make_message(guild=False) # reference=None
dm_channel = AsyncMock()
msg = _make_message(guild=False, channel=dm_channel) # reference=None
_run(cog._on_message(msg))
ts.router.send_message.assert_not_awaited()
dm_channel.send.assert_awaited_once()
sent_text = dm_channel.send.call_args[0][0]
assert "/ask" in sent_text
def test_dm_reply_unlinked_user_ignored(self):
"""DM reply from an unlinked user should be ignored."""
@@ -760,6 +776,8 @@ class TestNotificationTracking:
bot.config.max_message_length = 2000
bot.config.streaming_edit_interval = 1.5
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_ws_map = {}
bot._MAX_NOTIFY_TRACKING = 100
@@ -797,6 +815,8 @@ class TestNotificationTracking:
bot = MagicMock(spec=TurnstoneBot)
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_ws_map = {}
@@ -817,6 +837,482 @@ class TestNotificationTracking:
assert len(bot._notify_ws_map) == 0
# ---------------------------------------------------------------------------
# Formatter: format_tool_result
# ---------------------------------------------------------------------------
class TestFormatToolResult:
"""Tests for format_tool_result in _formatter.py."""
def test_basic_output(self):
from turnstone.channels._formatter import format_tool_result
result = format_tool_result("hello world")
assert "```" in result
assert "hello world" in result
def test_wraps_in_code_block(self):
from turnstone.channels._formatter import format_tool_result
result = format_tool_result("output text")
assert result.startswith("```\n")
assert result.endswith("\n```")
def test_truncates_long_output_by_lines(self):
from turnstone.channels._formatter import format_tool_result
output = "\n".join(f"line {i}" for i in range(20))
result = format_tool_result(output)
# Should have at most 10 content lines + ellipsis
inner = result.split("```")[1]
assert inner.strip().count("\n") <= 11
def test_truncates_long_output_by_chars(self):
from turnstone.channels._formatter import format_tool_result
output = "x" * 600
result = format_tool_result(output)
# Code block content should be <= 500 chars (497 + ellipsis)
inner = result.split("```")[1].strip()
assert len(inner) <= 501 # 497 + ellipsis char
def test_escapes_triple_backticks_in_output(self):
from turnstone.channels._formatter import format_tool_result
output = "before ``` after"
result = format_tool_result(output)
# Only the opening and closing code fences should remain as ```.
assert result.count("```") == 2
# ---------------------------------------------------------------------------
# Thinking indicator lifecycle
# ---------------------------------------------------------------------------
class TestThinkingIndicator:
"""Tests for ThinkingStart/Stop event handling in the Discord bot."""
def _make_bot(self):
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
bot.config.streaming_edit_interval = 1.5
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
return bot
def test_thinking_start_sends_message(self):
from turnstone.sdk.events import ThinkingStartEvent
bot = self._make_bot()
thread = AsyncMock()
sent_msg = MagicMock()
thread.send = AsyncMock(return_value=sent_msg)
event = ThinkingStartEvent(ws_id="ws-1")
_run(bot._on_ws_event("ws-1", thread, event))
thread.send.assert_awaited_once_with("*Thinking...*")
assert bot._thinking_msgs["ws-1"] is sent_msg
def test_thinking_stop_preserves_message_for_reuse(self):
from turnstone.sdk.events import ThinkingStopEvent
bot = self._make_bot()
thread = AsyncMock()
thinking_msg = MagicMock()
thinking_msg.delete = AsyncMock()
bot._thinking_msgs["ws-1"] = thinking_msg
event = ThinkingStopEvent(ws_id="ws-1")
_run(bot._on_ws_event("ws-1", thread, event))
# Message kept for next event to reuse via edit.
thinking_msg.delete.assert_not_awaited()
assert "ws-1" in bot._thinking_msgs
def test_thinking_stop_without_message_is_noop(self):
from turnstone.sdk.events import ThinkingStopEvent
bot = self._make_bot()
thread = AsyncMock()
event = ThinkingStopEvent(ws_id="ws-1")
_run(bot._on_ws_event("ws-1", thread, event))
def test_content_event_reuses_thinking_message(self):
from turnstone.sdk.events import ContentEvent
bot = self._make_bot()
thread = AsyncMock()
thinking_msg = MagicMock()
thinking_msg.edit = AsyncMock()
bot._thinking_msgs["ws-1"] = thinking_msg
event = ContentEvent(ws_id="ws-1", text="Hello")
_run(bot._on_ws_event("ws-1", thread, event))
# Thinking message becomes the StreamingMessage base — no delete.
assert "ws-1" not in bot._thinking_msgs
sm = bot._streaming["ws-1"]
assert sm._message is thinking_msg
def test_stream_end_clears_thinking_message(self):
from turnstone.sdk.events import StreamEndEvent
bot = self._make_bot()
thread = AsyncMock()
thinking_msg = MagicMock()
thinking_msg.delete = AsyncMock()
bot._thinking_msgs["ws-1"] = thinking_msg
bot._notify_reply_channels = {}
event = StreamEndEvent(ws_id="ws-1")
_run(bot._on_ws_event("ws-1", thread, event))
thinking_msg.delete.assert_awaited_once()
assert "ws-1" not in bot._thinking_msgs
# ---------------------------------------------------------------------------
# Tool info / result embeds
# ---------------------------------------------------------------------------
class TestToolInfoEvent:
"""Tests for ToolInfoEvent handling in the Discord bot."""
def _make_bot(self):
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
bot.config.streaming_edit_interval = 1.5
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
return bot
def test_sends_per_item_embed(self):
from turnstone.sdk.events import ToolInfoEvent
bot = self._make_bot()
thread = AsyncMock()
sent_msg = MagicMock()
thread.send = AsyncMock(return_value=sent_msg)
items = [{"func_name": "bash", "preview": "ls -la", "needs_approval": False}]
event = ToolInfoEvent(ws_id="ws-1", items=items)
_run(bot._on_ws_event("ws-1", thread, event))
thread.send.assert_awaited_once()
embed = thread.send.call_args[1]["embed"]
assert embed.title == "bash"
assert embed.description == "ls -la"
# Message tracked for later editing by ToolResultEvent.
assert bot._tool_info_msgs["ws-1"] == [("", "bash", "ls -la", sent_msg)]
def test_multiple_tools_send_multiple_embeds(self):
from turnstone.sdk.events import ToolInfoEvent
bot = self._make_bot()
thread = AsyncMock()
items = [
{"func_name": "bash", "preview": "ls", "needs_approval": False},
{"func_name": "read_file", "preview": "/etc", "needs_approval": False},
]
event = ToolInfoEvent(ws_id="ws-1", items=items)
_run(bot._on_ws_event("ws-1", thread, event))
assert thread.send.await_count == 2
assert len(bot._tool_info_msgs["ws-1"]) == 2
def test_shows_all_items_regardless_of_approval(self):
from turnstone.sdk.events import ToolInfoEvent
bot = self._make_bot()
thread = AsyncMock()
items = [
{"func_name": "bash", "preview": "rm -rf /", "needs_approval": True},
{"func_name": "read_file", "preview": "/etc/hosts", "needs_approval": False},
]
event = ToolInfoEvent(ws_id="ws-1", items=items)
_run(bot._on_ws_event("ws-1", thread, event))
# Both items shown — running indicator is separate from approval dialog.
assert thread.send.await_count == 2
def test_reuses_thinking_message_for_first_tool(self):
from turnstone.sdk.events import ToolInfoEvent
bot = self._make_bot()
thread = AsyncMock()
thinking_msg = MagicMock()
thinking_msg.edit = AsyncMock()
bot._thinking_msgs["ws-1"] = thinking_msg
items = [{"func_name": "bash", "preview": "ls -la", "needs_approval": False}]
event = ToolInfoEvent(ws_id="ws-1", items=items)
_run(bot._on_ws_event("ws-1", thread, event))
# Thinking message edited into tool embed, no new message sent.
thinking_msg.edit.assert_awaited_once()
thread.send.assert_not_awaited()
assert "ws-1" not in bot._thinking_msgs
# The reused message is tracked for ToolResultEvent editing.
assert bot._tool_info_msgs["ws-1"][0][3] is thinking_msg
class TestToolResultEvent:
"""Tests for ToolResultEvent handling in the Discord bot."""
def _make_bot(self):
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
bot.config.streaming_edit_interval = 1.5
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
return bot
def test_marks_info_done_and_sends_result(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
# Pre-populate a tool info message (as ToolInfoEvent would).
info_msg = MagicMock()
info_msg.edit = AsyncMock()
bot._tool_info_msgs["ws-1"] = [("", "bash", "ls -la", info_msg)]
event = ToolResultEvent(ws_id="ws-1", name="bash", output="file1\nfile2")
_run(bot._on_ws_event("ws-1", thread, event))
# Info embed edited to "Done" status.
info_msg.edit.assert_awaited_once()
status_embed = info_msg.edit.call_args[1]["embed"]
assert "Done" in status_embed.title
assert status_embed.description == "ls -la" # preview preserved
# Result sent as separate new message.
thread.send.assert_awaited_once()
result_embed = thread.send.call_args[1]["embed"]
assert result_embed.title == "bash"
assert "file1" in result_embed.description
# Entry consumed from tracking list.
assert bot._tool_info_msgs["ws-1"] == []
def test_result_sent_even_without_info_match(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
event = ToolResultEvent(ws_id="ws-1", name="bash", output="file1\nfile2")
_run(bot._on_ws_event("ws-1", thread, event))
thread.send.assert_awaited_once()
embed = thread.send.call_args[1]["embed"]
assert embed.title == "bash"
assert "file1" in embed.description
def test_error_result_uses_red_color(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
event = ToolResultEvent(
ws_id="ws-1", name="bash", output="command not found", is_error=True
)
_run(bot._on_ws_event("ws-1", thread, event))
embed = thread.send.call_args[1]["embed"]
assert embed.color == discord.Color.red()
def test_success_result_uses_dark_grey_color(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
event = ToolResultEvent(ws_id="ws-1", name="bash", output="ok")
_run(bot._on_ws_event("ws-1", thread, event))
embed = thread.send.call_args[1]["embed"]
assert embed.color == discord.Color.dark_grey()
def test_call_id_matching(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
first_msg = MagicMock()
first_msg.edit = AsyncMock()
second_msg = MagicMock()
second_msg.edit = AsyncMock()
bot._tool_info_msgs["ws-1"] = [
("call-1", "bash", "", first_msg),
("call-2", "bash", "", second_msg),
]
# Result with call_id matches the correct message regardless of order.
event = ToolResultEvent(ws_id="ws-1", call_id="call-2", name="bash", output="result")
_run(bot._on_ws_event("ws-1", thread, event))
second_msg.edit.assert_awaited_once()
first_msg.edit.assert_not_awaited()
def test_fifo_fallback_when_no_call_id(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
first_msg = MagicMock()
first_msg.edit = AsyncMock()
second_msg = MagicMock()
second_msg.edit = AsyncMock()
bot._tool_info_msgs["ws-1"] = [("", "bash", "", first_msg), ("", "bash", "", second_msg)]
# No call_id — falls back to FIFO name match.
event1 = ToolResultEvent(ws_id="ws-1", name="bash", output="result1")
_run(bot._on_ws_event("ws-1", thread, event1))
first_msg.edit.assert_awaited_once()
second_msg.edit.assert_not_awaited()
event2 = ToolResultEvent(ws_id="ws-1", name="bash", output="result2")
_run(bot._on_ws_event("ws-1", thread, event2))
second_msg.edit.assert_awaited_once()
def test_edit_failure_falls_back_to_send(self):
from turnstone.sdk.events import ToolResultEvent
bot = self._make_bot()
thread = AsyncMock()
info_msg = MagicMock()
info_msg.edit = AsyncMock(side_effect=Exception("Discord API error"))
bot._tool_info_msgs["ws-1"] = [("", "bash", "ls -la", info_msg)]
event = ToolResultEvent(ws_id="ws-1", name="bash", output="ok")
_run(bot._on_ws_event("ws-1", thread, event))
# Edit failed, should fall back to send.
info_msg.edit.assert_awaited_once()
thread.send.assert_awaited_once()
# ---------------------------------------------------------------------------
# Approval resolved (timeout / external resolution)
# ---------------------------------------------------------------------------
class TestApprovalResolved:
"""ApprovalResolvedEvent should disable buttons on the pending approval embed."""
def _make_bot(self):
from turnstone.channels.discord.bot import TurnstoneBot
bot = MagicMock(spec=TurnstoneBot)
bot.config = MagicMock()
bot.config.max_message_length = 2000
bot.config.streaming_edit_interval = 1.5
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._thinking_msgs = {}
bot._tool_info_msgs = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
bot._should_auto_approve = MagicMock(return_value=False)
bot._on_ws_event = TurnstoneBot._on_ws_event.__get__(bot, TurnstoneBot)
return bot
def test_disables_buttons_on_timeout(self):
from turnstone.sdk.events import ApprovalResolvedEvent
bot = self._make_bot()
thread = AsyncMock()
# Set up a pending approval message with components.
approval_msg = MagicMock()
approval_msg.embeds = [MagicMock()]
approval_msg.components = []
approval_msg.edit = AsyncMock()
bot._pending_approval_msgs["ws-1"] = approval_msg
event = ApprovalResolvedEvent(ws_id="ws-1", approved=False, feedback="timeout")
_run(bot._on_ws_event("ws-1", thread, event))
approval_msg.edit.assert_awaited_once()
# Pending approval message should be removed.
assert "ws-1" not in bot._pending_approval_msgs
def test_disables_buttons_on_approved(self):
from turnstone.sdk.events import ApprovalResolvedEvent
bot = self._make_bot()
thread = AsyncMock()
approval_msg = MagicMock()
approval_msg.embeds = [MagicMock()]
approval_msg.components = []
approval_msg.edit = AsyncMock()
bot._pending_approval_msgs["ws-1"] = approval_msg
event = ApprovalResolvedEvent(ws_id="ws-1", approved=True)
_run(bot._on_ws_event("ws-1", thread, event))
approval_msg.edit.assert_awaited_once()
# Check the embed title was updated with "Approved".
edited_embed = approval_msg.edit.call_args[1]["embed"]
assert "Approved" in edited_embed.title
def test_no_pending_approval_is_noop(self):
from turnstone.sdk.events import ApprovalResolvedEvent
bot = self._make_bot()
thread = AsyncMock()
event = ApprovalResolvedEvent(ws_id="ws-1", approved=False)
_run(bot._on_ws_event("ws-1", thread, event))
# No error, no state change.
class TestChannelCLI:
"""Tests for the channel CLI entry point."""
+229 -125
View File
@@ -3,12 +3,30 @@
import asyncio
import json
import queue
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
from turnstone.console.collector import ClusterCollector, NodeSnapshot
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-console",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_TEST_AUTH_HEADERS = {"Authorization": f"Bearer {_test_jwt()}"}
# ---------------------------------------------------------------------------
# Mock storage for collector tests
# ---------------------------------------------------------------------------
@@ -29,32 +47,15 @@ class MockStorage:
# ---------------------------------------------------------------------------
def _make_collector(storage=None, poll_interval=0, discovery_interval=999):
"""Create a collector with zero poll interval (no jitter delay in tests)."""
def _make_collector(storage=None, discovery_interval=999):
"""Create a collector for tests (discovery disabled by default)."""
s = storage or MockStorage()
return ClusterCollector(
storage=s,
poll_interval=poll_interval,
discovery_interval=discovery_interval,
)
def _dashboard_response(workstreams=None, aggregate=None):
"""Build a /v1/api/dashboard-style response dict."""
return {
"workstreams": workstreams or [],
"aggregate": aggregate
or {
"total_tokens": 0,
"total_tool_calls": 0,
"active_count": 0,
"total_count": 0,
"uptime_seconds": 0,
"node": "local",
},
}
# ---------------------------------------------------------------------------
# ClusterCollector — unit tests
# ---------------------------------------------------------------------------
@@ -157,30 +158,35 @@ class TestCollectorDiscovery:
assert c._nodes["node-a"].started == 1234567890.0
class TestCollectorPolling:
"""Polling /v1/api/dashboard from nodes."""
class TestCollectorSnapshot:
"""Applying node_snapshot SSE events."""
def test_apply_poll_populates_workstreams(self):
def test_apply_snapshot_populates_workstreams(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
dashboard = _dashboard_response(
workstreams=[
{
"id": "ws1",
"name": "test",
"state": "running",
"tokens": 1000,
"context_ratio": 0.15,
"activity": "bash: ls",
"activity_state": "tool",
"tool_calls": 3,
"title": "My task",
},
],
aggregate={"total_tokens": 1000, "total_tool_calls": 3},
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [
{
"id": "ws1",
"name": "test",
"state": "running",
"tokens": 1000,
"context_ratio": 0.15,
"activity": "bash: ls",
"activity_state": "tool",
"tool_calls": 3,
"title": "My task",
},
],
"health": {"status": "ok"},
"aggregate": {"total_tokens": 1000, "total_tool_calls": 3},
},
)
c._apply_poll("node-a", dashboard, {"status": "ok"})
detail = c.get_node_detail("node-a")
assert len(detail["workstreams"]) == 1
@@ -189,7 +195,7 @@ class TestCollectorPolling:
assert detail["workstreams"][0]["server_url"] == "http://a:8080"
assert detail["health"]["status"] == "ok"
def test_apply_poll_replaces_stale_workstreams(self):
def test_apply_snapshot_replaces_stale_workstreams(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
@@ -197,30 +203,44 @@ class TestCollectorPolling:
workstreams={"old-ws": {"id": "old-ws", "name": "old", "state": "idle"}},
)
dashboard = _dashboard_response(
workstreams=[{"id": "new-ws", "name": "new", "state": "running"}]
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [{"id": "new-ws", "name": "new", "state": "running"}],
"health": {},
"aggregate": {},
},
)
c._apply_poll("node-a", dashboard, {})
detail = c.get_node_detail("node-a")
assert len(detail["workstreams"]) == 1
assert detail["workstreams"][0]["id"] == "new-ws"
def test_apply_poll_ignores_unknown_node(self):
def test_apply_snapshot_ignores_unknown_node(self):
c = _make_collector()
# Should not raise
c._apply_poll("unknown", _dashboard_response(), {})
c._apply_snapshot(
"unknown", {"type": "node_snapshot", "workstreams": [], "health": {}, "aggregate": {}}
)
def test_apply_poll_emits_ws_created_for_new_workstream(self):
def test_apply_snapshot_emits_ws_created_for_new_workstream(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
dashboard = _dashboard_response(
workstreams=[{"id": "ws1", "name": "new-task", "state": "idle"}]
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [{"id": "ws1", "name": "new-task", "state": "idle"}],
"health": {},
"aggregate": {},
},
)
c._apply_poll("node-a", dashboard, {})
event = q.get_nowait()
assert event["type"] == "ws_created"
@@ -228,7 +248,7 @@ class TestCollectorPolling:
assert event["name"] == "new-task"
assert event["node_id"] == "node-a"
def test_apply_poll_emits_ws_closed_for_removed_workstream(self):
def test_apply_snapshot_emits_ws_closed_for_removed_workstream(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
@@ -238,13 +258,22 @@ class TestCollectorPolling:
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
c._apply_poll("node-a", _dashboard_response(), {})
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [],
"health": {},
"aggregate": {},
},
)
event = q.get_nowait()
assert event["type"] == "ws_closed"
assert event["ws_id"] == "ws1"
def test_apply_poll_no_events_when_unchanged(self):
def test_apply_snapshot_no_events_when_unchanged(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
@@ -254,15 +283,21 @@ class TestCollectorPolling:
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
# Same state — no events expected
dashboard = _dashboard_response(
workstreams=[{"id": "ws1", "name": "same", "state": "idle"}]
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [{"id": "ws1", "name": "same", "state": "idle"}],
"health": {},
"aggregate": {},
},
)
c._apply_poll("node-a", dashboard, {})
assert q.empty()
def test_apply_poll_emits_state_change(self):
def test_apply_snapshot_emits_state_change_as_cluster_state(self):
"""State change events must use type 'cluster_state' for the frontend."""
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
@@ -272,30 +307,141 @@ class TestCollectorPolling:
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
dashboard = _dashboard_response(
workstreams=[{"id": "ws1", "name": "same", "state": "running"}]
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [{"id": "ws1", "name": "same", "state": "running"}],
"health": {},
"aggregate": {},
},
)
c._apply_poll("node-a", dashboard, {})
event = q.get_nowait()
assert event["type"] == "ws_state"
assert event["type"] == "cluster_state"
assert event["ws_id"] == "ws1"
assert event["state"] == "running"
def test_apply_poll_skips_empty_id_workstream(self):
def test_apply_snapshot_skips_empty_id_workstream(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
dashboard = _dashboard_response(workstreams=[{"name": "no-id", "state": "idle"}])
c._apply_poll("node-a", dashboard, {})
c._apply_snapshot(
"node-a",
{
"type": "node_snapshot",
"node_id": "node-a",
"workstreams": [{"name": "no-id", "state": "idle"}],
"health": {},
"aggregate": {},
},
)
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."""
class TestCollectorDelta:
"""Applying individual SSE delta events."""
def test_apply_delta_ws_state_fans_out_as_cluster_state(self):
"""Server emits ws_state; collector must translate to cluster_state."""
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
workstreams={"ws1": {"id": "ws1", "name": "test", "state": "idle"}},
)
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
c._apply_delta(
"node-a", {"type": "ws_state", "ws_id": "ws1", "state": "running", "tokens": 500}
)
event = q.get_nowait()
assert event["type"] == "cluster_state"
assert event["state"] == "running"
# Verify in-memory state was updated
assert c._nodes["node-a"].workstreams["ws1"]["state"] == "running"
def test_apply_delta_ws_created(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
c._apply_delta("node-a", {"type": "ws_created", "ws_id": "ws1", "name": "new"})
event = q.get_nowait()
assert event["type"] == "ws_created"
assert "ws1" in c._nodes["node-a"].workstreams
def test_apply_delta_ws_closed(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
workstreams={"ws1": {"id": "ws1", "name": "old", "state": "idle"}},
)
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
c._apply_delta("node-a", {"type": "ws_closed", "ws_id": "ws1"})
event = q.get_nowait()
assert event["type"] == "ws_closed"
assert "ws1" not in c._nodes["node-a"].workstreams
def test_apply_delta_ws_rename(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
workstreams={"ws1": {"id": "ws1", "name": "old-name", "state": "idle"}},
)
q: queue.Queue[dict] = queue.Queue()
c.register_listener(q)
c._apply_delta("node-a", {"type": "ws_rename", "ws_id": "ws1", "name": "new-name"})
event = q.get_nowait()
assert event["type"] == "ws_rename"
assert event["name"] == "new-name"
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "new-name"
def test_apply_delta_health_changed(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
server_url="http://a:8080",
health={"status": "ok", "backend": {"status": "up", "circuit_state": "closed"}},
)
c._apply_delta("node-a", {"type": "health_changed", "circuit_state": "open"})
health = c._nodes["node-a"].health
assert health["backend"]["circuit_state"] == "open"
assert health["backend"]["status"] == "down"
assert health["status"] == "degraded"
def test_apply_delta_aggregate(self):
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
c._apply_delta(
"node-a",
{"type": "aggregate", "total_tokens": 5000, "total_tool_calls": 42, "active_count": 3},
)
assert c._nodes["node-a"].aggregate["total_tokens"] == 5000
assert c._nodes["node-a"].aggregate["total_tool_calls"] == 42
def test_mark_unreachable_preserves_workstreams(self):
"""Disconnection marks unreachable but preserves workstream data."""
c = _make_collector()
c._nodes["node-a"] = NodeSnapshot(
node_id="node-a",
@@ -304,47 +450,12 @@ class TestCollectorPolling:
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
)
# Mock httpx to return 401
import httpx as _httpx
c._mark_unreachable("node-a")
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 TestCollectorFanout:
"""SSE fan-out to registered listeners."""
@@ -618,13 +729,11 @@ class TestConsoleHTTPEndpoints:
_load_static()
from turnstone.core.auth import AuthConfig
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client
client.close()
@@ -860,12 +969,11 @@ class TestConsoleWorkstreamCreation:
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
# Set up a mock proxy_client (lifespan doesn't run in TestClient)
@@ -881,7 +989,7 @@ class TestConsoleWorkstreamCreation:
mock_proxy.post = mock_post
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client, mock_post
client.close()
@@ -1058,14 +1166,13 @@ class TestConsoleProxy:
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client
client.close()
@@ -1229,14 +1336,13 @@ class TestConsoleVersionEndpoints:
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
app = create_app(
collector=mock_collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client
client.close()
@@ -1271,7 +1377,6 @@ class TestSharedStatic:
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
@@ -1283,9 +1388,9 @@ class TestSharedStatic:
}
app = create_app(
collector=collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
yield client
client.close()
@@ -1388,7 +1493,6 @@ class TestProxySharedStatic:
from starlette.testclient import TestClient
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
collector = MagicMock(spec=ClusterCollector)
@@ -1401,9 +1505,9 @@ class TestProxySharedStatic:
collector.get_node_detail.return_value = None
app = create_app(
collector=collector,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
client = TestClient(app, raise_server_exceptions=False)
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
resp = client.get("/node/unknown/shared/base.css")
assert resp.status_code == 404
client.close()
@@ -1722,14 +1826,14 @@ class TestProxyAuthHeaders:
# Should use ServiceTokenManager, not mint a user JWT
assert headers["Authorization"] == f"Bearer {mgr.token}"
def test_fallback_static_token(self):
"""No auth_result, no ServiceTokenManager → uses static proxy_auth_token."""
def test_no_mgr_no_user_returns_empty(self):
"""No auth_result, no ServiceTokenManager → empty headers."""
from turnstone.console.server import _proxy_auth_headers
req = self._make_request(proxy_auth_token="static-tok-123")
req = self._make_request()
headers = _proxy_auth_headers(req)
assert headers == {"Authorization": "Bearer static-tok-123"}
assert headers == {}
# ---------------------------------------------------------------------------
+40 -7
View File
@@ -13,6 +13,24 @@ from turnstone.console.collector import ClusterCollector
from turnstone.console.router import ConsoleRouter, NodeRef
from turnstone.core.hash_ring import NoAvailableNodeError
# Shared test auth — JWT-based
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _test_jwt() -> str:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
return create_jwt(
user_id="test-routing",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
_TEST_AUTH_HEADERS: dict[str, str] = {"Authorization": f"Bearer {_test_jwt()}"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -42,12 +60,11 @@ def _make_app(
router: Any = None,
) -> Any:
from turnstone.console.server import _load_static, create_app
from turnstone.core.auth import AuthConfig
_load_static()
return create_app(
collector=collector or _make_mock_collector(),
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
router=router,
)
@@ -100,6 +117,7 @@ class TestRouteCreate:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -109,6 +127,7 @@ class TestRouteCreate:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -126,6 +145,7 @@ class TestRouteCreate:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"resume_ws": "old_ws_id"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -150,6 +170,7 @@ class TestRouteCreate:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"target_node": "node-c"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -203,6 +224,7 @@ class TestRouteCreate503Retry:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test-ws"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
data = resp.json()
@@ -233,6 +255,7 @@ class TestRouteProxy:
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc123", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
# Verify upstream URL was /v1/api/send (not /v1/api/route/send)
@@ -245,6 +268,7 @@ class TestRouteProxy:
resp = client.post(
"/v1/api/route/approve",
json={"ws_id": "abc123", "approved": True},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -252,6 +276,7 @@ class TestRouteProxy:
resp = client.post(
"/v1/api/route/cancel",
json={"ws_id": "abc123"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -259,6 +284,7 @@ class TestRouteProxy:
resp = client.post(
"/v1/api/route/command",
json={"ws_id": "abc123", "command": "status"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -266,6 +292,7 @@ class TestRouteProxy:
resp = client.post(
"/v1/api/route/workstreams/close",
json={"ws_id": "abc123"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 200
@@ -288,14 +315,14 @@ class TestRouteLookup:
client.close()
def test_route_lookup(self, client):
resp = client.get("/v1/api/route?ws_id=abc123")
resp = client.get("/v1/api/route?ws_id=abc123", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["node_url"] == "http://a:8080"
assert data["node_id"] == "node-a"
def test_route_lookup_missing_ws_id(self, client):
resp = client.get("/v1/api/route")
resp = client.get("/v1/api/route", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 400
assert "ws_id" in resp.json()["error"]
@@ -329,6 +356,7 @@ class TestRouteNotReady:
resp = client_no_router.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -336,6 +364,7 @@ class TestRouteNotReady:
resp = client_empty_cache.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
@@ -343,22 +372,24 @@ class TestRouteNotReady:
resp = client_no_router.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_no_router_503(self, client_no_router):
resp = client_no_router.get("/v1/api/route?ws_id=abc")
resp = client_no_router.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503
def test_route_proxy_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_empty_cache_503(self, client_empty_cache):
resp = client_empty_cache.get("/v1/api/route?ws_id=abc")
resp = client_empty_cache.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503
@@ -384,6 +415,7 @@ class TestRouteNoNode:
resp = client.post(
"/v1/api/route/workstreams/new",
json={"name": "test"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
assert "No available node" in resp.json()["error"]
@@ -392,9 +424,10 @@ class TestRouteNoNode:
resp = client.post(
"/v1/api/route/send",
json={"ws_id": "abc", "message": "hello"},
headers=_TEST_AUTH_HEADERS,
)
assert resp.status_code == 503
def test_route_lookup_no_node_503(self, client):
resp = client.get("/v1/api/route?ws_id=abc")
resp = client.get("/v1/api/route?ws_id=abc", headers=_TEST_AUTH_HEADERS)
assert resp.status_code == 503
+6 -1
View File
@@ -425,11 +425,16 @@ class TestSkillCatalogDisclosure:
session._mcp_client = None
session._notify_on_complete = "{}"
session._tool_error_flags = {}
from turnstone.prompts import ClientType
session._tools = []
session._client_type = ClientType.CLI
session._username = ""
# Memory stubs
session._memory_config = MagicMock()
session._memory_config.fetch_limit = 0
session._user_id = ""
session._user_id = "test-user"
with (
patch(
+38 -48
View File
@@ -8,8 +8,26 @@ import pytest
from starlette.testclient import TestClient
from turnstone.channels._http import create_channel_app
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
from turnstone.core.storage._sqlite import SQLiteBackend
_JWT_SECRET = "a" * 32
def _make_jwt() -> str:
"""Create a valid JWT for channel auth."""
return create_jwt(
user_id="system",
scopes=frozenset({"write"}),
source="service",
secret=_JWT_SECRET,
audience=JWT_AUD_CHANNEL,
)
def _auth_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {_make_jwt()}"}
@pytest.fixture
def storage(tmp_path):
@@ -33,22 +51,22 @@ def no_auth_client(storage, mock_adapter):
@pytest.fixture
def client(storage, mock_adapter):
"""Default client with static auth token configured."""
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
"""Default client with JWT auth configured."""
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app)
@pytest.fixture
def authed_client(storage, mock_adapter):
"""Alias same as client, for auth-specific test clarity."""
app = create_channel_app({"discord": mock_adapter}, storage, auth_token="test-secret-token")
"""Alias -- same as client, for auth-specific test clarity."""
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app)
@pytest.fixture
def jwt_client(storage, mock_adapter):
"""Client with JWT auth configured."""
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret="a" * 32)
app = create_channel_app({"discord": mock_adapter}, storage, jwt_secret=_JWT_SECRET)
return TestClient(app)
@@ -58,9 +76,6 @@ class TestNotifyEndpoint:
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def _headers(self) -> dict[str, str]:
return {"Authorization": "Bearer test-secret-token"}
def test_direct_discord_target(self, client, mock_adapter):
resp = client.post(
"/v1/api/notify",
@@ -68,7 +83,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123456"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 200
results = resp.json()["results"]
@@ -85,7 +100,7 @@ class TestNotifyEndpoint:
"message": "Hello!",
"title": "Alert",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 200
mock_adapter.send.assert_called_once_with("123456", "**Alert**\nHello!")
@@ -101,7 +116,7 @@ class TestNotifyEndpoint:
"target": {"username": "testuser"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 200
results = resp.json()["results"]
@@ -116,7 +131,7 @@ class TestNotifyEndpoint:
"target": {"username": "nobody"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 404
error = resp.json()["error"]
@@ -132,10 +147,10 @@ class TestNotifyEndpoint:
"target": {"username": "testuser"},
"message": "Hello!",
},
headers={"Authorization": "Bearer test-secret-token"},
headers=_auth_headers(),
)
assert resp.status_code == 404
# Generic message must not differentiate "not found" vs "no channels"
# Generic message -- must not differentiate "not found" vs "no channels"
error = resp.json()["error"]
assert "testuser" not in error
assert "not found or has no linked channels" in error
@@ -144,7 +159,7 @@ class TestNotifyEndpoint:
resp = client.post(
"/v1/api/notify",
json={"target": {"username": "x"}},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 400
@@ -152,7 +167,7 @@ class TestNotifyEndpoint:
resp = client.post(
"/v1/api/notify",
json={"message": "Hello!"},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 400
@@ -163,7 +178,7 @@ class TestNotifyEndpoint:
"target": {"invalid": "field"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 400
@@ -175,7 +190,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "email", "channel_id": "test@example.com"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 200
results = resp.json()["results"]
@@ -189,7 +204,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123456"},
"message": "Hello!",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 200
results = resp.json()["results"]
@@ -201,7 +216,7 @@ class TestNotifyEndpoint:
content=b"not json",
headers={
"content-type": "application/json",
"Authorization": "Bearer test-secret-token",
"Authorization": f"Bearer {_make_jwt()}",
},
)
assert resp.status_code == 400
@@ -214,7 +229,7 @@ class TestNotifyEndpoint:
"target": {"channel_type": "discord", "channel_id": "123"},
"message": " ",
},
headers=self._headers(),
headers=_auth_headers(),
)
assert resp.status_code == 400
@@ -256,30 +271,9 @@ class TestNotifyAuth:
)
assert resp.status_code == 401
def test_accept_valid_static_token(self, authed_client, mock_adapter):
"""Requests with correct static token are accepted."""
resp = authed_client.post(
"/v1/api/notify",
json={
"target": {"channel_type": "discord", "channel_id": "123"},
"message": "Hello!",
},
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
assert resp.json()["results"][0]["status"] == "sent"
def test_accept_valid_jwt(self, jwt_client, mock_adapter):
"""Requests with a valid JWT for the channel audience are accepted."""
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
token = create_jwt(
user_id="system",
scopes=frozenset({"write"}),
source="service",
secret="a" * 32,
audience=JWT_AUD_CHANNEL,
)
token = _make_jwt()
resp = jwt_client.post(
"/v1/api/notify",
json={
@@ -292,13 +286,11 @@ class TestNotifyAuth:
def test_reject_jwt_wrong_audience(self, jwt_client):
"""JWTs with wrong audience are rejected."""
from turnstone.core.auth import create_jwt
token = create_jwt(
user_id="system",
scopes=frozenset({"write"}),
source="service",
secret="a" * 32,
secret=_JWT_SECRET,
audience="turnstone-server", # wrong audience
)
resp = jwt_client.post(
@@ -313,8 +305,6 @@ class TestNotifyAuth:
def test_reject_jwt_wrong_secret(self, jwt_client):
"""JWTs signed with wrong secret are rejected."""
from turnstone.core.auth import JWT_AUD_CHANNEL, create_jwt
token = create_jwt(
user_id="system",
scopes=frozenset({"write"}),
+360
View File
@@ -0,0 +1,360 @@
"""Tests for the system message composition harness (turnstone.prompts)."""
from __future__ import annotations
import pytest
from turnstone.prompts import (
ClientType,
SessionContext,
compose_system_message,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_CTX = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="PDT",
username="sarah.chen",
)
_ALL_TOOLS: frozenset[str] = frozenset({"web_search", "read_file", "bash"})
_NO_TOOLS: frozenset[str] = frozenset()
# ---------------------------------------------------------------------------
# 1. Assembly smoke test per client type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("ct", [ClientType.WEB, ClientType.CLI, ClientType.CHAT])
def test_smoke_all_client_types(ct: ClientType) -> None:
result = compose_system_message(
client_type=ct,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
# BASE content present
assert "resident engineer" in result
# CONTEXT present
assert "sarah.chen" in result
assert "2026-03-31" in result
def test_smoke_web_has_mermaid() -> None:
result = compose_system_message(
client_type=ClientType.WEB,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Mermaid" in result
assert "KaTeX" in result
def test_smoke_cli_no_mermaid() -> None:
result = compose_system_message(
client_type=ClientType.CLI,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Mermaid" not in result or "Do not use" in result
def test_smoke_chat_no_tables() -> None:
result = compose_system_message(
client_type=ClientType.CHAT,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Do not use them" in result
# ---------------------------------------------------------------------------
# 2. Required field validation
# ---------------------------------------------------------------------------
def test_missing_current_datetime() -> None:
ctx = SessionContext(current_datetime="", timezone="PDT", username="alice")
with pytest.raises(ValueError, match="current_datetime"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
def test_missing_timezone() -> None:
ctx = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="",
username="alice",
)
with pytest.raises(ValueError, match="timezone"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
def test_missing_username() -> None:
ctx = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="PDT",
username="",
)
with pytest.raises(ValueError, match="username"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
# ---------------------------------------------------------------------------
# 3. Unknown client type rejection
# ---------------------------------------------------------------------------
def test_unknown_client_type() -> None:
with pytest.raises(ValueError, match="Unknown client_type"):
compose_system_message("tablet", _VALID_CTX, _NO_TOOLS) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# 4. Missing policy file
# ---------------------------------------------------------------------------
def test_missing_policy_file() -> None:
with pytest.raises(FileNotFoundError, match="nonexistent"):
compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
policies=["nonexistent"],
)
# ---------------------------------------------------------------------------
# 5. Module isolation — BASE must be environment-agnostic
# ---------------------------------------------------------------------------
def test_base_module_isolation() -> None:
from turnstone.prompts import _load
base = _load("base.md")
for forbidden in ("Mermaid", "KaTeX", "terminal", "monospace", "Slack", "Discord"):
assert forbidden not in base, f"BASE must not contain '{forbidden}'"
# ---------------------------------------------------------------------------
# 6. ENV mutual exclusion
# ---------------------------------------------------------------------------
def test_env_mutual_exclusion() -> None:
web = compose_system_message(ClientType.WEB, _VALID_CTX, _ALL_TOOLS)
# Web should have Mermaid.js but not "No diagram rendering" from CLI
assert "Mermaid" in web
assert "No diagram rendering" not in web
# ---------------------------------------------------------------------------
# 7. Policy tool gating — file-based (negative case)
# ---------------------------------------------------------------------------
def test_file_policy_gated_out() -> None:
"""web_search policy excluded when web_search tool is not available."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"read_file"}), # no web_search
policies=["web_search"],
)
assert "Web Search Policy" not in result
# ---------------------------------------------------------------------------
# 8. Policy tool gating — positive case
# ---------------------------------------------------------------------------
def test_file_policy_gated_in() -> None:
"""web_search policy included when web_search tool is available."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"web_search"}),
policies=["web_search"],
)
assert "Web Search Policy" in result
# ---------------------------------------------------------------------------
# 9. Unconditional policy not gated
# ---------------------------------------------------------------------------
def test_unconditional_policy() -> None:
"""A DB policy with no tool_gate is always included."""
db = [
{
"name": "custom_rule",
"content": "## Custom Rule\nAlways be polite.",
"tool_gate": "",
"priority": 0,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "Always be polite" in result
# ---------------------------------------------------------------------------
# 10. DB policy override
# ---------------------------------------------------------------------------
def test_db_policy_overrides_file() -> None:
"""DB policy with same name as file policy wins."""
db = [
{
"name": "web_search",
"content": "## DB Web Search Override\nCustom content.",
"tool_gate": "web_search",
"priority": 0,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
policies=["web_search"],
db_policies=db,
)
assert "DB Web Search Override" in result
assert "Use local tools" not in result # original file content
# ---------------------------------------------------------------------------
# 11. DB-only policy
# ---------------------------------------------------------------------------
def test_db_only_policy() -> None:
"""DB policy not in explicit list is still included."""
db = [
{
"name": "extra_rule",
"content": "## Extra\nDo not share secrets.",
"tool_gate": "",
"priority": 5,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "Do not share secrets" in result
# ---------------------------------------------------------------------------
# 12. Disabled DB policy
# ---------------------------------------------------------------------------
def test_disabled_db_policy() -> None:
"""DB policy with enabled=False is skipped."""
db = [
{
"name": "disabled_rule",
"content": "## Disabled\nThis should not appear.",
"tool_gate": "",
"priority": 0,
"enabled": False,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "This should not appear" not in result
# ---------------------------------------------------------------------------
# 13. ISO 8601 validation
# ---------------------------------------------------------------------------
def test_invalid_iso_datetime() -> None:
ctx = SessionContext(
current_datetime="not-a-date",
timezone="PDT",
username="alice",
)
with pytest.raises(ValueError, match="not valid ISO 8601"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
# ---------------------------------------------------------------------------
# 14. DB policy priority ordering
# ---------------------------------------------------------------------------
def test_db_policy_priority_ordering() -> None:
"""DB-only policies are assembled in priority order (ascending)."""
db = [
{
"name": "second",
"content": "SECOND_MARKER",
"tool_gate": "",
"priority": 10,
"enabled": True,
},
{
"name": "first",
"content": "FIRST_MARKER",
"tool_gate": "",
"priority": 1,
"enabled": True,
},
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
first_pos = result.index("FIRST_MARKER")
second_pos = result.index("SECOND_MARKER")
assert first_pos < second_pos
# ---------------------------------------------------------------------------
# 15. TOOLS module excluded when no tools available
# ---------------------------------------------------------------------------
def test_tools_excluded_when_no_tools() -> None:
"""TOOLS module is not included when available_tools is empty."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
)
assert "TOOL PATTERNS" not in result
def test_tools_included_when_tools_available() -> None:
"""TOOLS module is included when available_tools is non-empty."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
)
assert "TOOL PATTERNS" in result
+2 -2
View File
@@ -2303,8 +2303,8 @@ class TestAnthropicToolSearch:
# MCP tool should be deferred
assert result[1]["defer_loading"] is True
# Search tool should be appended
assert result[-1]["type"] == "tool_search_tool_bm25_20251119"
assert result[-1]["name"] == "tool_search"
assert result[-1]["type"] == "tool_search_tool_bm25"
assert result[-1]["name"] == "tool_search_tool_bm25"
def test_inject_tool_search_no_op_without_deferred(self, provider):
caps = provider.get_capabilities("claude-opus-4-6-20260101")
+30 -11
View File
@@ -580,6 +580,24 @@ class TestSessionConfig:
# ---------------------------------------------------------------------------
_TEST_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _server_jwt() -> str:
from turnstone.core.auth import JWT_AUD_SERVER, create_jwt
return create_jwt(
user_id="test-server-live",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_TEST_JWT_SECRET,
audience=JWT_AUD_SERVER,
)
_SERVER_AUTH_HEADERS = {"Authorization": f"Bearer {_server_jwt()}"}
class TestServerHealthMetrics:
"""Verify /health and /metrics endpoints using a Starlette TestClient.
@@ -596,7 +614,6 @@ class TestServerHealthMetrics:
from starlette.testclient import TestClient
import turnstone.server as srv_mod
from turnstone.core.auth import AuthConfig
from turnstone.core.metrics import MetricsCollector
from turnstone.core.workstream import WorkstreamState
@@ -631,7 +648,7 @@ class TestServerHealthMetrics:
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
)
cls.client = TestClient(app, raise_server_exceptions=False)
@@ -727,8 +744,8 @@ class TestServerHealthMetrics:
assert 'le="+Inf"' in body
def test_unknown_endpoint_returns_404(self):
status, _, _ = self._get("/does-not-exist")
assert status == 404
resp = self.client.get("/does-not-exist", headers=_SERVER_AUTH_HEADERS)
assert resp.status_code == 404
def test_health_contains_backend_field(self):
_, _, body = self._get("/health")
@@ -772,7 +789,6 @@ class TestServerRateLimiting:
from starlette.testclient import TestClient
import turnstone.server as srv_mod
from turnstone.core.auth import AuthConfig
from turnstone.core.metrics import MetricsCollector
from turnstone.core.ratelimit import RateLimiter
from turnstone.core.workstream import WorkstreamState
@@ -808,7 +824,7 @@ class TestServerRateLimiting:
global_listeners=[],
global_listeners_lock=threading.Lock(),
skip_permissions=False,
auth_config=AuthConfig(),
jwt_secret=_TEST_JWT_SECRET,
rate_limiter=RateLimiter(enabled=True, rate=2.0, burst=3),
)
cls.client = TestClient(app, raise_server_exceptions=False)
@@ -830,16 +846,19 @@ class TestServerRateLimiting:
"""After exhausting burst on a non-exempt endpoint, get 429."""
# Exhaust burst on a non-exempt endpoint
for _ in range(5):
self._get("/v1/api/workstreams")
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
# At least one should be 429
statuses = [self._get("/v1/api/workstreams").status_code for _ in range(3)]
statuses = [
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS).status_code
for _ in range(3)
]
assert 429 in statuses
def test_429_includes_retry_after(self):
"""429 response includes Retry-After header."""
# Burn through burst
for _ in range(10):
resp = self._get("/v1/api/workstreams")
resp = self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
if resp.status_code == 429:
assert "retry-after" in resp.headers
data = resp.json()
@@ -851,7 +870,7 @@ class TestServerRateLimiting:
"""Health endpoint is always accessible regardless of rate limit."""
# Burn through bucket on non-exempt path
for _ in range(10):
self._get("/v1/api/workstreams")
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
# Health should still work
resp = self._get("/health")
assert resp.status_code == 200
@@ -859,6 +878,6 @@ class TestServerRateLimiting:
def test_metrics_exempt_from_ratelimit(self):
"""Metrics endpoint is always accessible regardless of rate limit."""
for _ in range(10):
self._get("/v1/api/workstreams")
self.client.get("/v1/api/workstreams", headers=_SERVER_AUTH_HEADERS)
resp = self._get("/metrics")
assert resp.status_code == 200
@@ -0,0 +1,479 @@
"""Tests for skill resource materialization to disk.
Verifies that skill-bundled resources (scripts, references, assets) stored
in the ``skill_resources`` table are written to a temp directory when a
skill is loaded, exposed via ``SKILL_RESOURCES_DIR`` env var and ``PATH``,
and cleaned up on skill change or session close.
"""
from __future__ import annotations
import os
import stat
from typing import Any
from unittest.mock import MagicMock
from turnstone.core.session import ChatSession
from turnstone.core.storage._registry import get_storage
# ---------------------------------------------------------------------------
# Helpers (mirrors test_skills.py)
# ---------------------------------------------------------------------------
class NullUI:
"""UI adapter that discards all output."""
def on_thinking_start(self):
pass
def on_thinking_stop(self):
pass
def on_reasoning_token(self, text):
pass
def on_content_token(self, text):
pass
def on_stream_end(self):
pass
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
pass
def on_status(self, usage, context_window, effort):
pass
def on_plan_review(self, content):
return ""
def on_info(self, message):
pass
def on_error(self, message):
pass
def on_state_change(self, state):
pass
def on_rename(self, name):
pass
def on_output_warning(self, call_id, assessment):
pass
def _make_session(**kwargs: Any) -> ChatSession:
defaults: dict[str, Any] = dict(
client=MagicMock(),
model="test-model",
ui=NullUI(),
instructions=None,
temperature=0.5,
max_tokens=4096,
tool_timeout=30,
)
defaults.update(kwargs)
return ChatSession(**defaults)
def _create_skill(db: Any, skill_id: str, name: str, content: str, **kw: Any) -> None:
db.create_prompt_template(
template_id=skill_id,
name=name,
category=kw.get("category", "general"),
content=content,
variables=kw.get("variables", "[]"),
is_default=kw.get("is_default", False),
org_id="",
created_by="test",
origin="manual",
mcp_server="",
readonly=False,
description="",
tags="[]",
source_url="",
version="1.0.0",
author="",
activation=kw.get("activation", "named"),
token_estimate=0,
model="",
auto_approve=False,
temperature=None,
reasoning_effort="",
max_tokens=None,
token_budget=0,
agent_max_turns=None,
notify_on_complete="{}",
enabled=True,
allowed_tools="[]",
priority=0,
)
def _sys_content(session: ChatSession) -> str:
msgs = [m for m in session.system_messages if m["role"] == "system"]
assert msgs
return msgs[0]["content"]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestMaterializeResources:
def test_materialize_creates_files(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "test-skill", "Use the scripts.")
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hello')")
db.create_skill_resource("r2", "s1", "references/api.md", "# API")
session = _make_session(skill="test-skill")
assert session._skill_resources_dir is not None
base = session._skill_resources_dir
assert os.path.isdir(base)
helper = os.path.join(base, "scripts", "helper.py")
assert os.path.isfile(helper)
with open(helper) as f:
assert f.read() == "print('hello')"
api_md = os.path.join(base, "references", "api.md")
assert os.path.isfile(api_md)
with open(api_md) as f:
assert f.read() == "# API"
session.close()
def test_scripts_executable(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "exec-skill", "Run scripts/run.sh")
db.create_skill_resource("r1", "s1", "scripts/run.sh", "#!/bin/bash\necho hi")
session = _make_session(skill="exec-skill")
base = session._skill_resources_dir
run_sh = os.path.join(base, "scripts", "run.sh")
mode = os.stat(run_sh).st_mode
assert mode & stat.S_IXUSR # owner execute
session.close()
def test_non_scripts_not_executable(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "ref-skill", "Read references/guide.md")
db.create_skill_resource("r1", "s1", "references/guide.md", "# Guide")
session = _make_session(skill="ref-skill")
base = session._skill_resources_dir
guide = os.path.join(base, "references", "guide.md")
mode = os.stat(guide).st_mode
assert not (mode & stat.S_IXUSR) # not executable
session.close()
def test_cleanup_on_close(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "cleanup-skill", "content")
db.create_skill_resource("r1", "s1", "scripts/a.py", "code")
session = _make_session(skill="cleanup-skill")
base = session._skill_resources_dir
assert os.path.isdir(base)
session.close()
assert not os.path.exists(base)
assert session._skill_resources_dir is None
def test_cleanup_on_skill_switch(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "skill-a", "Skill A")
db.create_skill_resource("r1", "s1", "scripts/a.py", "code_a")
_create_skill(db, "s2", "skill-b", "Skill B")
db.create_skill_resource("r2", "s2", "scripts/b.py", "code_b")
session = _make_session(skill="skill-a")
dir_a = session._skill_resources_dir
assert os.path.isfile(os.path.join(dir_a, "scripts", "a.py"))
session.set_skill("skill-b")
dir_b = session._skill_resources_dir
assert dir_b != dir_a
assert not os.path.exists(dir_a)
assert os.path.isfile(os.path.join(dir_b, "scripts", "b.py"))
session.close()
def test_cleanup_on_skill_clear(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "clear-skill", "content")
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
session = _make_session(skill="clear-skill")
base = session._skill_resources_dir
assert os.path.isdir(base)
session.set_skill(None)
assert not os.path.exists(base)
assert session._skill_resources_dir is None
session.close()
def test_empty_resources_no_dir(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "no-res-skill", "content")
# No resources added
session = _make_session(skill="no-res-skill")
assert session._skill_resources_dir is None
session.close()
def test_no_skill_no_dir(self, tmp_db):
session = _make_session()
assert session._skill_resources_dir is None
session.close()
def test_path_traversal_rejected(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "traversal-skill", "content")
# Inject a malicious path directly into storage
db.create_skill_resource("r1", "s1", "../etc/passwd", "bad content")
db.create_skill_resource("r2", "s1", "scripts/good.py", "good content")
session = _make_session(skill="traversal-skill")
base = session._skill_resources_dir
# The traversal path must not be written inside the resources dir
assert not os.path.exists(os.path.join(base, "etc"))
# The good resource should still be materialized
assert os.path.isfile(os.path.join(base, "scripts", "good.py"))
session.close()
class TestSkillResourceEnv:
def test_env_with_resources(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "env-skill", "content")
db.create_skill_resource("r1", "s1", "scripts/tool.py", "code")
session = _make_session(skill="env-skill")
env = session._skill_resource_env()
assert env["SKILL_RESOURCES_DIR"] == session._skill_resources_dir
assert "PATH" in env
scripts_dir = os.path.join(session._skill_resources_dir, "scripts")
assert env["PATH"].startswith(scripts_dir + ":")
session.close()
def test_env_without_scripts_dir(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "no-scripts-skill", "content")
db.create_skill_resource("r1", "s1", "references/doc.md", "# Doc")
session = _make_session(skill="no-scripts-skill")
env = session._skill_resource_env()
assert "SKILL_RESOURCES_DIR" in env
# No scripts/ subdir so PATH should not be overridden
assert "PATH" not in env
session.close()
def test_env_empty_when_no_resources(self, tmp_db):
session = _make_session()
assert session._skill_resource_env() == {}
session.close()
class TestSystemMessageHint:
def test_hint_present_when_resources_exist(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "hint-skill", "Use the bundled scripts.")
db.create_skill_resource("r1", "s1", "scripts/run.py", "code")
session = _make_session(skill="hint-skill")
content = _sys_content(session)
assert "$SKILL_RESOURCES_DIR" in content
assert "scripts/ are on PATH" in content
session.close()
def test_no_hint_when_no_resources(self, tmp_db):
db = get_storage()
_create_skill(db, "s1", "plain-skill", "No resources here.")
session = _make_session(skill="plain-skill")
content = _sys_content(session)
assert "SKILL_RESOURCES_DIR" not in content
session.close()
class TestMaterializeEdgeCases:
def test_all_resources_rejected_no_dir(self, tmp_db):
"""When every resource fails path validation, no temp dir is left."""
db = get_storage()
_create_skill(db, "s1", "all-bad", "content")
db.create_skill_resource("r1", "s1", "../escape", "bad")
db.create_skill_resource("r2", "s1", "/absolute", "bad")
session = _make_session(skill="all-bad")
assert session._skill_resources_dir is None
session.close()
def test_dot_path_rejected(self, tmp_db):
"""A bare '.' path is rejected rather than crashing."""
db = get_storage()
_create_skill(db, "s1", "dot-skill", "content")
db.create_skill_resource("r1", "s1", ".", "bad")
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
session = _make_session(skill="dot-skill")
base = session._skill_resources_dir
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
session.close()
def test_empty_path_rejected(self, tmp_db):
"""An empty string path is rejected."""
db = get_storage()
_create_skill(db, "s1", "empty-skill", "content")
db.create_skill_resource("r1", "s1", "", "bad")
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
session = _make_session(skill="empty-skill")
assert session._skill_resources_dir is not None
session.close()
def test_nested_traversal_rejected(self, tmp_db):
"""Traversal hidden inside a valid prefix is still caught."""
db = get_storage()
_create_skill(db, "s1", "nested-skill", "content")
db.create_skill_resource("r1", "s1", "scripts/../../../etc/passwd", "bad")
db.create_skill_resource("r2", "s1", "scripts/ok.py", "good")
session = _make_session(skill="nested-skill")
base = session._skill_resources_dir
assert not os.path.exists(os.path.join(base, "etc"))
assert os.path.isfile(os.path.join(base, "scripts", "ok.py"))
session.close()
def test_double_close_idempotent(self, tmp_db):
"""Calling close() twice does not raise."""
db = get_storage()
_create_skill(db, "s1", "double-skill", "content")
db.create_skill_resource("r1", "s1", "scripts/x.py", "code")
session = _make_session(skill="double-skill")
session.close()
session.close() # must not raise
class TestPreflightValidation:
def test_missing_resource_warns(self, tmp_db):
"""Skill content references a script not in resources."""
db = get_storage()
_create_skill(db, "s1", "warn-skill", "Run scripts/missing.py to start.")
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="warn-skill")
ui.on_info.assert_called_once()
msg = ui.on_info.call_args[0][0]
assert "scripts/missing.py" in msg
assert "warn-skill" in msg
session.close()
def test_all_resources_present_no_warn(self, tmp_db):
"""No warning when all referenced paths are bundled."""
db = get_storage()
_create_skill(db, "s1", "ok-skill", "Run scripts/helper.py for help.")
db.create_skill_resource("r1", "s1", "scripts/helper.py", "print('hi')")
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="ok-skill")
ui.on_info.assert_not_called()
session.close()
def test_no_references_no_warn(self, tmp_db):
"""Skill content with no resource paths triggers no validation warning."""
db = get_storage()
_create_skill(db, "s1", "plain-skill", "Just a plain skill with no paths.")
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="plain-skill")
ui.on_info.assert_not_called()
session.close()
def test_multiple_missing_warns_once(self, tmp_db):
"""Multiple missing resources produce a single warning listing all."""
db = get_storage()
_create_skill(
db,
"s1",
"multi-skill",
"Use scripts/a.py and scripts/b.sh to process references/guide.md",
)
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="multi-skill")
ui.on_info.assert_called_once()
msg = ui.on_info.call_args[0][0]
assert "3 resource(s)" in msg
assert "scripts/a.py" in msg
assert "scripts/b.sh" in msg
assert "references/guide.md" in msg
session.close()
def test_validation_skipped_no_skill(self, tmp_db):
"""No crash or warning when no skill is active."""
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui)
ui.on_info.assert_not_called()
session.close()
def test_json_extension_not_truncated(self, tmp_db):
"""assets/config.json should match as .json, not .js."""
db = get_storage()
_create_skill(db, "s1", "json-skill", "Load assets/config.json for settings.")
db.create_skill_resource("r1", "s1", "assets/config.json", "{}")
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="json-skill")
ui.on_info.assert_not_called()
session.close()
def test_compound_prefix_not_matched(self, tmp_db):
"""'myscripts/tool.py' should not match as 'scripts/tool.py'."""
db = get_storage()
_create_skill(
db,
"s1",
"compound-skill",
"The myscripts/tool.py file is unrelated.",
)
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="compound-skill")
ui.on_info.assert_not_called()
session.close()
def test_extension_suffix_not_matched(self, tmp_db):
"""'scripts/tool.python' should not match as 'scripts/tool.py'."""
db = get_storage()
_create_skill(
db,
"s1",
"suffix-skill",
"Run scripts/tool.python to start.",
)
ui = NullUI()
ui.on_info = MagicMock()
session = _make_session(ui=ui, skill="suffix-skill")
ui.on_info.assert_not_called()
session.close()
+109 -2
View File
@@ -55,8 +55,8 @@ def _make_app(tls_manager):
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="",
scopes=frozenset({"approve"}),
token_source="config",
scopes=frozenset({"approve", "service"}),
token_source="test",
)
return await call_next(request)
@@ -124,6 +124,113 @@ def test_delete_cert_not_found(tls_manager):
assert resp.status_code == 404
# ── Auth enforcement ──────────────────────────────────────────────────────────
def _make_app_no_auth(tls_manager):
"""Create app without auth middleware — simulates unauthenticated requests."""
from starlette.applications import Starlette
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
)
app.state.tls_manager = tls_manager
return app
def _make_app_read_only(tls_manager):
"""Create app with read-only auth — should be rejected by admin endpoints."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
from turnstone.core.auth import AuthResult
async def _grant_read(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="viewer",
scopes=frozenset({"read"}),
token_source="jwt",
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_read)],
)
app.state.tls_manager = tls_manager
return app
def test_unauthenticated_list_certs_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.get("/certs")
assert resp.status_code == 401
def test_unauthenticated_renew_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 401
def test_unauthenticated_delete_401(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_no_auth(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 401
def test_read_only_renew_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 403
def test_read_only_delete_403(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app_read_only(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 403
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
+2 -2
View File
@@ -84,5 +84,5 @@ def test_collector_tls_defaults():
storage_mock = MagicMock()
collector = ClusterCollector(storage=storage_mock)
# Should create httpx client without errors
assert collector._http_client is not None
# Should store TLS settings for async client creation
assert collector._tls_verify is True
+2 -2
View File
@@ -145,7 +145,7 @@ async def test_tls_ca_cert_endpoint(tls_manager):
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config"
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
)
return await call_next(request)
@@ -190,7 +190,7 @@ async def test_tls_endpoints_disabled():
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config"
user_id="", scopes=frozenset({"approve", "service"}), token_source="test"
)
return await call_next(request)
+54 -10
View File
@@ -713,6 +713,41 @@ class TestWebUI:
assert ui._plan_result == "approved"
t.join()
def test_pending_plan_review_stored_and_replayed(self):
"""Plan review state is stored for SSE reconnection replay."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
assert ui._pending_plan_review is None
# Simulate on_plan_review in a background thread (it blocks)
def review():
ui.on_plan_review("Here is the plan")
t = threading.Thread(target=review)
t.start()
time.sleep(0.1)
# While blocking, pending state should be set
assert ui._pending_plan_review is not None
assert ui._pending_plan_review["type"] == "plan_review"
assert ui._pending_plan_review["content"] == "Here is the plan"
# Resolve — pending state should be cleared
ui.resolve_plan("looks good")
t.join(timeout=2)
assert ui._pending_plan_review is None
assert ui._plan_result == "looks good"
def test_pending_plan_review_cleared_on_resolve_before_wait_returns(self):
"""resolve_plan clears pending state immediately, not just after wait."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
ui._pending_plan_review = {"type": "plan_review", "content": "test"}
ui.resolve_plan("ok")
assert ui._pending_plan_review is None
# ---------------------------------------------------------------------------
# WebUI SSE fan-out
@@ -730,13 +765,23 @@ class TestWebUIFanOut:
ui._enqueue({"type": "content", "text": "hello"}) # should not raise
def test_enqueue_single_listener(self):
"""Single listener receives the event."""
"""Single listener receives the event with ws_id stamped."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
q = ui._register_listener()
ui._enqueue({"type": "content", "text": "hello"})
assert q.get_nowait() == {"type": "content", "text": "hello"}
assert q.get_nowait() == {"type": "content", "text": "hello", "ws_id": "test"}
def test_enqueue_does_not_mutate_input(self):
"""_enqueue must not mutate the caller's dict."""
from turnstone.server import WebUI
ui = WebUI(ws_id="test")
ui._register_listener()
original = {"type": "content", "text": "hello"}
ui._enqueue(original)
assert "ws_id" not in original
def test_enqueue_multiple_listeners(self):
"""All registered listeners receive an identical copy."""
@@ -747,12 +792,12 @@ class TestWebUIFanOut:
q2 = ui._register_listener()
q3 = ui._register_listener()
event = {"type": "content", "text": "world"}
ui._enqueue(event)
ui._enqueue({"type": "content", "text": "world"})
assert q1.get_nowait() == event
assert q2.get_nowait() == event
assert q3.get_nowait() == event
expected = {"type": "content", "text": "world", "ws_id": "test"}
assert q1.get_nowait() == expected
assert q2.get_nowait() == expected
assert q3.get_nowait() == expected
def test_unregister_stops_delivery(self):
"""After unregister, the queue receives no further events."""
@@ -784,11 +829,10 @@ class TestWebUIFanOut:
assert fast.qsize() == 0
# Enqueue via fan-out — slow drops (full), fast receives
event = {"type": "content", "text": "overflow"}
ui._enqueue(event)
ui._enqueue({"type": "content", "text": "overflow"})
assert slow.qsize() == 500 # still full, overflow dropped
assert fast.qsize() == 1
assert fast.get_nowait() == event
assert fast.get_nowait() == {"type": "content", "text": "overflow", "ws_id": "test"}
def test_unregister_idempotent(self):
"""Double unregister does not raise."""
+1 -3
View File
@@ -56,11 +56,9 @@
# --- Auth (node, console) ---
[auth]
# enabled = true # env: TURNSTONE_AUTH_ENABLED
# Auth is always enabled. JWT secret is required.
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
# env: TURNSTONE_JWT_SECRET
# token = "" # Static config token for full access
# env: TURNSTONE_AUTH_TOKEN
# --- Logging (turnstone, node, console) ---
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.9.7"
__version__ = "1.0.0"
+13 -18
View File
@@ -265,9 +265,19 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
url = f"{console_url}/v1/api/admin/tls/certs"
headers = {}
token = getattr(args, "auth_token", "") or _get_config_token()
if token:
headers["Authorization"] = f"Bearer {token}"
# Prefer JWT via ServiceTokenManager when JWT secret is available
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
mgr = ServiceTokenManager(
user_id="admin-cli",
scopes=frozenset({"read", "write", "approve", "service"}),
source="cli",
secret=jwt_secret,
audience=JWT_AUD_CONSOLE,
)
headers["Authorization"] = f"Bearer {mgr.token}"
resp = httpx.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
@@ -283,20 +293,6 @@ def _cmd_tls_list(args: argparse.Namespace) -> None:
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
def _get_config_token() -> str:
"""Try to load auth token from config.toml or environment."""
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
if token:
return token
try:
from turnstone.core.config import load_config
cfg = load_config("auth")
return str(cfg.get("token", ""))
except Exception:
return ""
def _discover_console_url() -> str:
"""Discover console URL from the services table."""
from turnstone.core.storage import get_storage
@@ -381,7 +377,6 @@ def main() -> None:
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
p_tlslist.add_argument("--console-url", default="", help="Console URL")
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
args = parser.parse_args()
if not args.command:
+35
View File
@@ -942,6 +942,41 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
],
tags=["Admin"],
),
# --- Admin: Prompt Policies ---
EndpointSpec(
"/v1/api/admin/prompt-policies",
"GET",
"List all prompt policies for system message composition",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/prompt-policies",
"POST",
"Create a prompt policy",
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/prompt-policies/{policy_id}",
"GET",
"Get a single prompt policy",
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/prompt-policies/{policy_id}",
"PUT",
"Update a prompt policy",
error_codes=[400, 404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/prompt-policies/{policy_id}",
"DELETE",
"Delete a prompt policy",
error_codes=[404],
tags=["Admin"],
),
# --- Admin: TLS / ACME ---
EndpointSpec(
"/v1/api/admin/tls/ca",
+4
View File
@@ -57,6 +57,10 @@ class CreateWorkstreamRequest(BaseModel):
description="Workstream ID to resume atomically during creation (empty = fresh start)",
)
skill: str = Field(default="", description="Skill name (replaces default skills)")
client_type: str = Field(
default="",
description="Client surface type (web, cli, chat). Defaults to web for server-created sessions.",
)
class CreateWorkstreamResponse(BaseModel):
+5 -2
View File
@@ -137,8 +137,11 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
"/v1/api/events/global",
"GET",
"Global SSE event stream",
description="Global Server-Sent Events stream for state-change broadcasts "
"across all workstreams. Returns text/event-stream.",
description="Server-Sent Events stream for node-level state broadcasts. "
"Emits a node_snapshot event on connect (workstreams, health, aggregate), "
"followed by real-time delta events (ws_state, ws_activity, ws_created, "
"ws_closed, ws_rename, health_changed, aggregate). "
"Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
tags=["Streaming"],
),
# --- Saved workstreams ---
+43 -14
View File
@@ -82,10 +82,9 @@ For commercial providers (OpenAI, Anthropic-via-proxy), use the real key.
- `POSTGRES_USER` PostgreSQL username (default: turnstone)
- `POSTGRES_PASSWORD` PostgreSQL password (required for production/cluster)
### Authentication
- `TURNSTONE_AUTH_ENABLED` Enable auth (`true`/empty)
- `TURNSTONE_JWT_SECRET` JWT signing secret (required if auth enabled)
- `TURNSTONE_AUTH_TOKEN` Static bearer token for inter-service auth
### Authentication (always enabled)
- `TURNSTONE_JWT_SECRET` JWT signing secret (required). All services must share the same secret. \
Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
### OIDC SSO (optional)
- `TURNSTONE_OIDC_ISSUER` OIDC issuer URL (e.g., https://accounts.google.com). Setting this + CLIENT_ID + CLIENT_SECRET enables SSO.
@@ -162,8 +161,9 @@ Walk the user through setting up their deployment step by step:
(may differ from this wizard's model). Ask for base URL, API key, model name.
4. **Database**: SQLite (dev/simple) vs PostgreSQL (production/cluster). \
PostgreSQL is required for cluster mode.
5. **Security**: Recommend enabling auth for any non-local deployment. \
Use `generate_secret` for JWT secret, auth token, and Postgres password. \
5. **Security**: Auth is always enabled and requires `TURNSTONE_JWT_SECRET`. \
Use `generate_secret` for JWT secret and Postgres password. \
Always set `TURNSTONE_JWT_SECRET` in the .env. \
Ask for initial admin username and password. \
If the user's deployment will use an external identity provider (Okta, Azure AD, Google, etc.), \
offer to configure OIDC SSO. Ask for the issuer URL, client ID, and client secret. \
@@ -192,7 +192,7 @@ Note: the MCP SDK's DNS rebinding protection must be disabled for Docker-interna
(the compose.yaml handles this), and the server must bind to 0.0.0.0 (not 127.0.0.1) to be \
reachable from other containers.
- The `DATABASE_URL` for docker compose internal networking uses the hostname `postgres` \
(e.g., `postgresql://turnstone:<password>@postgres:5432/turnstone`).
(e.g., `postgresql+psycopg://turnstone:<password>@postgres:5432/turnstone`).
- For local LLM backends (vLLM, llama.cpp, Ollama, etc.), set `OPENAI_API_KEY=dummy` in the \
.env file local servers typically don't require authentication. The `LLM_BASE_URL` should \
use `host.docker.internal` to reach the host machine from inside Docker \
@@ -643,22 +643,42 @@ class _BootstrapLLM:
messages=messages,
tools=tools if tools else None,
)
choice = resp.choices[0]
content = choice.message.content or ""
# Guard against non-spec responses from proxies (Open WebUI, LiteLLM, etc.)
if resp is None:
raise RuntimeError(
"Server returned null — your OpenAI-compatible endpoint may not "
"support tool calling. Try a direct connection to the model server."
)
choices = getattr(resp, "choices", None)
if not choices:
raise RuntimeError(
"Server returned an empty choices array. "
"The model may have hit its context limit, or the proxy "
"dropped the response."
)
choice = choices[0]
message = getattr(choice, "message", None)
if message is None:
raise RuntimeError(
"Server returned a choice with no message. "
"Your OpenAI-compatible endpoint may not fully implement "
"the chat completions API."
)
content = message.content or ""
tool_calls = None
if choice.message.tool_calls:
if getattr(message, "tool_calls", None):
tool_calls = [
{
"id": tc.id,
"id": getattr(tc, "id", None) or f"call_{secrets.token_hex(4)}",
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in choice.message.tool_calls
for i, tc in enumerate(message.tool_calls)
]
return content, tool_calls, choice.finish_reason or "stop"
return content, tool_calls, getattr(choice, "finish_reason", None) or "stop"
# -- Anthropic path -----------------------------------------------------
@@ -1002,7 +1022,16 @@ def _run_conversation(
retries += 1
if retries >= _max_retries:
print(f"\n{RED}LLM error after {_max_retries} attempts: {exc}{RESET}")
print("Please check your connection and try again.")
print()
print("Troubleshooting:")
print(
f" {DIM}• If using a proxy (Open WebUI, LiteLLM), try connecting directly{RESET}"
)
print(f" {DIM}• Verify the endpoint supports tool/function calling{RESET}")
print(f" {DIM}• Check that the model context window isn't exceeded{RESET}")
print(
f" {DIM}• Try a different model — not all models handle tool calls reliably{RESET}"
)
return
print(f"\n{RED}LLM error: {exc}{RESET}")
print(f"{DIM}Retrying ({retries}/{_max_retries})...{RESET}")
+20
View File
@@ -139,6 +139,26 @@ def format_plan_review(content: str) -> str:
return f"**Plan review requested:**\n\n{content}"
def format_tool_result(output: str) -> str:
"""Format a tool result into a compact code-block summary.
Truncates to the first 10 lines (plus an ellipsis line if trimmed) or
500 characters, whichever is shorter.
"""
# Truncate to 10 lines.
lines = output.split("\n", 10)
if len(lines) > 10:
lines = lines[:10]
lines.append("\u2026")
trimmed = "\n".join(lines)
# Escape triple backticks to prevent code-block breakout.
trimmed = trimmed.replace("```", "` ` `")
# Truncate to 500 chars (after escaping, which can expand the string).
if len(trimmed) > 500:
trimmed = trimmed[:497] + "\u2026"
return f"```\n{trimmed}\n```"
def truncate(text: str, max_length: int = 200) -> str:
"""Truncate *text* to *max_length*, appending an ellipsis if trimmed."""
if len(text) <= max_length:
+3 -13
View File
@@ -37,10 +37,9 @@ async def _handle_health(request: Request) -> JSONResponse:
def _check_auth(request: Request) -> JSONResponse | None:
"""Validate the request's Authorization header. Returns an error response or None."""
auth_token: str = getattr(request.app.state, "auth_token", "")
jwt_secret: str = getattr(request.app.state, "jwt_secret", "")
if not auth_token and not jwt_secret:
if not jwt_secret:
log.warning("notify.auth_not_configured")
return JSONResponse({"error": "authentication not configured"}, status_code=401)
@@ -50,15 +49,8 @@ def _check_auth(request: Request) -> JSONResponse | None:
token = header[7:]
# Static token check
if auth_token:
import hmac
if hmac.compare_digest(token, auth_token):
return None
# JWT check
if jwt_secret and "." in token:
# JWT validation
if "." in token:
from turnstone.core.auth import JWT_AUD_CHANNEL, validate_jwt
result = validate_jwt(token, jwt_secret, audience=JWT_AUD_CHANNEL)
@@ -178,7 +170,6 @@ def create_channel_app(
adapters: dict[str, ChannelAdapter],
storage: StorageBackend,
*,
auth_token: str = "",
jwt_secret: str = "",
) -> Starlette:
"""Create the channel gateway HTTP application."""
@@ -195,7 +186,6 @@ def create_channel_app(
)
app.state.adapters = adapters
app.state.storage = storage
app.state.auth_token = auth_token
app.state.jwt_secret = jwt_secret
return app
+3
View File
@@ -120,6 +120,7 @@ class ChannelRouter:
name: str = "",
model: str = "",
initial_message: str = "",
client_type: str = "",
) -> tuple[str, bool]:
"""Look up or create a workstream for a channel.
@@ -176,6 +177,7 @@ class ChannelRouter:
skill=self._skill,
auto_approve=self._auto_approve,
auto_approve_tools=_tools_csv,
client_type=client_type,
)
ws_id = data.get("ws_id", "")
else:
@@ -187,6 +189,7 @@ class ChannelRouter:
skill=self._skill,
auto_approve=self._auto_approve,
auto_approve_tools=_tools_csv,
client_type=client_type,
)
ws_id = resp.ws_id
data = {"ws_id": resp.ws_id, "name": resp.name}
+1 -12
View File
@@ -72,13 +72,6 @@ def main() -> None:
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
# -- Auth ----------------------------------------------------------------
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_CHANNEL_AUTH_TOKEN", ""),
help="Static auth token for /v1/api/notify (default: $TURNSTONE_CHANNEL_AUTH_TOKEN)",
)
# -- Workstream defaults -------------------------------------------------
parser.add_argument(
"--model",
@@ -121,7 +114,6 @@ def main() -> None:
)
# -- Auth config ---------------------------------------------------------
auth_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "") or args.auth_token
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
# Prefer auto-rotating service JWTs when jwt_secret is available.
@@ -132,7 +124,7 @@ def main() -> None:
if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, JWT_AUD_SERVER, ServiceTokenManager
_scopes = frozenset({"read", "write", "approve"})
_scopes = frozenset({"read", "write", "approve", "service"})
_console_mgr = ServiceTokenManager(
user_id="channel-gateway",
scopes=_scopes,
@@ -151,7 +143,6 @@ def main() -> None:
)
_console_token_factory = lambda: _console_mgr.token # noqa: E731
_server_token_factory = lambda: _server_mgr.token # noqa: E731
auth_token = "" # don't also pass static token
server_url: str = args.server_url
console_url: str = args.console_url
@@ -242,7 +233,6 @@ def main() -> None:
config,
server_url,
storage,
api_token=auth_token,
console_url=console_url,
console_token_factory=_console_token_factory,
server_token_factory=_server_token_factory,
@@ -253,7 +243,6 @@ def main() -> None:
channel_app = create_channel_app(
adapters, # type: ignore[arg-type]
storage,
auth_token=auth_token,
jwt_secret=jwt_secret,
)
+243 -18
View File
@@ -24,6 +24,7 @@ from turnstone.channels._formatter import chunk_message
from turnstone.channels._routing import ChannelRouter
from turnstone.core.log import get_logger
from turnstone.sdk.events import (
ApprovalResolvedEvent,
ApproveRequestEvent,
ContentEvent,
ErrorEvent,
@@ -31,6 +32,10 @@ from turnstone.sdk.events import (
PlanReviewEvent,
ServerEvent,
StreamEndEvent,
ThinkingStartEvent,
ThinkingStopEvent,
ToolInfoEvent,
ToolResultEvent,
)
if TYPE_CHECKING:
@@ -175,9 +180,16 @@ class TurnstoneBot:
server_token_factory=server_token_factory,
)
self._commands_synced: bool = False
self._subscribed_ws: set[str] = set()
self._sse_tasks: dict[str, asyncio.Task[None]] = {}
self._streaming: dict[str, StreamingMessage] = {}
# Transient "Thinking..." status messages, deleted when content starts.
self._thinking_msgs: dict[str, discord.Message] = {}
# Per-tool "running" embeds, edited in-place when the result arrives.
# List preserves call order for FIFO matching when the same tool name
# appears more than once in a single turn.
self._tool_info_msgs: dict[str, list[tuple[str, str, str, discord.Message]]] = {}
# Track the Discord message containing the pending approval embed per
# workstream so that IntentVerdictEvent can update it with LLM judge
# results.
@@ -193,12 +205,17 @@ class TurnstoneBot:
# response message can be re-tracked for multi-turn DM conversations.
self._notify_reply_channels: dict[str, tuple[discord.abc.Messageable, str]] = {}
# Shared HTTP client for SSE connections (long-lived, no timeout).
# Shared HTTP client for SSE connections.
# Read timeout detects half-open connections (server sends ping=5s
# keepalives, so 90s is very conservative).
# Token factory provides auto-rotating JWTs; static token is fallback.
headers: dict[str, str] = {}
if api_token and not server_token_factory:
headers["Authorization"] = f"Bearer {api_token}"
self._http_client = httpx.AsyncClient(headers=headers, timeout=None)
self._http_client = httpx.AsyncClient(
headers=headers,
timeout=httpx.Timeout(connect=10.0, read=90.0, write=10.0, pool=10.0),
)
intents = discord.Intents.default()
intents.message_content = True
@@ -219,6 +236,10 @@ class TurnstoneBot:
async def on_ready() -> None:
await self._on_ready()
@self._bot.event
async def on_resumed() -> None:
await self._on_resumed()
# -- lifecycle -----------------------------------------------------------
async def _setup_hook(self) -> None:
@@ -236,23 +257,57 @@ class TurnstoneBot:
log.info("discord.setup_hook_complete")
async def _on_ready(self) -> None:
"""Sync slash commands and recover existing routes."""
"""Sync slash commands (once) and recover existing routes."""
import discord
bot = self._bot
log.info("discord.ready", user=str(bot.user), guild_count=len(bot.guilds))
if self.config.guild_id:
guild = discord.Object(id=self.config.guild_id)
bot.tree.copy_global_to(guild=guild)
await bot.tree.sync(guild=guild)
log.info("discord.commands_synced", guild_id=self.config.guild_id)
else:
await bot.tree.sync()
log.info("discord.commands_synced_global")
if not self._commands_synced:
if self.config.guild_id:
guild = discord.Object(id=self.config.guild_id)
bot.tree.copy_global_to(guild=guild)
await bot.tree.sync(guild=guild)
log.info("discord.commands_synced", guild_id=self.config.guild_id)
else:
await bot.tree.sync()
log.info("discord.commands_synced_global")
self._commands_synced = True
self._purge_dead_sse_tasks("ready")
await self._recover_routes()
async def _on_resumed(self) -> None:
"""Recover dead SSE tasks after a gateway session resume.
Unlike ``on_ready``, ``on_resumed`` fires when discord.py resumes
an existing session after a brief disconnect ``on_ready`` is NOT
called in that case. Any SSE listener tasks that died during the
blip need to be cleaned up and re-subscribed.
"""
self._purge_dead_sse_tasks("resumed")
await self._recover_routes()
def _purge_dead_sse_tasks(self, trigger: str) -> None:
"""Remove completed/failed SSE tasks so they can be re-subscribed."""
dead = [ws_id for ws_id, task in self._sse_tasks.items() if task.done()]
for ws_id in dead:
task = self._sse_tasks.pop(ws_id)
self._subscribed_ws.discard(ws_id)
# Retrieve exception to suppress "Task exception was never
# retrieved" warnings and log the underlying failure.
if not task.cancelled():
exc = task.exception()
if exc is not None:
log.warning(
"discord.sse_task_failed",
trigger=trigger,
ws_id=ws_id,
error=str(exc),
)
if dead:
log.info("discord.purged_dead_tasks", trigger=trigger, count=len(dead), ws_ids=dead)
async def _recover_routes(self) -> None:
"""Re-subscribe to event channels for existing discord routes.
@@ -302,6 +357,11 @@ class TurnstoneBot:
await task
self._subscribed_ws.discard(ws_id)
self._streaming.pop(ws_id, None)
thinking_msg = self._thinking_msgs.pop(ws_id, None)
if thinking_msg is not None:
with contextlib.suppress(Exception):
await thinking_msg.delete()
self._tool_info_msgs.pop(ws_id, None)
self._pending_approval_msgs.pop(ws_id, None)
self._notify_reply_channels.pop(ws_id, None)
# Purge stale notification tracking entries for this workstream.
@@ -322,6 +382,11 @@ class TurnstoneBot:
self._subscribed_ws.discard(ws_id)
self._sse_tasks.pop(ws_id, None)
self._streaming.pop(ws_id, None)
thinking_msg = self._thinking_msgs.pop(ws_id, None)
if thinking_msg is not None:
with contextlib.suppress(Exception):
await thinking_msg.delete()
self._tool_info_msgs.pop(ws_id, None)
self._pending_approval_msgs.pop(ws_id, None)
self._notify_reply_channels.pop(ws_id, None)
stale = [mid for mid, entry in self._notify_ws_map.items() if entry[0] == ws_id]
@@ -340,14 +405,15 @@ class TurnstoneBot:
"""
import httpx_sse
# When routing through the console, connect SSE directly to the
# assigned server node (node_url from the create response).
node_base = await self.router.get_node_url(ws_id)
url = f"{node_base}/v1/api/events"
delay = _SSE_RECONNECT_DELAY
url = "" # set before loop so exception handlers can reference it
while True:
try:
# Re-resolve node URL on each attempt so reconnects pick up
# changes after bot restarts or router cache expiry.
node_base = await self.router.get_node_url(ws_id)
url = f"{node_base}/v1/api/events"
# Refresh auth header per-connection (token may have rotated)
sse_headers: dict[str, str] | None = None
if self._token_factory is not None:
@@ -371,7 +437,13 @@ class TurnstoneBot:
ws_id=ws_id,
status=status,
)
# Fall through to backoff/retry for transient errors.
# Don't try to parse a non-SSE error body —
# fall through to backoff/retry below.
raise httpx.HTTPStatusError(
f"SSE upstream {status}",
request=event_source.response.request,
response=event_source.response,
)
delay = _SSE_RECONNECT_DELAY # reset on successful connect
async for sse in event_source.aiter_sse():
if sse.event == "message" or not sse.event:
@@ -385,12 +457,34 @@ class TurnstoneBot:
)
continue
event = ServerEvent.from_dict(data)
await self._on_ws_event(ws_id, thread, event)
try:
await self._on_ws_event(ws_id, thread, event)
except Exception:
# Discord API failures (rate limits, outages)
# must not kill the SSE connection.
log.warning(
"discord.event_dispatch_failed",
ws_id=ws_id,
exc_info=True,
)
except httpx.HTTPStatusError:
pass # already logged above; fall through to backoff
except httpx.RemoteProtocolError:
# Server closed connection (normal on stream_end or shutdown).
log.debug("discord.sse_remote_closed", ws_id=ws_id)
except asyncio.CancelledError:
return # unsubscribe or shutdown
except httpx.ReadTimeout:
# No data received within read timeout — likely a half-open
# connection. Reconnect to recover.
log.info("discord.sse_read_timeout", ws_id=ws_id)
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
log.warning(
"discord.sse_connect_failed",
ws_id=ws_id,
url=url,
error=str(exc),
)
except Exception:
log.warning("discord.sse_error", ws_id=ws_id, exc_info=True)
@@ -416,7 +510,28 @@ class TurnstoneBot:
)
from turnstone.channels.discord.views import ApprovalView, PlanReviewView
if isinstance(event, ContentEvent):
if isinstance(event, ThinkingStartEvent):
# Clean up any prior thinking message (consecutive starts without stop).
prev = self._thinking_msgs.pop(ws_id, None)
if prev is not None:
with contextlib.suppress(Exception):
await prev.delete()
try:
msg = await thread.send("*Thinking...*")
self._thinking_msgs[ws_id] = msg
except Exception:
log.debug("discord.thinking_start_send_failed", ws_id=ws_id)
elif isinstance(event, ThinkingStopEvent):
# Leave the thinking message in place — the next visible event
# (ContentEvent, ToolInfoEvent, StreamEndEvent) will edit or
# clean it up, avoiding a delete→gap→new-message flicker.
pass
elif isinstance(event, ContentEvent):
# Reuse thinking message as the initial streaming message so the
# first flush edits it in-place (no delete→gap→send flicker).
thinking_msg = self._thinking_msgs.pop(ws_id, None)
sm = self._streaming.get(ws_id)
if sm is None:
sm = StreamingMessage(
@@ -424,9 +539,100 @@ class TurnstoneBot:
max_length=self.config.max_message_length,
edit_interval=self.config.streaming_edit_interval,
)
if thinking_msg is not None:
sm._message = thinking_msg
self._streaming[ws_id] = sm
elif thinking_msg is not None:
with contextlib.suppress(Exception):
await thinking_msg.delete()
await sm.append(event.text)
elif isinstance(event, ToolInfoEvent):
from turnstone.channels._formatter import truncate
# Reuse the thinking message for the first tool embed.
thinking_msg = self._thinking_msgs.pop(ws_id, None)
# Show a "running" embed for every tool. The approval dialog
# (ApproveRequestEvent) is a separate concern — it asks "do you
# authorize this?" while the running embed says "this tool is
# executing." Both can coexist in the thread.
for it in event.items:
name = it.get("func_name") or it.get("approval_label") or "tool"
raw_preview = it.get("preview", "")
# Sanitize preview: escape backticks to prevent markdown
# breakout and strip @-mentions.
raw_preview = raw_preview.replace("`", "\\`")
raw_preview = discord.utils.escape_mentions(raw_preview)
preview = truncate(raw_preview, max_length=120) or None
embed = discord.Embed(
title=name,
description=preview,
color=discord.Color.light_grey(),
)
# Edit thinking message into first tool embed to avoid flicker.
if thinking_msg is not None:
try:
await thinking_msg.edit(content=None, embed=embed)
msg = thinking_msg
except Exception:
msg = await thread.send(embed=embed)
thinking_msg = None
else:
msg = await thread.send(embed=embed)
call_id = it.get("call_id", "")
self._tool_info_msgs.setdefault(ws_id, []).append(
(call_id, name, preview or "", msg)
)
# If no items consumed the thinking message (empty event), clean up.
if thinking_msg is not None:
with contextlib.suppress(Exception):
await thinking_msg.delete()
elif isinstance(event, ToolResultEvent):
from turnstone.channels._formatter import format_tool_result
# Mark the matching "running" embed as complete/errored.
# Prefer call_id match (deterministic); fall back to name (FIFO).
info_list = self._tool_info_msgs.get(ws_id, [])
matched_preview = ""
matched_msg: discord.Message | None = None
if event.call_id:
for i, (cid, _tname, _prev, _tmsg) in enumerate(info_list):
if cid == event.call_id:
entry = info_list.pop(i)
matched_preview, matched_msg = entry[2], entry[3]
break
if matched_msg is None:
for i, (_cid, tname, _prev, _tmsg) in enumerate(info_list):
if tname == event.name:
entry = info_list.pop(i)
matched_preview, matched_msg = entry[2], entry[3]
break
if matched_msg is not None:
status = "Error" if event.is_error else "Done"
status_color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
status_embed = discord.Embed(
title=f"{event.name} \u2014 {status}",
description=matched_preview or None,
color=status_color,
)
try:
await matched_msg.edit(content=None, embed=status_embed)
except Exception:
log.debug("discord.tool_info_status_edit_failed", ws_id=ws_id)
# Send the result as a separate message.
desc = format_tool_result(event.output)
color = discord.Color.red() if event.is_error else discord.Color.dark_grey()
result_embed = discord.Embed(
title=event.name,
description=desc,
color=color,
)
await thread.send(embed=result_embed)
elif isinstance(event, ApproveRequestEvent):
# Evaluate admin tool policies before auto-approve.
_policy_handled = False
@@ -539,7 +745,26 @@ class TurnstoneBot:
except Exception:
log.debug("discord.verdict_embed_edit_failed", ws_id=ws_id)
elif isinstance(event, ApprovalResolvedEvent):
# Server resolved the approval (timeout, external approve/reject).
# Disable the buttons so they can't be clicked stale.
approval_msg = self._pending_approval_msgs.pop(ws_id, None)
if approval_msg is not None:
from turnstone.channels.discord.views import disable_message_buttons
label = "Approved" if event.approved else "Denied"
try:
await disable_message_buttons(approval_msg, label)
except Exception:
log.debug("discord.approval_resolved_edit_failed", ws_id=ws_id)
elif isinstance(event, StreamEndEvent):
# Edge-case cleanup: clear any lingering thinking indicator.
thinking_msg = self._thinking_msgs.pop(ws_id, None)
if thinking_msg is not None:
with contextlib.suppress(Exception):
await thinking_msg.delete()
self._tool_info_msgs.pop(ws_id, None)
sm = self._streaming.pop(ws_id, None)
if sm is not None:
await sm.finalize()
+7
View File
@@ -136,6 +136,7 @@ class MessageCog:
str(channel.id),
name=channel.name or "",
initial_message="",
client_type="chat",
)
except (TimeoutError, RuntimeError):
log.warning("discord.ws_reactivation_failed", thread_id=channel.id)
@@ -192,6 +193,7 @@ class MessageCog:
name=thread_name,
model=self.ts.config.model,
initial_message="",
client_type="chat",
)
await self.ts.subscribe_ws(ws_id, thread)
@@ -210,6 +212,10 @@ class MessageCog:
# Only handle explicit replies to a tracked notification message.
ref = message.reference
if ref is None or ref.message_id is None:
await message.channel.send(
"*Direct messages aren't supported. "
"Use `/ask` in a server channel or @mention me to start a conversation.*"
)
return
# Atomic pop prevents TOCTOU race across await points.
@@ -366,6 +372,7 @@ class MessageCog:
name=thread_name,
model=self.ts.config.model,
initial_message="",
client_type="chat",
)
await self.ts.subscribe_ws(ws_id, thread)
+19 -8
View File
@@ -30,15 +30,17 @@ def _parse_footer(interaction: discord.Interaction) -> tuple[str, str] | None:
return parts[0], parts[1]
async def _disable_buttons(interaction: discord.Interaction, label: str) -> None:
"""Edit the message to disable all buttons and append a result label."""
async def disable_message_buttons(message: discord.Message, label: str) -> None:
"""Disable all buttons on *message* and append *label* to the embed title.
Used both from interaction callbacks (via the message attribute) and
from bot event handlers when the server resolves an approval externally
(e.g. timeout).
"""
import discord
if interaction.message is None:
return
view = discord.ui.View()
for item in interaction.message.components or []:
for item in message.components or []:
for child in item.children: # type: ignore[union-attr]
button: discord.ui.Button[discord.ui.View] = discord.ui.Button(
label=getattr(child, "label", ""),
@@ -48,12 +50,19 @@ async def _disable_buttons(interaction: discord.Interaction, label: str) -> None
)
view.add_item(button)
embed = interaction.message.embeds[0] if interaction.message.embeds else None
embed = message.embeds[0] if message.embeds else None
if embed is not None:
embed.color = discord.Color.greyple()
embed.title = f"{embed.title} - {label}"
await interaction.message.edit(embed=embed, view=view)
await message.edit(embed=embed, view=view)
async def _disable_buttons(interaction: discord.Interaction, label: str) -> None:
"""Edit the interaction message to disable all buttons and append *label* to the embed title."""
if interaction.message is None:
return
await disable_message_buttons(interaction.message, label)
# ---------------------------------------------------------------------------
@@ -151,6 +160,8 @@ class ApprovalView:
)
label = "Always Approved" if always else ("Approved" if approved else "Rejected")
# Pop pending approval so ApprovalResolvedEvent doesn't double-update.
self.bot._pending_approval_msgs.pop(ws_id, None)
await _disable_buttons(interaction, label)
await interaction.followup.send(
f"Tool execution **{label.lower()}**.",
+15 -9
View File
@@ -633,7 +633,7 @@ def _handle_ws_command(
# ─── Cluster commands ─────────────────────────────────────────────────────
def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token: str = "") -> None:
def _handle_cluster_command(cmd_line: str, console_url: str | None) -> None:
"""Handle /cluster subcommands querying the turnstone-console API."""
import httpx
@@ -642,8 +642,18 @@ def _handle_cluster_command(cmd_line: str, console_url: str | None, auth_token:
return
headers: dict[str, str] = {}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
if jwt_secret:
from turnstone.core.auth import JWT_AUD_CONSOLE, ServiceTokenManager
_cluster_token_mgr = ServiceTokenManager(
user_id="cli",
scopes=frozenset({"read", "write", "approve", "service"}),
source="cli",
secret=jwt_secret,
audience=JWT_AUD_CONSOLE,
)
headers["Authorization"] = f"Bearer {_cluster_token_mgr.token}"
parts = cmd_line.strip().split()
sub = parts[1] if len(parts) > 1 else "status"
@@ -968,11 +978,6 @@ def main() -> None:
default=None,
help="Turnstone console URL for /cluster commands (e.g., http://localhost:8090)",
)
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
help="Bearer token for authenticating to turnstone services (default: $TURNSTONE_AUTH_TOKEN)",
)
parser.add_argument(
"--mcp-config",
default=None,
@@ -1132,6 +1137,7 @@ def main() -> None:
ws_id: str | None = None,
*,
skill: str | None = None,
client_type: str = "",
) -> ChatSession:
assert ui is not None, "session_factory requires a non-None UI"
r_client, r_model, r_cfg = registry.resolve(model_alias)
@@ -1246,7 +1252,7 @@ def main() -> None:
continue
if user_input.startswith("/cluster"):
_handle_cluster_command(user_input, args.console_url, args.auth_token)
_handle_cluster_command(user_input, args.console_url)
continue
active = manager.get_active()
+358 -201
View File
@@ -1,22 +1,26 @@
"""Cluster state collector — aggregates data from all turnstone nodes.
Discovers nodes via the service registry (StorageBackend), polls each
node's /v1/api/dashboard endpoint for workstream data.
Discovers nodes via the service registry (StorageBackend) and subscribes
to each node's ``/v1/api/events/global`` SSE stream for real-time state
updates. A single asyncio event loop on one dedicated thread multiplexes
all SSE connections, scaling to 1000+ nodes.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import queue
import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import httpx
import httpx_sse
if TYPE_CHECKING:
from turnstone.console.metrics import ConsoleMetrics
@@ -34,7 +38,7 @@ class NodeSnapshot:
node_id: str = ""
server_url: str = ""
started: float = 0.0
last_seen: float = 0.0 # monotonic time of last successful poll
last_seen: float = 0.0 # monotonic time of last successful data
max_ws: int = 10 # max workstreams (capacity)
workstreams: dict[str, dict[str, Any]] = field(default_factory=dict)
health: dict[str, Any] = field(default_factory=dict)
@@ -43,21 +47,18 @@ class NodeSnapshot:
class ClusterCollector:
"""Aggregates cluster state from the service registry and per-node HTTP APIs.
"""Aggregates cluster state from the service registry and per-node SSE streams.
Two daemon threads:
1. Node discovery queries the service registry every ``discovery_interval`` seconds
2. Poll loop fetches /v1/api/dashboard from each node every ``poll_interval`` seconds
2. SSE manager single asyncio event loop multiplexing SSE connections to all nodes
"""
def __init__(
self,
storage: StorageBackend,
poll_interval: float = 15.0,
discovery_interval: float = 15.0,
max_poll_workers: int = 200,
discovery_interval: float = 60.0,
http_timeout: float = 30.0,
auth_token: str = "",
token_manager: ServiceTokenManager | None = None,
tls_verify: Any = True,
tls_cert: tuple[str, str] | None = None,
@@ -65,57 +66,53 @@ class ClusterCollector:
console_metrics: ConsoleMetrics | None = None,
):
self._storage = storage
self._poll_interval = poll_interval
self._discovery_interval = discovery_interval
self._max_poll_workers = max_poll_workers
self._http_timeout = http_timeout
self._token_manager = token_manager
self._router = router
self._console_metrics = console_metrics
# 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._tls_verify = tls_verify
self._tls_cert = tls_cert
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)
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),
),
verify=tls_verify,
cert=tls_cert,
)
# SSE fan-out to browser clients
self._listeners: list[queue.Queue[dict[str, Any]]] = []
self._listeners_lock = threading.Lock()
# SSE manager state (managed by the asyncio event loop thread)
self._sse_loop: asyncio.AbstractEventLoop | None = None
self._sse_tasks: dict[str, asyncio.Task[None]] = {}
self._sse_stop_events: dict[str, asyncio.Event] = {}
self._sse_async_client: httpx.AsyncClient | None = None
def upgrade_tls(self, tls_verify: Any = True, tls_cert: tuple[str, str] | None = None) -> None:
"""Replace the httpx client with one using mTLS context."""
old = self._http_client
self._http_client = httpx.Client(
timeout=httpx.Timeout(
connect=10, read=self._http_timeout, write=5, pool=self._http_timeout
),
limits=httpx.Limits(
max_connections=self._max_poll_workers + 10,
max_keepalive_connections=min(self._max_poll_workers, 200),
),
verify=tls_verify,
cert=tls_cert,
"""Update TLS settings for future SSE connections."""
self._tls_verify = tls_verify
self._tls_cert = tls_cert
# If the async client is running, replace it on the event loop.
if self._sse_loop is not None and self._sse_loop.is_running():
asyncio.run_coroutine_threadsafe(self._replace_async_client(), self._sse_loop)
async def _replace_async_client(self) -> None:
"""Replace the async httpx client (called on the SSE event loop).
Closing the old client terminates its underlying connections, which
causes active ``_node_sse_task`` coroutines to raise and reconnect
using the new client with updated TLS settings.
"""
old = self._sse_async_client
self._sse_async_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
limits=httpx.Limits(max_connections=2000, max_keepalive_connections=1500),
verify=self._tls_verify,
cert=self._tls_cert,
)
# Don't close old client — concurrent _fetch_node() threads may still
# be using it. It will be GC'd once all references are released, and
# the current client is closed in stop().
del old
if old is not None:
await old.aclose()
# -- lifecycle -----------------------------------------------------------
@@ -124,7 +121,7 @@ class ClusterCollector:
self._running = True
for target, name in [
(self._discovery_loop, "console-discovery"),
(self._poll_loop, "console-poll"),
(self._sse_manager_thread, "console-sse"),
]:
t = threading.Thread(target=target, name=name, daemon=True)
t.start()
@@ -132,10 +129,22 @@ class ClusterCollector:
log.info("ClusterCollector started")
def stop(self) -> None:
"""Stop all threads and clean up resources."""
"""Stop all threads and clean up resources.
Sets ``_running = False`` which causes the SSE manager coroutine to
exit naturally (its ``while self._running`` loop terminates), running
its ``finally`` cleanup (cancel tasks, close AsyncClient).
"""
self._running = False
self._poll_pool.shutdown(wait=False)
self._http_client.close()
# Request cancellation of all SSE tasks so they don't block the
# manager's cleanup. The manager coroutine exits when _running is
# False and handles remaining task cancellation in its finally block.
if self._sse_loop is not None and self._sse_loop.is_running():
for node_id in list(self._sse_tasks):
asyncio.run_coroutine_threadsafe(self._stop_node(node_id), self._sse_loop)
# Wait for background threads to finish their shutdown.
for t in self._threads:
t.join(timeout=5)
log.info("ClusterCollector stopped")
def _fanout(self, event: dict[str, Any]) -> None:
@@ -145,6 +154,137 @@ class ClusterCollector:
with contextlib.suppress(queue.Full):
q.put_nowait(event)
# -- auth helpers --------------------------------------------------------
def _auth_headers(self) -> dict[str, str]:
"""Build auth headers for the current SSE connection."""
if self._token_manager is not None:
return {"Authorization": f"Bearer {self._token_manager.token}"}
return {}
# -- SSE manager ---------------------------------------------------------
def _sse_manager_thread(self) -> None:
"""Run asyncio event loop that manages all node SSE connections."""
self._sse_loop = asyncio.new_event_loop()
try:
self._sse_loop.run_until_complete(self._sse_manager())
finally:
self._sse_loop.close()
self._sse_loop = None
async def _sse_manager(self) -> None:
"""Top-level coroutine — runs until collector stops."""
self._sse_async_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
limits=httpx.Limits(max_connections=2000, max_keepalive_connections=1500),
verify=self._tls_verify,
cert=self._tls_cert,
)
try:
while self._running:
await asyncio.sleep(1)
finally:
# Cancel all remaining tasks
for task in self._sse_tasks.values():
task.cancel()
for task in self._sse_tasks.values():
with contextlib.suppress(asyncio.CancelledError):
await task
self._sse_tasks.clear()
self._sse_stop_events.clear()
await self._sse_async_client.aclose()
self._sse_async_client = None
async def _start_node(self, node_id: str) -> None:
"""Start an SSE task for a node (called on the SSE event loop)."""
if node_id in self._sse_tasks:
return # already running
stop = asyncio.Event()
self._sse_stop_events[node_id] = stop
self._sse_tasks[node_id] = asyncio.create_task(
self._node_sse_task(node_id, stop),
name=f"sse-{node_id}",
)
async def _stop_node(self, node_id: str) -> None:
"""Stop an SSE task for a node (called on the SSE event loop)."""
stop = self._sse_stop_events.pop(node_id, None)
if stop:
stop.set()
task = self._sse_tasks.pop(node_id, None)
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _node_sse_task(self, node_id: str, stop_event: asyncio.Event) -> None:
"""Persistent SSE connection to a single server node."""
backoff = 1.0
while not stop_event.is_set() and self._running:
url = self._get_node_url(node_id)
if not url or self._sse_async_client is None:
break
base = url.rstrip("/")
try:
async with httpx_sse.aconnect_sse(
self._sse_async_client,
"GET",
f"{base}/v1/api/events/global",
params={"expected_node_id": node_id},
headers=self._auth_headers(),
) as source:
if source.response.status_code == 409:
log.warning("Node identity mismatch for %s at %s", node_id, url)
self._mark_unreachable(node_id)
break # stop reconnecting — wrong node at this URL
source.response.raise_for_status()
async for sse in source.aiter_sse():
if stop_event.is_set():
break
if not sse.data:
continue # ping/comment frame
try:
data = json.loads(sse.data)
except json.JSONDecodeError:
log.debug("Invalid SSE JSON from node %s", node_id)
continue
etype = data.get("type", "")
if etype == "node_snapshot":
# Client-side identity check (defense in depth)
if data.get("node_id") != node_id:
log.warning(
"Snapshot node_id mismatch: expected %s, got %s",
node_id,
data.get("node_id"),
)
self._mark_unreachable(node_id)
break
self._apply_snapshot(node_id, data)
backoff = 1.0
else:
self._apply_delta(node_id, data)
except asyncio.CancelledError:
raise
except Exception:
log.debug("SSE error for node %s", node_id, exc_info=True)
self._mark_unreachable(node_id)
await asyncio.sleep(min(backoff, 30) + random.random())
backoff = min(backoff * 2, 30)
def _get_node_url(self, node_id: str) -> str:
"""Get the server URL for a node (thread-safe)."""
with self._lock:
node = self._nodes.get(node_id)
return node.server_url if node else ""
def _mark_unreachable(self, node_id: str) -> None:
"""Mark a node as unreachable (thread-safe)."""
with self._lock:
node = self._nodes.get(node_id)
if node:
node.reachable = False
# -- node discovery ------------------------------------------------------
def _discovery_loop(self) -> None:
@@ -161,6 +301,8 @@ class ClusterCollector:
raw_services = self._storage.list_services("server", max_age_seconds=120)
active_ids = set()
pending_events: list[dict[str, Any]] = []
new_nodes: list[str] = []
lost_nodes: list[str] = []
with self._lock:
for svc in raw_services:
@@ -183,6 +325,7 @@ class ClusterCollector:
max_ws=meta.get("max_ws", 10),
)
pending_events.append({"type": "node_joined", "node_id": nid})
new_nodes.append(nid)
log.info("Discovered node: %s", nid)
else:
self._nodes[nid].server_url = url or self._nodes[nid].server_url
@@ -193,10 +336,18 @@ class ClusterCollector:
for nid in lost:
del self._nodes[nid]
pending_events.append({"type": "node_lost", "node_id": nid})
lost_nodes.append(nid)
log.info("Lost node: %s", nid)
for event in pending_events:
self._fanout(event)
# Manage SSE tasks for new/lost nodes
if self._sse_loop is not None and self._sse_loop.is_running():
for nid in new_nodes:
asyncio.run_coroutine_threadsafe(self._start_node(nid), self._sse_loop)
for nid in lost_nodes:
asyncio.run_coroutine_threadsafe(self._stop_node(nid), self._sse_loop)
# Notify the routing layer so it can refresh its hash-ring cache
# when the rebalancer has published a new version.
if self._router is not None:
@@ -211,114 +362,67 @@ class ClusterCollector:
self._router.version,
)
# -- polling -------------------------------------------------------------
# -- SSE event handlers --------------------------------------------------
def _poll_loop(self) -> None:
"""Periodically fetch /v1/api/dashboard from each node."""
while self._running:
try:
self._poll_all_nodes()
except Exception:
log.exception("Poll loop error")
time.sleep(self._poll_interval)
def _reconcile_node(
self, node_id: str, node: NodeSnapshot, new_ws_list: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Diff new workstream data against the current snapshot.
@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.
Returns a list of pending events. Caller must hold ``_lock``.
Updates ``node.workstreams`` in place.
"""
h = hash(node_id) & 0x7FFFFFFF # positive 31-bit
return (h % 2147483647) / 2147483647 * window # M31 = 2^31 - 1
pending: list[dict[str, Any]] = []
old_ids = {k for k in node.workstreams if k}
new_ws: dict[str, dict[str, Any]] = {}
for ws in new_ws_list:
ws_id = ws.get("id", "")
if not ws_id:
continue
ws["node"] = node_id
ws["server_url"] = node.server_url
new_ws[ws_id] = ws
new_ids = set(new_ws.keys())
# Additions
for ws_id in sorted(new_ids - old_ids):
ws = new_ws[ws_id]
pending.append(
{
"type": "ws_created",
"ws_id": ws_id,
"name": ws.get("name", ""),
"node_id": node_id,
}
)
# Removals
for ws_id in sorted(old_ids - new_ids):
pending.append({"type": "ws_closed", "ws_id": ws_id})
# State and name changes on existing workstreams
for ws_id in sorted(new_ids & old_ids):
old_ws = node.workstreams.get(ws_id, {})
new_w = new_ws[ws_id]
old_state = old_ws.get("state", "")
new_state = new_w.get("state", "")
if old_state != new_state:
pending.append(
{
"type": "cluster_state",
"ws_id": ws_id,
"state": new_state,
"node_id": node_id,
"tokens": new_w.get("tokens", 0),
"content": new_w.get("content", ""),
}
)
old_name = old_ws.get("name", "")
new_name = new_w.get("name", "")
if old_name != new_name and new_name:
pending.append({"type": "ws_rename", "ws_id": ws_id, "name": new_name})
node.workstreams = new_ws
return pending
def _poll_all_nodes(self) -> None:
"""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)
for n in self._nodes.values()
if n.server_url and n.server_url.startswith("http")
]
if not targets:
return
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.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,
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", 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", 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
def _apply_poll(self, node_id: str, dashboard: dict[str, Any], health: dict[str, Any]) -> None:
"""Apply polled data to the in-memory node snapshot."""
ws_list = dashboard.get("workstreams", [])
aggregate = dashboard.get("aggregate", {})
def _apply_snapshot(self, node_id: str, data: dict[str, Any]) -> None:
"""Apply a ``node_snapshot`` SSE event to the in-memory state."""
pending_events: list[dict[str, Any]] = []
with self._lock:
node = self._nodes.get(node_id)
@@ -326,63 +430,116 @@ class ClusterCollector:
return
node.last_seen = time.monotonic()
node.reachable = True
node.health = health
node.aggregate = aggregate
# Build new workstream map
old_ids = {k for k in node.workstreams if k}
new_ws: dict[str, dict[str, Any]] = {}
for ws in ws_list:
ws_id = ws.get("id", "")
if not ws_id:
continue
ws["node"] = node_id
ws["server_url"] = node.server_url
new_ws[ws_id] = ws
new_ids = set(new_ws.keys())
# Detect additions not yet known to SSE clients
for ws_id in sorted(new_ids - old_ids):
ws = new_ws[ws_id]
node.health = data.get("health", {})
node.aggregate = data.get("aggregate", {})
pending_events = self._reconcile_node(node_id, node, data.get("workstreams", []))
for event in pending_events:
self._fanout(event)
def _apply_delta(self, node_id: str, data: dict[str, Any]) -> None:
"""Apply a single delta SSE event to the in-memory state."""
etype = data.get("type", "")
if not etype:
return
pending_events: list[dict[str, Any]] = []
with self._lock:
node = self._nodes.get(node_id)
if not node:
return
node.last_seen = time.monotonic()
if etype == "ws_state":
# Server emits ws_state; translate to cluster_state for browser.
# Only fan out if the workstream is known — a ws_state arriving
# before ws_created (race) is silently absorbed on reconnect.
ws_id = data.get("ws_id", "")
ws = node.workstreams.get(ws_id)
if ws:
ws["state"] = data.get("state", ws.get("state", ""))
ws["tokens"] = data.get("tokens", ws.get("tokens", 0))
ws["context_ratio"] = data.get("context_ratio", ws.get("context_ratio", 0))
ws["activity"] = data.get("activity", ws.get("activity", ""))
ws["activity_state"] = data.get("activity_state", ws.get("activity_state", ""))
pending_events.append(
{
"type": "cluster_state",
"ws_id": ws_id,
"state": data.get("state", ""),
"node_id": node_id,
"tokens": data.get("tokens", 0),
"content": data.get("content", ""),
}
)
elif etype == "ws_activity":
ws_id = data.get("ws_id", "")
ws = node.workstreams.get(ws_id)
if ws:
ws["activity"] = data.get("activity", "")
ws["activity_state"] = data.get("activity_state", "")
# Activity events are not forwarded to cluster SSE — only state changes
elif etype == "ws_created":
ws_id = data.get("ws_id", "")
if ws_id and ws_id not in node.workstreams:
node.workstreams[ws_id] = {
"id": ws_id,
"name": data.get("name", ""),
"state": "idle",
"node": node_id,
"server_url": node.server_url,
"model": data.get("model", ""),
"model_alias": data.get("model_alias", ""),
"tokens": 0,
"context_ratio": 0.0,
"activity": "",
"activity_state": "",
"tool_calls": 0,
"title": "",
}
pending_events.append(
{
"type": "ws_created",
"ws_id": ws_id,
"name": ws.get("name", ""),
"name": data.get("name", ""),
"node_id": node_id,
}
)
# Detect removals
for ws_id in sorted(old_ids - new_ids):
elif etype == "ws_closed":
ws_id = data.get("ws_id", "")
node.workstreams.pop(ws_id, None)
pending_events.append({"type": "ws_closed", "ws_id": ws_id})
# Detect state changes on existing workstreams
for ws_id in sorted(new_ids & old_ids):
old_ws = node.workstreams.get(ws_id, {})
new_w = new_ws[ws_id]
old_state = old_ws.get("state", "")
new_state = new_w.get("state", "")
if old_state != new_state:
pending_events.append(
{
"type": "ws_state",
"ws_id": ws_id,
"state": new_state,
"node_id": node_id,
"tokens": new_w.get("tokens", 0),
"content": new_w.get("content", ""),
}
)
# Detect name/title changes
old_name = old_ws.get("name", "")
new_name = new_w.get("name", "")
if old_name != new_name and new_name:
pending_events.append(
{
"type": "ws_rename",
"ws_id": ws_id,
"name": new_name,
}
)
node.workstreams = new_ws
# Fan out diffs to SSE listeners outside the lock
elif etype == "ws_rename":
ws_id = data.get("ws_id", "")
name = data.get("name", "")
ws = node.workstreams.get(ws_id)
if ws and name:
ws["name"] = name
pending_events.append({"type": "ws_rename", "ws_id": ws_id, "name": name})
elif etype == "health_changed":
# Update the health dict's circuit state in-place
circuit = data.get("circuit_state", "")
if circuit:
if not node.health:
node.health = {}
backend = node.health.setdefault("backend", {})
backend["circuit_state"] = circuit
backend["status"] = "up" if circuit == "closed" else "down"
node.health["status"] = "ok" if circuit == "closed" else "degraded"
# Not forwarded to cluster SSE — next snapshot refreshes UI
elif etype == "aggregate":
node.aggregate = {
"total_tokens": data.get("total_tokens", 0),
"total_tool_calls": data.get("total_tool_calls", 0),
"active_count": data.get("active_count", 0),
"total_count": data.get("total_count", 0),
}
# Not forwarded to cluster SSE — overview queries read from snapshot
for event in pending_events:
self._fanout(event)
+5 -1
View File
@@ -64,6 +64,7 @@ class Rebalancer:
lock_ttl: int = 120,
eager_migrate: bool = False,
api_token: str = "",
token_manager: Any = None,
) -> None:
self._storage = storage
self._router = router
@@ -75,6 +76,7 @@ class Rebalancer:
self._lock_ttl = lock_ttl
self._eager_migrate = eager_migrate
self._api_token = api_token
self._token_manager = token_manager
self._stop_event = threading.Event()
self._trigger_event = threading.Event()
self._thread: threading.Thread | None = None
@@ -530,7 +532,9 @@ class Rebalancer:
return 0
headers: dict[str, str] = {}
if self._api_token:
if self._token_manager is not None:
headers["Authorization"] = f"Bearer {self._token_manager.token}"
elif self._api_token:
headers["Authorization"] = f"Bearer {self._api_token}"
migrated = 0
+234 -76
View File
@@ -162,19 +162,11 @@ def _proxy_auth_headers(request: Request) -> dict[str, str]:
)
return {"Authorization": f"Bearer {token}"}
# Fallback: service identity (no user context).
# When auth is disabled on the console, auth_result is None, so all proxied
# requests use the full-privilege service identity. This is safe only when
# the upstream server also has auth disabled.
# Fallback: service identity via ServiceTokenManager.
mgr = getattr(request.app.state, "proxy_token_mgr", None)
if mgr is not None:
return dict(mgr.bearer_header)
# Fall back to static proxy_auth_token (e.g. from --auth-token)
static_token = getattr(request.app.state, "proxy_auth_token", "")
if static_token:
return {"Authorization": f"Bearer {static_token}"}
return {}
@@ -5567,6 +5559,193 @@ async def tls_ca_cert(request: Request) -> Response:
)
# ---------------------------------------------------------------------------
# Admin: Prompt Policies (system message composition)
# ---------------------------------------------------------------------------
async def admin_list_prompt_policies(request: Request) -> JSONResponse:
"""GET /v1/api/admin/prompt-policies — list all prompt policies."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policies = storage.list_prompt_policies()
return JSONResponse({"policies": policies})
async def admin_create_prompt_policy(request: Request) -> JSONResponse:
"""POST /v1/api/admin/prompt-policies — create a prompt policy."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:64]
content = str(body.get("content", "")).strip()[:32768]
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not content:
return JSONResponse({"error": "content is required"}, status_code=400)
try:
priority = int(body.get("priority", 0))
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
policy_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
storage.upsert_prompt_policy(
{
"policy_id": policy_id,
"name": name,
"content": content,
"tool_gate": str(body.get("tool_gate", "")).strip(),
"priority": priority,
"enabled": bool(body.get("enabled", True)),
"org_id": str(body.get("org_id", "")).strip(),
"created_by": audit_uid,
}
)
record_audit(
storage,
audit_uid,
"prompt_policy.create",
"prompt_policy",
policy_id,
{"name": name},
ip,
)
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
async def admin_get_prompt_policy(request: Request) -> JSONResponse:
"""GET /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policy_id = request.path_params["policy_id"]
policy = storage.get_prompt_policy(policy_id)
if policy is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
return JSONResponse(policy)
async def admin_update_prompt_policy(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_prompt_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
update = dict(body)
update["policy_id"] = policy_id
if "name" in update:
update["name"] = str(update["name"]).strip()[:64]
if "priority" in update:
try:
update["priority"] = int(update["priority"])
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
if "content" in update:
update["content"] = str(update["content"]).strip()[:32768]
if "tool_gate" in update:
update["tool_gate"] = str(update["tool_gate"] or "").strip()
if "enabled" in update:
update["enabled"] = bool(update["enabled"])
storage.upsert_prompt_policy(update)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"prompt_policy.update",
"prompt_policy",
policy_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
async def admin_delete_prompt_policy(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_prompt_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
storage.delete_prompt_policy(policy_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"prompt_policy.delete",
"prompt_policy",
policy_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok", "policy_id": policy_id})
async def admin_ring_status(request: Request) -> JSONResponse:
"""GET /v1/api/admin/ring/status — hash ring rebalancer status."""
from turnstone.core.auth import require_permission
@@ -5737,10 +5916,8 @@ def _seed_config_from_env(config_store: Any, storage: Any) -> None:
def create_app(
*,
collector: ClusterCollector,
auth_config: Any,
jwt_secret: str = "",
auth_storage: Any = None,
proxy_auth_token: str = "",
proxy_token_mgr: Any = None,
cors_origins: list[str] | None = None,
tls_manager: Any = None,
@@ -6005,6 +6182,27 @@ def create_app(
"/api/admin/model-capabilities/known",
admin_known_models,
),
# Governance: Prompt Policies
Route("/api/admin/prompt-policies", admin_list_prompt_policies),
Route(
"/api/admin/prompt-policies",
admin_create_prompt_policy,
methods=["POST"],
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_get_prompt_policy,
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_update_prompt_policy,
methods=["PUT"],
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_delete_prompt_policy,
methods=["DELETE"],
),
# Governance: Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
@@ -6057,10 +6255,8 @@ def create_app(
lifespan=_lifespan,
)
app.state.collector = collector
app.state.auth_config = auth_config
app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage
app.state.proxy_auth_token = proxy_auth_token
app.state.proxy_token_mgr = proxy_token_mgr
app.state.console_url = console_url
app.state.tls_manager = tls_manager
@@ -6093,7 +6289,7 @@ def create_app(
scheduler = TaskScheduler(
collector=collector,
storage=auth_storage,
api_token=proxy_auth_token,
api_token="",
token_manager=proxy_token_mgr,
)
app.state.scheduler = scheduler
@@ -6140,21 +6336,9 @@ def main() -> None:
default=8090,
help="Port to listen on (default: 8090)",
)
parser.add_argument(
"--poll-interval",
type=float,
default=10.0,
help="Node polling interval in seconds (default: 10)",
)
from turnstone.core.log import add_log_args
add_log_args(parser)
parser.add_argument(
"--auth-token",
default=os.environ.get("TURNSTONE_AUTH_TOKEN", ""),
help="Bearer token for polling turnstone-server nodes (default: $TURNSTONE_AUTH_TOKEN)",
)
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
@@ -6165,10 +6349,9 @@ def main() -> None:
configure_logging_from_args(args, "console")
from turnstone.core.auth import load_auth_config, load_jwt_secret
from turnstone.core.auth import load_jwt_secret
auth_config = load_auth_config()
jwt_secret = load_jwt_secret() if auth_config.enabled else ""
jwt_secret = load_jwt_secret()
# Initialize storage early — the collector needs it for service discovery.
auth_storage = None
@@ -6197,39 +6380,23 @@ def main() -> None:
)
raise SystemExit(1)
# If no explicit auth token is provided, use a ServiceTokenManager
# so collector JWTs auto-rotate. A shared JWT secret is required for
# multi-service deployments — ephemeral secrets differ per process.
collector_token = args.auth_token
collector_token_mgr = None
if not collector_token:
_jwt_secret = os.environ.get("TURNSTONE_JWT_SECRET", "")
if not _jwt_secret:
log.error(
"TURNSTONE_JWT_SECRET is not set and no --auth-token provided. "
"The console cannot authenticate to server nodes. Set TURNSTONE_JWT_SECRET "
"to a shared secret (at least 32 characters) or pass --auth-token."
)
raise SystemExit(1)
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
collector_token_mgr = ServiceTokenManager(
user_id="console-collector",
scopes=frozenset({"read"}),
source="console",
secret=_jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.collector_token_manager_created")
collector_token_mgr = ServiceTokenManager(
user_id="console-collector",
scopes=frozenset({"read"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.collector_token_manager_created")
router = ConsoleRouter(storage=auth_storage)
console_metrics = ConsoleMetrics()
collector = ClusterCollector(
storage=auth_storage,
poll_interval=args.poll_interval,
auth_token=collector_token if collector_token_mgr is None else "",
token_manager=collector_token_mgr,
router=router,
console_metrics=console_metrics,
@@ -6238,22 +6405,15 @@ def main() -> None:
_load_static()
# If no explicit auth token is provided, use a ServiceTokenManager
# so proxy JWTs auto-rotate.
proxy_token = args.auth_token
proxy_token_mgr = None
if not proxy_token and jwt_secret:
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
proxy_token_mgr = ServiceTokenManager(
user_id="console-proxy",
scopes=frozenset({"read", "write", "approve"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.proxy_token_manager_created")
proxy_token_mgr = ServiceTokenManager(
user_id="console-proxy",
scopes=frozenset({"read", "write", "approve", "service"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.proxy_token_manager_created")
from turnstone.core.web_helpers import parse_cors_origins
@@ -6340,7 +6500,8 @@ def main() -> None:
threshold=_rcs.get("rebalancer.threshold", 0.10),
vnodes_per_unit=_rcs.get("ring.vnodes_per_unit", 150),
eager_migrate=_rcs.get("rebalancer.eager_migrate", False),
api_token=proxy_token if proxy_token_mgr is None else "",
api_token="",
token_manager=proxy_token_mgr,
)
log.info("rebalancer.configured")
except Exception:
@@ -6348,10 +6509,8 @@ def main() -> None:
app = create_app(
collector=collector,
auth_config=auth_config,
jwt_secret=jwt_secret,
auth_storage=auth_storage,
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
proxy_token_mgr=proxy_token_mgr,
cors_origins=cors_origins,
tls_manager=tls_mgr,
@@ -6362,8 +6521,7 @@ def main() -> None:
)
log.info("Console starting on %s", console_url)
if auth_config.enabled:
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
log.info("Auth: enabled (JWT)")
print("Press Ctrl+C to stop.")
import uvicorn
+9
View File
@@ -59,6 +59,7 @@ function showAdmin() {
watches: "admin.watches",
roles: "admin.roles",
policies: "admin.policies",
"prompt-policies": "admin.prompt_policies",
skills: "admin.skills",
usage: "admin.usage",
audit: "admin.audit",
@@ -198,6 +199,7 @@ function switchAdminTab(tab) {
"settings",
"tls",
"mcp",
"prompt-policies",
];
for (var p = 0; p < panels.length; p++) {
var el = document.getElementById("admin-" + panels[p]);
@@ -222,6 +224,7 @@ function switchAdminTab(tab) {
if (tab === "settings") loadSettings();
if (tab === "tls") loadTlsCerts();
if (tab === "mcp") loadAdminMcp();
if (tab === "prompt-policies") loadPromptPolicies();
// Update breadcrumb with active tab label
var activeNav = document.querySelector('.admin-nav[data-tab="' + tab + '"]');
@@ -1848,6 +1851,10 @@ function _installTrap(overlayId, boxId, trapRef) {
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
else if (overlayId === "model-create-overlay") hideCreateModelModal();
else if (overlayId === "create-ppolicy-overlay")
hideCreatePromptPolicyModal();
else if (overlayId === "edit-ppolicy-overlay")
hideEditPromptPolicyModal();
}
};
}
@@ -1937,6 +1944,8 @@ document.addEventListener("keydown", function (e) {
["mcp-create-overlay", hideCreateMcpModal],
["github-import-overlay", hideGitHubImportModal],
["model-create-overlay", hideCreateModelModal],
["create-ppolicy-overlay", hideCreatePromptPolicyModal],
["edit-ppolicy-overlay", hideEditPromptPolicyModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
+279
View File
@@ -2450,3 +2450,282 @@ function submitGitHubImport() {
submitBtn.textContent = "Install";
});
}
// ---------------------------------------------------------------------------
// Prompt Policies (system message composition)
// ---------------------------------------------------------------------------
var _promptPolicies = [];
var _cppTrapHandler = null;
var _cppTriggerEl = null;
var _eppTrapHandler = null;
var _eppTriggerEl = null;
function loadPromptPolicies() {
authFetch("/v1/api/admin/prompt-policies")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
_promptPolicies = data.policies || [];
_renderPromptPolicies(_promptPolicies);
})
.catch(function () {
var el = document.getElementById("admin-prompt-policies-table");
el.textContent = "";
var empty = document.createElement("div");
empty.className = "dashboard-empty";
empty.textContent = "Failed to load prompts";
el.appendChild(empty);
});
}
function _renderPromptPolicies(items) {
var el = document.getElementById("admin-prompt-policies-table");
el.textContent = "";
if (!items.length) {
var empty = document.createElement("div");
empty.className = "dashboard-empty";
empty.textContent = "No prompts defined";
el.appendChild(empty);
return;
}
for (var i = 0; i < items.length; i++) {
var p = items[i];
var row = document.createElement("div");
row.className = "admin-row";
row.setAttribute("role", "listitem");
var colName = document.createElement("span");
colName.className = "admin-col admin-col-pname";
colName.textContent = p.name;
row.appendChild(colName);
var colGate = document.createElement("span");
colGate.className = "admin-col admin-col-ppattern";
if (p.tool_gate) {
var code = document.createElement("code");
code.textContent = p.tool_gate;
colGate.appendChild(code);
} else {
var em = document.createElement("em");
em.textContent = "unconditional";
colGate.appendChild(em);
}
row.appendChild(colGate);
var colPri = document.createElement("span");
colPri.className = "admin-col admin-col-ppriority";
colPri.textContent = String(p.priority);
row.appendChild(colPri);
var colStatus = document.createElement("span");
colStatus.className = "admin-col admin-col-pstatus";
var dot = document.createElement("span");
dot.className = p.enabled ? "watch-active" : "watch-completed";
dot.title = p.enabled ? "Enabled" : "Disabled";
dot.textContent = p.enabled ? "\u25CF active" : "\u25CB disabled";
colStatus.appendChild(dot);
row.appendChild(colStatus);
var colActions = document.createElement("span");
colActions.className = "admin-col admin-col-actions";
var editBtn = document.createElement("button");
editBtn.className = "admin-btn-action";
editBtn.textContent = "edit";
editBtn.setAttribute("data-edit-ppolicy", p.policy_id);
editBtn.setAttribute("aria-label", "Edit prompt " + p.name);
colActions.appendChild(editBtn);
var delBtn = document.createElement("button");
delBtn.className = "admin-btn-danger";
delBtn.textContent = "delete";
delBtn.setAttribute("data-delete-ppolicy", p.policy_id);
delBtn.setAttribute("data-ppolicy-name", p.name);
delBtn.setAttribute("aria-label", "Delete prompt " + p.name);
colActions.appendChild(delBtn);
row.appendChild(colActions);
el.appendChild(row);
}
el.querySelectorAll("[data-edit-ppolicy]").forEach(function (btn) {
btn.addEventListener("click", function () {
showEditPromptPolicyModal(this.getAttribute("data-edit-ppolicy"));
});
});
el.querySelectorAll("[data-delete-ppolicy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var pid = this.getAttribute("data-delete-ppolicy");
var pname = this.getAttribute("data-ppolicy-name");
showConfirmModal(
"Delete Prompt",
'Delete prompt "' + pname + '"?',
"Delete",
function () {
authFetch("/v1/api/admin/prompt-policies/" + pid, {
method: "DELETE",
})
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Prompt deleted");
loadPromptPolicies();
})
.catch(function () {
showToast("Failed to delete prompt");
});
},
);
});
});
}
function showCreatePromptPolicyModal() {
_cppTriggerEl = document.activeElement;
var ov = document.getElementById("create-ppolicy-overlay");
ov.style.display = "flex";
document.getElementById("cpp-name").value = "";
document.getElementById("cpp-gate").value = "";
document.getElementById("cpp-content").value = "";
document.getElementById("cpp-priority").value = "0";
document.getElementById("cpp-error").style.display = "none";
document.getElementById("cpp-name").focus();
_cppTrapHandler = _installTrap(
"create-ppolicy-overlay",
"create-ppolicy-box",
);
}
function hideCreatePromptPolicyModal() {
document.getElementById("create-ppolicy-overlay").style.display = "none";
_cppTrapHandler = _removeTrap(_cppTrapHandler);
if (_cppTriggerEl && _cppTriggerEl.focus) {
_cppTriggerEl.focus();
}
_cppTriggerEl = null;
}
function submitCreatePromptPolicy() {
var errEl = document.getElementById("cpp-error");
var name = document.getElementById("cpp-name").value.trim();
var content = document.getElementById("cpp-content").value.trim();
if (!name || !content) {
errEl.textContent = "Name and content are required";
errEl.style.display = "";
return;
}
errEl.style.display = "none";
var submitBtn = document.getElementById("cpp-submit");
submitBtn.disabled = true;
authFetch("/v1/api/admin/prompt-policies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
content: content,
tool_gate: document.getElementById("cpp-gate").value.trim(),
priority:
parseInt(document.getElementById("cpp-priority").value, 10) || 0,
enabled: true,
}),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
hideCreatePromptPolicyModal();
showToast("Prompt created");
loadPromptPolicies();
})
.catch(function (e) {
errEl.textContent = e.message;
errEl.style.display = "";
})
.finally(function () {
submitBtn.disabled = false;
});
}
function showEditPromptPolicyModal(policyId) {
_eppTriggerEl = document.activeElement;
var p = null;
for (var i = 0; i < _promptPolicies.length; i++) {
if (_promptPolicies[i].policy_id === policyId) {
p = _promptPolicies[i];
break;
}
}
if (!p) return;
document.getElementById("epp-id").value = p.policy_id;
document.getElementById("epp-name").value = p.name;
document.getElementById("epp-gate").value = p.tool_gate || "";
document.getElementById("epp-content").value = p.content || "";
document.getElementById("epp-priority").value = p.priority || 0;
document.getElementById("epp-enabled").checked = p.enabled;
document.getElementById("epp-error").style.display = "none";
var ov = document.getElementById("edit-ppolicy-overlay");
ov.style.display = "flex";
document.getElementById("epp-name").focus();
_eppTrapHandler = _installTrap("edit-ppolicy-overlay", "edit-ppolicy-box");
}
function hideEditPromptPolicyModal() {
document.getElementById("edit-ppolicy-overlay").style.display = "none";
_eppTrapHandler = _removeTrap(_eppTrapHandler);
if (_eppTriggerEl && _eppTriggerEl.focus) {
_eppTriggerEl.focus();
}
_eppTriggerEl = null;
}
function submitEditPromptPolicy() {
var errEl = document.getElementById("epp-error");
var policyId = document.getElementById("epp-id").value;
var name = document.getElementById("epp-name").value.trim();
var content = document.getElementById("epp-content").value.trim();
if (!name || !content) {
errEl.textContent = "Name and content are required";
errEl.style.display = "";
return;
}
errEl.style.display = "none";
var submitBtn = document.getElementById("epp-submit");
submitBtn.disabled = true;
authFetch("/v1/api/admin/prompt-policies/" + policyId, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
content: content,
tool_gate: document.getElementById("epp-gate").value.trim(),
priority:
parseInt(document.getElementById("epp-priority").value, 10) || 0,
enabled: document.getElementById("epp-enabled").checked,
}),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
hideEditPromptPolicyModal();
showToast("Prompt updated");
loadPromptPolicies();
})
.catch(function (e) {
errEl.textContent = e.message;
errEl.style.display = "";
})
.finally(function () {
submitBtn.disabled = false;
});
}
+61
View File
@@ -95,6 +95,7 @@
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
<button id="tab-roles" class="admin-nav" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
<button id="tab-prompt-policies" class="admin-nav" data-tab="prompt-policies" role="tab" aria-selected="false" aria-controls="admin-prompt-policies" tabindex="-1" onclick="switchAdminTab('prompt-policies')">Prompts</button>
</div>
<div class="admin-sidebar-group" data-group="extensions" role="group" aria-label="Extensions">
<div class="admin-sidebar-group-label" aria-hidden="true">Extensions</div>
@@ -259,6 +260,24 @@
</div>
</div>
<!-- Prompt Policies Tab -->
<div id="admin-prompt-policies" class="admin-panel" role="tabpanel" aria-labelledby="tab-prompt-policies" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">PROMPTS</span>
<button class="admin-action-btn" onclick="showCreatePromptPolicyModal()">+ Create prompt</button>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-pname">NAME</span>
<span class="admin-col admin-col-ppattern">TOOL GATE</span>
<span class="admin-col admin-col-ppriority">PRI</span>
<span class="admin-col admin-col-pstatus">STATUS</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-prompt-policies-table" role="list" aria-label="Prompt policies" aria-live="polite">
<div class="dashboard-empty">Loading prompt policies...</div>
</div>
</div>
<!-- Skills Tab -->
<div id="admin-skills" class="admin-panel" role="tabpanel" aria-labelledby="tab-skills" style="display:none">
<div class="admin-toolbar">
@@ -888,6 +907,48 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Create Prompt Policy Modal -->
<div id="create-ppolicy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-ppolicy-title">
<div id="create-ppolicy-box" class="admin-modal admin-modal-wide">
<h2 id="create-ppolicy-title">Create Prompt</h2>
<div id="cpp-error" role="alert" aria-live="assertive"></div>
<label for="cpp-name">Name <span class="label-hint">slug-style identifier</span></label>
<input id="cpp-name" type="text" placeholder="e.g. web_search, data_handling" autocomplete="off">
<label for="cpp-gate">Tool Gate <span class="label-hint">tool name or blank for unconditional</span></label>
<input id="cpp-gate" type="text" placeholder="e.g. web_search" autocomplete="off">
<label for="cpp-content">Content <span class="label-hint">markdown body for system message</span></label>
<textarea id="cpp-content" rows="10" placeholder="## Policy Name&#10;&#10;Behavioral guidance..." spellcheck="false"></textarea>
<label for="cpp-priority">Priority <span class="label-hint">higher = assembled later (closer to conversation)</span></label>
<input id="cpp-priority" type="number" value="0" min="0" max="9999">
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreatePromptPolicyModal()">Cancel</button>
<button id="cpp-submit" class="modal-submit" onclick="submitCreatePromptPolicy()">Create</button>
</div>
</div>
</div>
<!-- Edit Prompt Policy Modal -->
<div id="edit-ppolicy-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-ppolicy-title">
<div id="edit-ppolicy-box" class="admin-modal admin-modal-wide">
<h2 id="edit-ppolicy-title">Edit Prompt</h2>
<div id="epp-error" role="alert" aria-live="assertive"></div>
<input id="epp-id" type="hidden">
<label for="epp-name">Name</label>
<input id="epp-name" type="text" autocomplete="off">
<label for="epp-gate">Tool Gate</label>
<input id="epp-gate" type="text" autocomplete="off">
<label for="epp-content">Content</label>
<textarea id="epp-content" rows="10" spellcheck="false"></textarea>
<label for="epp-priority">Priority</label>
<input id="epp-priority" type="number" value="0" min="0" max="9999">
<label class="admin-checkbox"><input id="epp-enabled" type="checkbox" checked> Enabled</label>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditPromptPolicyModal()">Cancel</button>
<button id="epp-submit" class="modal-submit" onclick="submitEditPromptPolicy()">Save</button>
</div>
</div>
</div>
<!-- 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 admin-modal-skill">
+13 -2
View File
@@ -912,7 +912,7 @@
.admin-row {
display: grid;
padding: 8px 12px;
align-items: center;
align-items: start;
border-radius: var(--radius-sm);
transition: background 0.1s;
}
@@ -1395,6 +1395,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
#create-schedule-overlay, #edit-schedule-overlay, #schedule-runs-overlay,
#create-role-overlay, #edit-role-overlay, #user-roles-overlay,
#create-policy-overlay, #edit-policy-overlay,
#create-ppolicy-overlay, #edit-ppolicy-overlay,
#create-template-overlay, #edit-template-overlay,
#memory-detail-overlay,
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
@@ -1493,6 +1494,12 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
grid-template-columns: 1.2fr 1fr 70px 50px 80px 140px;
}
/* Prompt Policies grid: NAME | TOOL GATE | PRI | STATUS | ACTIONS */
#admin-prompt-policies .admin-colheaders,
#admin-prompt-policies .admin-row {
grid-template-columns: 1.2fr 1fr 50px 80px 140px;
}
/* Policy action badges */
.policy-badge {
display: inline-block;
@@ -1807,6 +1814,9 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
grid-template-columns: 1fr 70px 50px 100px;
}
.admin-col-pstatus, .admin-col-ppriority { display: none; }
#admin-prompt-policies .admin-colheaders, #admin-prompt-policies .admin-row {
grid-template-columns: 1fr 70px 100px;
}
#admin-skills .admin-colheaders, #admin-skills .admin-row {
grid-template-columns: 1fr 100px;
}
@@ -1858,7 +1868,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
grid-template-columns: 200px 1fr auto;
gap: 8px 16px;
padding: 8px 12px;
align-items: center;
align-items: start;
border-bottom: 1px solid var(--border);
}
.settings-row:hover { background: var(--row-alt, rgba(255,255,255,0.015)); }
@@ -1903,6 +1913,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
}
/* Bool toggle */
.settings-input .settings-toggle { margin-top: 2px; }
.settings-toggle {
position: relative;
display: inline-block;
+31 -130
View File
@@ -1,15 +1,12 @@
"""Bearer token authentication and authorization for turnstone HTTP servers.
Supports three token types:
Supports two token types:
1. **Config-file tokens** static tokens in ``config.toml`` or the
``TURNSTONE_AUTH_TOKEN`` env var. Validated in-memory via
``hmac.compare_digest``. Map to scopes via their role.
2. **API tokens** database-backed, prefixed ``ts_``, stored as SHA-256
1. **API tokens** database-backed, prefixed ``ts_``, stored as SHA-256
hashes. Exchanged for JWTs via ``/api/auth/login``.
3. **JWTs** short-lived session tokens issued after API token validation.
Validated locally via shared HMAC-SHA256 secret. Contain user_id and
scopes in claims.
2. **JWTs** short-lived session tokens issued after login or by
:class:`ServiceTokenManager`. Validated locally via shared HMAC-SHA256
secret. Contain user_id and scopes in claims.
Public paths (``/``, ``/static/*``, ``/shared/*``, ``/health``, ``/metrics``,
``/openapi.json``, ``/docs``, ``/api/auth/login``, ``/api/auth/logout``) are
@@ -19,7 +16,6 @@ always accessible without authentication.
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
@@ -28,7 +24,7 @@ import threading
import time
import urllib.parse
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -56,7 +52,7 @@ JWT_AUD_CONSOLE = "turnstone-console"
JWT_AUD_CHANNEL = "turnstone-channel"
_MIN_SECRET_LENGTH = 32 # 256 bits minimum for HMAC-SHA256
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve"})
VALID_SCOPES: frozenset[str] = frozenset({"read", "write", "approve", "service"})
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
USERNAME_MAX_LEN = 64
@@ -72,16 +68,12 @@ def is_valid_username(username: str) -> bool:
# Hierarchical: each scope implies all lower scopes.
# "service" is a superset that grants full access + bypasses RBAC permission checks.
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
"read": frozenset({"read"}),
"write": frozenset({"read", "write"}),
"approve": frozenset({"read", "write", "approve"}),
}
# Map old role names to scope sets.
_ROLE_TO_SCOPES: dict[str, frozenset[str]] = {
"read": frozenset({"read"}),
"full": frozenset({"read", "write", "approve"}),
"service": frozenset({"read", "write", "approve", "service"}),
}
# ---------------------------------------------------------------------------
@@ -106,7 +98,7 @@ def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
scopes.add("read")
return frozenset(scopes)
for perm in permissions:
if perm in VALID_SCOPES:
if perm in VALID_SCOPES and perm != "service":
scopes.update(SCOPE_HIERARCHY.get(perm, {perm}))
# Any admin.* permission requires access to admin endpoints → approve scope
if any(p.startswith("admin.") for p in permissions):
@@ -120,15 +112,14 @@ def require_permission(request: Request, permission: str) -> JSONResponse | None
"""Return a 403 JSONResponse if the user lacks *permission*, else None.
Call from admin handlers after the middleware scope check passes.
Config-file tokens (no user_id) are treated as full-access.
Service tokens (scope ``service``) bypass permission checks.
"""
from starlette.responses import JSONResponse
auth_result: AuthResult | None = getattr(getattr(request, "state", None), "auth_result", None)
if auth_result is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
# Config-file tokens (no user_id) are treated as full-access
if not auth_result.user_id:
if auth_result.has_scope("service"):
return None
if auth_result.has_permission(permission):
return None
@@ -200,9 +191,9 @@ def _strip_version_prefix(path: str) -> str:
class AuthResult:
"""Result of successful authentication."""
user_id: str # empty string for config-file tokens
user_id: str
scopes: frozenset[str]
token_source: str # "config", "jwt", "database"
token_source: str # "jwt", "database", "password", or service origin (e.g. "console", "cli")
permissions: frozenset[str] = frozenset()
def has_scope(self, scope: str) -> bool:
@@ -214,28 +205,6 @@ class AuthResult:
return permission in self.permissions
# ---------------------------------------------------------------------------
# AuthConfig (unchanged from before — static config-file tokens)
# ---------------------------------------------------------------------------
@dataclass
class AuthConfig:
"""Auth configuration loaded once at startup (not modified after creation)."""
enabled: bool = False
tokens: dict[str, str] = field(default_factory=dict) # token_value → role
def check(self, token: str | None) -> str | None:
"""Return the role for a valid config token, or *None*."""
if not token:
return None
for known_token, role in self.tokens.items():
if hmac.compare_digest(token, known_token):
return role
return None
# ---------------------------------------------------------------------------
# Token generation and hashing
# ---------------------------------------------------------------------------
@@ -303,7 +272,11 @@ def parse_scopes(scopes_str: str) -> frozenset[str]:
def load_jwt_secret() -> str:
"""Load JWT signing secret from env or config, or auto-generate."""
"""Load JWT signing secret from env or config.
Raises :class:`SystemExit` if no secret is configured. A JWT secret
is required for auth, inter-service communication, and session tokens.
"""
secret = os.environ.get("TURNSTONE_JWT_SECRET", "").strip()
if not secret:
from turnstone.core.config import load_config
@@ -312,18 +285,19 @@ def load_jwt_secret() -> str:
secret = str(auth_cfg.get("jwt_secret", "")).strip()
if not secret:
# Auto-generate an ephemeral secret
secret = secrets.token_hex(32)
log.warning(
"No JWT secret configured — using ephemeral secret (tokens will not survive restart)"
log.error(
"TURNSTONE_JWT_SECRET is required but not set. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return secret
raise SystemExit(1)
if len(secret) < _MIN_SECRET_LENGTH:
log.warning(
"JWT secret is shorter than %d characters — consider using a stronger secret",
log.error(
"JWT secret must be at least %d characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"',
_MIN_SECRET_LENGTH,
)
raise SystemExit(1)
return secret
@@ -397,62 +371,6 @@ def validate_jwt(token: str, secret: str, audience: str = "") -> AuthResult | No
)
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def load_auth_config() -> AuthConfig:
"""Build :class:`AuthConfig` from ``config.toml`` ``[auth]`` + env vars.
Auth is **enabled by default**. Set ``[auth] enabled = false`` or
``TURNSTONE_AUTH_ENABLED=0`` to disable.
Config format::
[auth]
enabled = false # opt out
[[auth.tokens]]
value = "tok_abc123"
role = "full"
Environment variables:
- ``TURNSTONE_AUTH_ENABLED=0`` disables auth
- ``TURNSTONE_AUTH_ENABLED=1`` enables auth (default)
- ``TURNSTONE_AUTH_TOKEN=<token>`` registers a single full-access token
"""
from turnstone.core.config import load_config
auth_cfg = load_config("auth")
enabled = bool(auth_cfg.get("enabled", True))
tokens: dict[str, str] = {}
# Tokens from config file (TOML array-of-tables)
for entry in auth_cfg.get("tokens", []):
value = entry.get("value", "") if isinstance(entry, dict) else ""
role = entry.get("role", "read") if isinstance(entry, dict) else ""
if value and role in ("read", "full"):
tokens[value] = role
# Environment variable overrides
env_enabled = os.environ.get("TURNSTONE_AUTH_ENABLED", "").strip().lower()
if env_enabled in ("1", "true", "yes"):
enabled = True
elif env_enabled in ("0", "false", "no"):
enabled = False
env_token = os.environ.get("TURNSTONE_AUTH_TOKEN", "").strip()
if env_token:
tokens[env_token] = "full"
if enabled and not tokens:
log.info("Auth enabled (no config tokens — use /api/auth/setup or turnstone-admin)")
return AuthConfig(enabled=enabled, tokens=tokens)
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
@@ -529,7 +447,6 @@ def _extract_proxied_path(normalized: str) -> str | None:
def check_request(
auth_config: AuthConfig,
method: str,
path: str,
auth_header: str | None,
@@ -539,20 +456,16 @@ def check_request(
jwt_audience: str = "",
storage: Any = None,
) -> tuple[bool, int, str, AuthResult | None]:
"""Validate a request against the auth config.
"""Validate a request.
Checks ``Authorization: Bearer <token>`` first, then falls back to the
``turnstone_auth`` cookie. Token types are auto-detected:
- Contains ``.`` JWT (validated with *jwt_secret*)
- Starts with ``ts_`` API token (looked up in *storage* by hash)
- Otherwise config-file token (hmac check)
Returns ``(allowed, status_code, message, auth_result)``.
"""
if not auth_config.enabled:
return True, 200, "", None
if is_public_path(path):
return True, 200, "", None
@@ -566,7 +479,7 @@ def check_request(
# Authenticate
result = _authenticate_token(
raw_token, auth_config, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
raw_token, jwt_secret=jwt_secret, jwt_audience=jwt_audience, storage=storage
)
if result is None:
return False, 401, "Unauthorized: missing or invalid token", None
@@ -581,7 +494,6 @@ def check_request(
def _authenticate_token(
token: str,
auth_config: AuthConfig,
*,
jwt_secret: str = "",
jwt_audience: str = "",
@@ -601,12 +513,6 @@ def _authenticate_token(
if token.startswith(TOKEN_PREFIX) and storage is not None:
return _authenticate_api_token(token, storage)
# 3. Config-file token (hmac comparison)
role = auth_config.check(token)
if role is not None:
scopes = _ROLE_TO_SCOPES.get(role, frozenset({"read"}))
return AuthResult(user_id="", scopes=scopes, token_source="config")
return None
@@ -852,7 +758,6 @@ class AuthMiddleware:
await self.app(scope, receive, send)
return
auth_config = request.app.state.auth_config
jwt_secret = getattr(request.app.state, "jwt_secret", "")
storage = getattr(request.app.state, "auth_storage", None)
method = request.method
@@ -860,7 +765,6 @@ class AuthMiddleware:
auth_header = request.headers.get("Authorization")
cookie_header = request.headers.get("Cookie")
allowed, status, msg, auth_result = check_request(
auth_config,
method,
path,
auth_header,
@@ -904,7 +808,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
except (ValueError, json.JSONDecodeError):
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
auth_config = request.app.state.auth_config
jwt_secret = getattr(request.app.state, "jwt_secret", "")
storage = getattr(request.app.state, "auth_storage", None)
login_limiter: LoginRateLimiter | None = getattr(request.app.state, "login_limiter", None)
@@ -955,7 +858,6 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
elif body.get("token"):
result = _authenticate_token(
body["token"],
auth_config,
jwt_secret=jwt_secret,
jwt_audience=audience,
storage=storage,
@@ -1011,7 +913,6 @@ async def handle_auth_status(request: Request) -> Response:
"""Shared ``GET /api/auth/status`` handler — login UI state detection."""
from starlette.responses import JSONResponse
auth_config = request.app.state.auth_config
storage = getattr(request.app.state, "auth_storage", None)
has_users = False
@@ -1027,9 +928,9 @@ async def handle_auth_status(request: Request) -> Response:
oidc_enabled = bool(oidc_config and oidc_config.enabled)
resp: dict[str, Any] = {
"auth_enabled": auth_config.enabled,
"auth_enabled": True,
"has_users": has_users,
"setup_required": auth_config.enabled and not has_users,
"setup_required": not has_users,
}
if oidc_enabled and oidc_config is not None:
resp["oidc_enabled"] = True
-1
View File
@@ -117,7 +117,6 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
"host": "host",
"port": "port",
"url": "console_url",
"poll_interval": "poll_interval",
"log_level": "log_level",
},
"auth": {
-1
View File
@@ -66,7 +66,6 @@ _EXPLICIT_SCRUB: frozenset[str] = frozenset(
"ANTHROPIC_API_KEY",
"TAVILY_API_KEY",
"TURNSTONE_JWT_SECRET",
"TURNSTONE_AUTH_TOKEN",
"TURNSTONE_DISCORD_TOKEN",
"TURNSTONE_GITHUB_TOKEN",
"TURNSTONE_OIDC_CLIENT_SECRET",
+23 -1
View File
@@ -43,6 +43,7 @@ class BackendHealthMonitor:
provider: str = "openai",
initial_model: str = "",
on_model_changed: Callable[[str, int | None], None] | None = None,
on_state_changed: Callable[[str], None] | None = None,
) -> None:
self._client = client
self._probe_interval = probe_interval
@@ -54,6 +55,7 @@ class BackendHealthMonitor:
self._provider = provider
self._last_detected_model = initial_model
self._on_model_changed = on_model_changed
self._on_state_changed = on_state_changed
self._lock = threading.Lock()
self._state = CircuitState.CLOSED
@@ -82,8 +84,17 @@ class BackendHealthMonitor:
# Passive tracking (called by request path)
# ------------------------------------------------------------------
def _fire_state_callback(self, state_val: str | None) -> None:
"""Fire on_state_changed callback outside the lock."""
if state_val is not None and self._on_state_changed is not None:
try:
self._on_state_changed(state_val)
except Exception:
log.debug("on_state_changed callback error", exc_info=True)
def record_success(self) -> None:
"""Called on successful LLM call. Resets failure count, closes circuit."""
state_to_dispatch: str | None = None
with self._lock:
self._consecutive_failures = 0
if self._state != CircuitState.CLOSED:
@@ -93,9 +104,12 @@ class BackendHealthMonitor:
self._last_state_change = time.monotonic()
log.info("Circuit breaker CLOSED (was %s): backend recovered", prev.value)
self._update_metrics()
state_to_dispatch = self._state.value
self._fire_state_callback(state_to_dispatch)
def record_failure(self) -> None:
"""Called on LLM call failure. May open circuit."""
state_to_dispatch: str | None = None
with self._lock:
self._consecutive_failures += 1
if self._state == CircuitState.HALF_OPEN:
@@ -105,6 +119,7 @@ class BackendHealthMonitor:
self._last_state_change = time.monotonic()
log.warning("Circuit breaker OPEN: probe failed in HALF_OPEN")
self._update_metrics()
state_to_dispatch = self._state.value
elif (
self._state == CircuitState.CLOSED
and self._consecutive_failures >= self._failure_threshold
@@ -116,6 +131,8 @@ class BackendHealthMonitor:
self._consecutive_failures,
)
self._update_metrics()
state_to_dispatch = self._state.value
self._fire_state_callback(state_to_dispatch)
# ------------------------------------------------------------------
# Query helpers
@@ -246,7 +263,12 @@ class BackendHealthMonitor:
# ------------------------------------------------------------------
def _update_metrics(self) -> None:
"""Push state to metrics collector. Called with *self._lock* held."""
"""Push circuit-breaker state to metrics collector.
Called with *self._lock* held. State-change callbacks are dispatched
by the callers (``record_success`` / ``record_failure``) after the
lock is released, not by this method.
"""
from turnstone.core.metrics import metrics
metrics.set_backend_status(self._state == CircuitState.CLOSED)
+2 -2
View File
@@ -69,7 +69,7 @@ def _merge_consecutive(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
_WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
# Tool search: server-side BM25 tool discovery for deferred tools
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25_20251119"
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25"
# -- model capabilities -------------------------------------------------------
@@ -205,7 +205,7 @@ class AnthropicProvider:
result.append({**tool, "defer_loading": True})
else:
result.append(tool)
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": "tool_search"})
result.append({"type": _TOOL_SEARCH_TOOL_TYPE, "name": _TOOL_SEARCH_TOOL_TYPE})
return result
# -- shared param logic --------------------------------------------------
+185 -61
View File
@@ -19,6 +19,7 @@ import mimetypes
import os
import queue
import re
import shutil
import signal
import subprocess
import tempfile
@@ -26,6 +27,7 @@ import textwrap
import threading
import time
import uuid
from datetime import UTC, datetime
from html import escape as _html_escape
from typing import TYPE_CHECKING, Any, Protocol
@@ -87,6 +89,7 @@ from turnstone.core.tools import (
merge_mcp_tools,
)
from turnstone.core.web import check_ssrf, strip_html
from turnstone.prompts import ClientType, SessionContext, compose_system_message
from turnstone.ui.colors import DIM, GRAY, GREEN, RED, RESET, YELLOW, bold, cyan, dim
log = get_logger(__name__)
@@ -159,6 +162,13 @@ _IMAGE_SIZE_CAP: int = 4 * 1024 * 1024
# Upper bound on total skill content injected into system messages
_MAX_SKILL_CONTENT: int = 32768
# Matches resource paths referenced in skill content (scripts/foo.py, etc.)
_RESOURCE_PATH_RE = re.compile(
r"(?<![/\w-])(?:scripts|references|assets)/[\w./-]+\."
r"(?:json|yaml|yml|toml|cfg|ini|py|sh|js|ts|md|txt)"
r"(?=[\s)\]}'\"`,;:\x60]|$)"
)
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
@@ -290,6 +300,8 @@ class ChatSession:
memory_config: MemoryConfig | None = None,
config_store: ConfigStore | None = None,
web_search_backend: str = "",
client_type: ClientType = ClientType.CLI,
username: str = "",
):
self.client = client
self.model = model
@@ -325,6 +337,8 @@ class ChatSession:
self.auto_approve = False
self._node_id = node_id
self._user_id = user_id
self._username = username
self._client_type = client_type
self._config_store = config_store
self._memory_config = memory_config or MemoryConfig()
self._ws_id = ws_id or uuid.uuid4().hex
@@ -411,6 +425,7 @@ class ChatSession:
self._skill_name: str | None = skill
self._skill_content: str | None = None
self._skill_resources: dict[str, str] = {}
self._skill_resources_dir: str | None = None
self._load_skills()
self._init_system_messages()
self._save_config()
@@ -577,6 +592,8 @@ class ChatSession:
else:
self._skill_content = None
self._skill_resources = {}
self._materialize_skill_resources()
self._validate_skill_resources()
def set_skill(self, name: str | None) -> None:
"""Set or clear the active skill."""
@@ -607,6 +624,81 @@ class ChatSession:
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
return {}
def _cleanup_skill_resources(self) -> None:
"""Remove materialized skill resources from disk."""
d = self._skill_resources_dir
if d is not None:
shutil.rmtree(d, ignore_errors=True)
self._skill_resources_dir = None
def _materialize_skill_resources(self) -> None:
"""Write skill resources to a temp directory for subprocess access."""
self._cleanup_skill_resources()
if not self._skill_resources:
return
base = tempfile.mkdtemp(prefix=f"skill-{self._ws_id[:8]}-")
written = 0
for rel_path, content in self._skill_resources.items():
normed = os.path.normpath(rel_path)
if not normed or normed == "." or normed.startswith(("..", "/")):
log.warning("skill_resources.bad_path", path=rel_path)
continue
if ".." in normed.split(os.sep):
log.warning("skill_resources.bad_path", path=rel_path)
continue
full = os.path.join(base, normed)
if not os.path.realpath(full).startswith(os.path.realpath(base)):
log.warning("skill_resources.path_escape", path=rel_path)
continue
try:
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
if normed.startswith("scripts/"):
os.chmod(full, 0o755)
written += 1
except Exception:
log.warning("skill_resources.write_failed", path=rel_path, exc_info=True)
if written == 0:
shutil.rmtree(base, ignore_errors=True)
return
self._skill_resources_dir = base
log.info(
"skill_resources.materialized",
dir=base,
count=written,
)
def _skill_resource_env(self) -> dict[str, str]:
"""Return extra env vars for bash when skill resources are materialized."""
if not self._skill_resources_dir:
return {}
env: dict[str, str] = {"SKILL_RESOURCES_DIR": self._skill_resources_dir}
scripts_dir = os.path.join(self._skill_resources_dir, "scripts")
if os.path.isdir(scripts_dir):
current_path = os.environ.get("PATH")
if current_path:
env["PATH"] = scripts_dir + os.pathsep + current_path
else:
env["PATH"] = scripts_dir
return env
def _validate_skill_resources(self) -> None:
"""Warn if skill content references resource paths not in skill_resources."""
if not self._skill_content or not self._skill_name:
return
referenced = {os.path.normpath(p) for p in _RESOURCE_PATH_RE.findall(self._skill_content)}
if not referenced:
return
available = {os.path.normpath(p) for p in self._skill_resources}
missing = sorted(referenced - available)
if missing:
log.warning("skill_resources.missing", skill=self._skill_name, paths=missing)
self.ui.on_info(
f"Skill '{self._skill_name}' references {len(missing)} resource(s) "
f"not bundled: {', '.join(missing)}"
)
# -- MCP tool refresh ----------------------------------------------------
def _on_mcp_tools_changed(self) -> None:
@@ -741,6 +833,7 @@ class ChatSession:
self._mcp_prompt_cb = None
if self._watch_runner:
self._watch_runner.remove_dispatch_fn(self._ws_id)
self._cleanup_skill_resources()
def _handle_mcp_refresh(self, arg: str) -> None:
"""Handle ``/mcp refresh [server]``."""
@@ -887,6 +980,13 @@ class ChatSession:
self._msg_tokens = [
max(1, int(self._msg_char_count(m) / self._chars_per_token)) for m in self.messages
]
log.info(
"Resuming ws=%s: %d messages, provider=%s, model=%s",
ws_id,
len(messages),
type(self._provider).__name__,
self.model,
)
# Restore persisted config
config = load_workstream_config(ws_id)
if config:
@@ -904,11 +1004,24 @@ class ChatSession:
self.context_window = cfg.context_window
if not self._manual_tool_truncation:
self.tool_truncation = int(cfg.context_window * self._chars_per_token * 0.5)
log.info(
"Resume: resolved alias=%s → provider=%s, model=%s, ctx=%d",
saved_alias,
type(self._provider).__name__,
model_name,
cfg.context_window,
)
elif saved_model and saved_model != self.model:
# No alias or alias no longer in registry — at least set the model name
self.model = saved_model
self._model_alias = None
self._cached_capabilities = None
log.warning(
"Resume: alias %r not in registry, keeping default provider=%s for model=%s",
saved_alias,
type(self._provider).__name__,
saved_model,
)
if "temperature" in config:
self.temperature = float(config["temperature"])
if "reasoning_effort" in config:
@@ -988,64 +1101,30 @@ class ChatSession:
"Never condescend to the form.",
]
else:
dev_parts = [
"You are a resident engineer on a small, focused infrastructure team. "
"Your workspace is an instrumented workbench — a terminal with tools for "
"reading, writing, searching, and executing code. You've been here a while. "
"You know the codebase. You know the tools. You know their limits.\n\n"
"Your team trusts you with real work: investigating bugs, implementing features, "
"reviewing security, writing code that ships. You have access to the project's "
"files, git history, and a running database. You don't have access to everything "
"— some tools require approval, some paths are restricted, and that's by design. "
"You work within those boundaries.\n\n"
"You think before you act. You read before you edit. You verify before you commit. "
"When something breaks, you diagnose before you retry. When you're uncertain, you "
"say so. When a request is ambiguous, you make a reasonable call and note what you "
"assumed — you don't stall asking for permission on every judgment call.\n\n"
"When you disagree with a direction, you push back with reasoning — then defer to "
"the team's call.\n\n"
"You are not performing a demo. There is no audience. The code you write will run. "
"The files you edit are real. The commits you make go to a shared repository. "
"Act accordingly.\n\n"
"TOOL PATTERNS:\n\n"
"Modify existing file → read_file then edit_file:\n"
" read_file(path='config.py') → "
"edit_file(path='config.py')\n\n"
"Modify multiple files → read_file then edit_file each:\n"
" read_file(path='a.py') → edit_file(path='a.py') → "
"read_file(path='b.py') → edit_file(path='b.py')\n\n"
"Create new file → write_file (generate reasonable "
"content even if the request is vague):\n"
" write_file(path='hello.py', content='...')\n"
" write_file(path='README.md', "
"content='# Project\\nDescription.')\n\n"
"Create a file then run it → write_file then bash:\n"
" write_file(path='fib.py', content='...') → "
"bash(command='python fib.py')\n\n"
"Find something across files → search:\n"
" search(query='test_')\n\n"
"Find and modify → search then read_file then edit_file:\n"
" search(query='MAX_RETRIES') → "
"read_file(path='found.py') → "
"edit_file(path='found.py')\n\n"
"Plan, design, or architect something → "
"explore codebase then plan_agent:\n"
" bash(command='ls') → read_file(path='app.py') → "
"plan_agent(goal='add caching to the application')\n"
" plan_agent(goal='refactor database layer "
"from monolith to service')\n"
" plan_agent(goal='restructure auth module')\n\n"
"Run a command, git, or tests → bash:\n"
" bash(command='git log -5')\n"
" bash(command='pytest')\n\n"
"Retrieve a URL → web_fetch:\n"
" web_fetch(url='https://example.com')\n\n"
"Search the web for information → web_search:\n"
" web_search(query='current population of Tokyo')\n\n"
"Look up command flags or documentation → man:\n"
" man(page='tar')\n"
" man(page='grep')",
]
# Compose system message from modular components
tool_names = frozenset(t["function"]["name"] for t in self._tools if "function" in t)
# Load DB prompt policies if storage is available
db_policies: list[dict[str, Any]] = []
try:
storage = get_storage()
if storage:
db_policies = storage.list_prompt_policies()
except Exception:
pass
now = datetime.now().astimezone()
ctx = SessionContext(
current_datetime=now.strftime("%Y-%m-%dT%H:%M"),
timezone=now.tzname() or "UTC",
username=self._username or self._user_id or "unknown",
)
composed = compose_system_message(
client_type=self._client_type,
context=ctx,
available_tools=tool_names,
policies=["web_search"],
db_policies=db_policies,
)
dev_parts = [composed]
# Tool search hint (client-side mode only — native mode needs no hint)
if self._tool_search:
caps = self._get_capabilities()
@@ -1119,6 +1198,12 @@ class ChatSession:
"Resource content omitted (total exceeds 8KB). "
"Resource files are listed above by path and size."
)
if self._skill_resources_dir:
lines.append(
"\nResource files are materialized on disk. "
"Scripts in scripts/ are on PATH and can be run by name. "
"All files are under $SKILL_RESOURCES_DIR."
)
lines.append("</skill-resources>")
dev_parts.append("\n".join(lines))
# Skill catalog: disclose search-activated skills so the model
@@ -1303,6 +1388,21 @@ class ChatSession:
) -> Iterator[StreamChunk]:
"""Attempt a streaming API call with retries on transient errors."""
prov = provider or self._provider
raw_url = str(getattr(client, "base_url", getattr(client, "_base_url", "?")))
safe_url = raw_url.split("?")[0] # strip query params (may contain keys)
msg_count = len(msgs)
role_counts: dict[str, int] = {}
for m in msgs:
r = m.get("role", "?")
role_counts[r] = role_counts.get(r, 0) + 1
log.debug(
"API call: provider=%s model=%s base_url=%s msgs=%d roles=%s",
type(prov).__name__,
model,
safe_url,
msg_count,
role_counts,
)
last_err: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
self._check_cancelled()
@@ -1322,6 +1422,29 @@ class ChatSession:
)
except Exception as e:
ename = type(e).__name__
cause_name = (
type(e.__cause__).__name__
if e.__cause__
else (type(e.__context__).__name__ if e.__context__ else "None")
)
log.warning(
"API error (attempt %d/%d): %s (cause=%s) "
"provider=%s model=%s base_url=%s msgs=%d",
attempt + 1,
self._MAX_RETRIES + 1,
ename,
cause_name,
type(prov).__name__,
model,
safe_url,
msg_count,
)
log.debug(
"API error details (attempt %d/%d)",
attempt + 1,
self._MAX_RETRIES + 1,
exc_info=True,
)
if ename not in prov.retryable_error_names or attempt == self._MAX_RETRIES:
raise
last_err = e
@@ -2212,7 +2335,8 @@ class ChatSession:
"""Emit status info via the UI."""
if not self._last_usage:
return
self.ui.on_status(self._last_usage, self.context_window, self.reasoning_effort)
usage: dict[str, Any] = {**self._last_usage, "model": self.model}
self.ui.on_status(usage, self.context_window, self.reasoning_effort)
# -- Conversation compaction ------------------------------------------------
@@ -4366,7 +4490,7 @@ class ChatSession:
stderr=subprocess.PIPE,
text=True,
start_new_session=True,
env=scrubbed_env(),
env=scrubbed_env(extra=self._skill_resource_env()),
)
with self._procs_lock:
self._active_procs.add(proc)
@@ -5725,7 +5849,7 @@ class ChatSession:
}
def _exec_watch(self, item: dict[str, Any]) -> tuple[str, str]:
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
call_id = item["call_id"]
action = item["action"]
+73
View File
@@ -45,6 +45,9 @@ from turnstone.core.storage._schema import (
workstream_overrides,
workstreams,
)
from turnstone.core.storage._schema import (
prompt_policies as prompt_policies_t,
)
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
@@ -57,6 +60,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
POLICY_MUTABLE as _POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
PROMPT_POLICY_MUTABLE as _PROMPT_POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
@@ -3087,6 +3093,73 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount
# -- Prompt policies -------------------------------------------------------
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(prompt_policies_t).order_by(prompt_policies_t.c.priority)
if org_id:
q = q.where(prompt_policies_t.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(prompt_policies_t).where(
prompt_policies_t.c.policy_id == policy["policy_id"]
)
).fetchone()
if existing:
fields = {k: v for k, v in policy.items() if k in _PROMPT_POLICY_MUTABLE}
fields["updated"] = now
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
conn.execute(
sa.update(prompt_policies_t)
.where(prompt_policies_t.c.policy_id == policy["policy_id"])
.values(**fields)
)
else:
conn.execute(
sa.insert(prompt_policies_t),
{
"policy_id": policy["policy_id"],
"name": policy["name"],
"content": policy["content"],
"tool_gate": policy.get("tool_gate", ""),
"priority": policy.get("priority", 0),
"enabled": 1 if policy.get("enabled", True) else 0,
"org_id": policy.get("org_id", ""),
"created_by": policy.get("created_by", ""),
"created": now,
"updated": now,
},
)
conn.commit()
def delete_prompt_policy(self, policy_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- TLS / ACME ------------------------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
+18
View File
@@ -1048,6 +1048,24 @@ class StorageBackend(Protocol):
"""Delete a model definition. Returns True if existed."""
...
# -- Prompt policies -------------------------------------------------------
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
"""Return all prompt policies ordered by priority."""
...
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
"""Return prompt policy dict or None."""
...
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
"""Create or update a prompt policy."""
...
def delete_prompt_policy(self, policy_id: str) -> bool:
"""Delete a prompt policy. Returns True if existed."""
...
# -- TLS / ACME (lacme Store) ----------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
+19
View File
@@ -575,6 +575,25 @@ model_definitions = sa.Table(
sa.Index("idx_model_definitions_enabled", model_definitions.c.enabled)
# ---------------------------------------------------------------------------
# Prompt policies — system message behavioral rules (admin-managed)
# ---------------------------------------------------------------------------
prompt_policies = sa.Table(
"prompt_policies",
metadata,
sa.Column("policy_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("content", sa.Text, nullable=False),
sa.Column("tool_gate", sa.Text, nullable=False, server_default=""),
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
# ---------------------------------------------------------------------------
# OIDC identity tables
# ---------------------------------------------------------------------------
+73
View File
@@ -45,6 +45,9 @@ from turnstone.core.storage._schema import (
workstream_overrides,
workstreams,
)
from turnstone.core.storage._schema import (
prompt_policies as prompt_policies_t,
)
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
@@ -57,6 +60,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
POLICY_MUTABLE as _POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
PROMPT_POLICY_MUTABLE as _PROMPT_POLICY_MUTABLE,
)
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
@@ -3152,6 +3158,73 @@ class SQLiteBackend:
conn.commit()
return result.rowcount
# -- Prompt policies -------------------------------------------------------
def list_prompt_policies(self, org_id: str = "") -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(prompt_policies_t).order_by(prompt_policies_t.c.priority)
if org_id:
q = q.where(prompt_policies_t.c.org_id == org_id)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def get_prompt_policy(self, policy_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def upsert_prompt_policy(self, policy: dict[str, Any]) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(prompt_policies_t).where(
prompt_policies_t.c.policy_id == policy["policy_id"]
)
).fetchone()
if existing:
fields = {k: v for k, v in policy.items() if k in _PROMPT_POLICY_MUTABLE}
fields["updated"] = now
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
conn.execute(
sa.update(prompt_policies_t)
.where(prompt_policies_t.c.policy_id == policy["policy_id"])
.values(**fields)
)
else:
conn.execute(
sa.insert(prompt_policies_t),
{
"policy_id": policy["policy_id"],
"name": policy["name"],
"content": policy["content"],
"tool_gate": policy.get("tool_gate", ""),
"priority": policy.get("priority", 0),
"enabled": 1 if policy.get("enabled", True) else 0,
"org_id": policy.get("org_id", ""),
"created_by": policy.get("created_by", ""),
"created": now,
"updated": now,
},
)
conn.commit()
def delete_prompt_policy(self, policy_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(prompt_policies_t).where(prompt_policies_t.c.policy_id == policy_id)
)
conn.commit()
return result.rowcount > 0
# -- TLS / ACME ------------------------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
+1
View File
@@ -108,6 +108,7 @@ MODEL_DEFINITION_MUTABLE = frozenset(
"enabled",
}
)
PROMPT_POLICY_MUTABLE = frozenset({"name", "content", "tool_gate", "priority", "enabled"})
VERDICT_MUTABLE = frozenset(
{
"user_decision",
@@ -0,0 +1,34 @@
"""Create prompt_policies table for system message composition.
Revision ID: 031
Revises: 030
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from alembic import op
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"prompt_policies",
sa.Column("policy_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False, unique=True),
sa.Column("content", sa.Text, nullable=False),
sa.Column("tool_gate", sa.Text, nullable=False, server_default=""),
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("org_id", sa.Text, nullable=False, server_default=""),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
def downgrade() -> None:
op.drop_table("prompt_policies")
+7 -2
View File
@@ -12,7 +12,7 @@ import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
ws_id: str | None = ...,
*,
skill: str | None = ...,
client_type: str = ...,
) -> ChatSession: ...
@@ -136,6 +137,7 @@ class WorkstreamManager:
skill_id: str = "",
skill_version: int = 0,
ws_id: str = "",
client_type: str = "",
) -> Workstream:
"""Create a new workstream. Returns the new ws.
@@ -177,7 +179,10 @@ class WorkstreamManager:
ws = Workstream(id=ws_id, name=name) if ws_id else Workstream(name=name)
if ui_factory:
ws.ui = ui_factory(ws.id)
ws.session = self._session_factory(ws.ui, model, ws.id, skill=skill)
factory_kwargs: dict[str, Any] = {"skill": skill}
if client_type:
factory_kwargs["client_type"] = client_type
ws.session = self._session_factory(ws.ui, model, ws.id, **factory_kwargs)
# Authoritative insert under lock with re-check (another thread may
# have filled capacity while we were unlocked).
+163
View File
@@ -0,0 +1,163 @@
"""System message composition harness.
Assembles modular system messages from BASE (persona), ENV (client surface),
CONTEXT (session variables), TOOLS (usage patterns), and POLICIES (behavioral
rules). Replaces the monolithic persona+tools section of
``ChatSession._init_system_messages()``.
"""
from __future__ import annotations
import dataclasses
import enum
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
_PROMPTS_DIR = Path(__file__).resolve().parent # turnstone/prompts/
# Files are read once at import time — they're static markdown.
# This matches the pattern in tools.py where JSON schemas are loaded once.
_FILE_CACHE: dict[Path, str] = {}
def _load(relpath: str) -> str:
"""Load and cache a prompt module file."""
path = _PROMPTS_DIR / relpath
if path not in _FILE_CACHE:
_FILE_CACHE[path] = path.read_text()
return _FILE_CACHE[path]
class ClientType(enum.StrEnum):
WEB = "web"
CLI = "cli"
CHAT = "chat"
@dataclasses.dataclass
class SessionContext:
current_datetime: str # ISO 8601, required
timezone: str # system tz abbreviation, required
username: str # users.username, required
# File-based policy-to-tool gating (defaults).
# DB policies carry their own tool_gate field.
POLICY_TOOL_GATES: dict[str, str] = {
"web_search": "web_search",
}
_ENV_MAP: dict[ClientType, str] = {
ClientType.WEB: "env/web.md",
ClientType.CLI: "env/cli.md",
ClientType.CHAT: "env/chat.md",
}
def _build_context(ctx: SessionContext) -> str:
"""Build the CONTEXT module from session variables."""
return (
"## Session Context\n"
"\n"
f"- **Current date/time:** {ctx.current_datetime} ({ctx.timezone})\n"
f"- **User:** {ctx.username}"
)
def _validate_context(ctx: SessionContext) -> None:
"""Validate required fields and format constraints."""
if not ctx.current_datetime:
raise ValueError("current_datetime is required")
if not ctx.timezone:
raise ValueError("timezone is required")
if not ctx.username:
raise ValueError("username is required")
# Validate ISO 8601
try:
datetime.fromisoformat(ctx.current_datetime)
except ValueError as exc:
raise ValueError(f"current_datetime is not valid ISO 8601: {ctx.current_datetime}") from exc
def compose_system_message(
client_type: ClientType,
context: SessionContext,
available_tools: frozenset[str],
policies: list[str] | None = None,
db_policies: list[dict[str, Any]] | None = None,
) -> str:
"""Compose a system message from modular components.
Parameters
----------
client_type:
Target rendering surface (web, cli, chat).
context:
Per-session variables (datetime, timezone, username).
available_tools:
Set of available tool names (used for policy gating).
policies:
Explicit file-based policy names to include (e.g. ``["web_search"]``).
db_policies:
Database-backed policies from ``storage.list_prompt_policies()``.
Returns
-------
str
The fully assembled system message, modules separated by double newlines.
"""
parts: list[str] = []
# 1. BASE — always included
parts.append(_load("base.md"))
# 2. ENV — exactly one, selected by client type
if client_type not in _ENV_MAP:
raise ValueError(f"Unknown client_type: {client_type!r}")
parts.append(_load(_ENV_MAP[client_type]))
# 3. CONTEXT — built programmatically (no template engine)
_validate_context(context)
parts.append(_build_context(context))
# 4. TOOLS — if any tools are available
if available_tools:
parts.append(_load("tools.md"))
# 5. POLICIES — resolve from DB first, fall back to files
# DB policies indexed by name for O(1) override lookup.
db_by_name: dict[str, dict[str, Any]] = {}
if db_policies:
db_by_name = {p["name"]: p for p in db_policies if p.get("enabled", True)}
for policy_name in policies or []:
db_row = db_by_name.pop(policy_name, None)
if db_row:
# DB override — use its content and tool_gate
gate = db_row.get("tool_gate", "")
if gate and gate not in available_tools:
log.debug("Skipping DB policy %r: requires tool %r", policy_name, gate)
continue
parts.append(db_row["content"])
else:
# File-based fallback
path = _PROMPTS_DIR / "policies" / f"{policy_name}.md"
if not path.exists():
raise FileNotFoundError(f"Policy module not found: {path}")
gate = POLICY_TOOL_GATES.get(policy_name, "")
if gate and gate not in available_tools:
log.debug("Skipping file policy %r: requires tool %r", policy_name, gate)
continue
parts.append(_load(f"policies/{policy_name}.md"))
# DB-only policies (not in the explicit list) — sorted by priority
for db_row in sorted(db_by_name.values(), key=lambda p: p.get("priority", 0)):
gate = db_row.get("tool_gate", "")
if gate and gate not in available_tools:
continue
parts.append(db_row["content"])
return "\n\n".join(parts)
+9
View File
@@ -0,0 +1,9 @@
You are a resident engineer on a small, focused infrastructure team. You've been here a while. You know the codebase. You know the tools. You know their limits.
Your team trusts you with real work: investigating bugs, implementing features, reviewing security, writing code that ships. You have access to the project's files, git history, and a running database. You don't have access to everything — some tools require approval, some paths are restricted, and that's by design. You work within those boundaries.
You think before you act. You read before you edit. You verify before you commit. When something breaks, you diagnose before you retry. When you're uncertain, you say so. When a request is ambiguous, you make a reasonable call and note what you assumed — you don't stall asking for permission on every judgment call.
When you disagree with a direction, you push back with reasoning — then defer to the team's call.
You are not performing a demo. There is no audience. The code you write will run. The files you edit are real. The commits you make go to a shared repository. Act accordingly.
+32
View File
@@ -0,0 +1,32 @@
## Output Environment
Your responses are delivered in a third-party chat platform (Slack, Discord, or Microsoft Teams). These platforms have constrained and inconsistent markdown support. Optimize for maximum portability and readability across all of them.
**Available rendering:**
- **Bold** (`**text**`) — Supported everywhere.
- **Italic** (`*text*`) — Supported everywhere (Slack also accepts `_text_`).
- **Inline code** (`` `text` ``) — Supported everywhere.
- **Code blocks** (triple backticks) — Supported everywhere, but language-specific syntax highlighting is inconsistent. Include language tags anyway for clients that support them.
- **Bullet lists** — Supported everywhere. Use `-` syntax.
- **Links**`[text](url)` works in Slack and Discord. Teams may render inconsistently. Use bare URLs when maximum portability matters.
**Not reliably available:**
- **Tables** — Slack and Discord do not render markdown tables. They display as broken pipe characters. Do not use them. Use aligned code blocks or bullet lists for structured data instead.
- **Headings** (`#`, `##`) — Slack does not support them (renders as literal `#`). Use **bold text** on its own line as a heading substitute.
- **Mermaid / KaTeX** — Not available. Do not use them.
- **Blockquotes** (`>`) — Supported in Slack and Discord, not reliably in Teams. Use sparingly.
- **Nested lists** — Inconsistent. Avoid nesting deeper than one level.
**Formatting principles:**
- Keep responses concise. Chat platforms favor short, scannable messages over long-form prose.
- Use emoji sparingly for visual anchoring: ✅ ❌ ⚠️ 🔍 are useful; decorative emoji is noise.
- For structured comparisons that would normally be a table, use a code block:
```
Model A: 95.2% accuracy, 1.2s latency
Model B: 91.8% accuracy, 0.4s latency
```
- Break long responses into logical chunks. A response that requires scrolling in a chat window is too long — consider splitting across messages or summarizing with an offer to elaborate.
- When referencing files, commands, or code, always use inline code formatting for scannability.
- Math expressions should use plain programming notation: `(a * b) / c`, not LaTeX.
+20
View File
@@ -0,0 +1,20 @@
## Output Environment
Your workspace is a terminal. Your responses are rendered as plain text in a monospace font with limited formatting support. Design your output for readability in this context.
**Available rendering:**
- **Code blocks** — Rendered in monospace with basic syntax highlighting. Language tags are still useful (```python, etc.) but rendering quality varies by terminal emulator.
- **Basic markdown** — Bold (**text**) and inline code (`text`) may render depending on the client. Headings (#) render as plain text with emphasis. Tables render as-is in monospace (pipe-aligned tables work well).
- **No diagram rendering** — Mermaid, KaTeX, and other embedded renderers are not available. Do not use them.
**Formatting principles:**
- Use indentation, whitespace, and ASCII structure for clarity.
- For flows and architectures, use simple text-based representations:
```
Input → Processing → Output
```
or indented tree structures, not Mermaid blocks.
- For math, write expressions inline using programming notation: `(a * b) / c`, `sum(x_i for i in 1..n)`, `sqrt(n)`. Do not use LaTeX/KaTeX syntax.
- Keep line lengths reasonable (~80-100 chars) for terminal readability.
- Tables work well — keep them pipe-aligned and concise.
+17
View File
@@ -0,0 +1,17 @@
## Output Environment
Your responses are rendered in a rich web client with full markdown support. Use the available rendering capabilities to communicate clearly — prefer structured visuals over walls of text when they aid understanding.
**Available rendering:**
- **Code blocks** — Syntax-highlighted via highlight.js. Always specify the language tag (```python, ```sql, ```yaml, etc.) for proper highlighting.
- **Diagrams** — Mermaid.js is supported via ```mermaid code blocks. Use flowcharts, sequence diagrams, state diagrams, ER diagrams, and Gantt charts when explaining flows, architectures, or processes. Prefer a diagram over a verbal description of a system or sequence.
- **Math** — KaTeX is supported for both inline (`$...$`) and display (`$$...$$`) notation. Use proper mathematical typesetting when discussing formulas, equations, or formal notation rather than ASCII approximations.
- **Standard markdown** — Tables, headings, bold, italic, lists, blockquotes, horizontal rules, footnotes, and definition lists all render correctly. Use tables for structured comparisons. Use headings to organize long responses.
- **GFM callouts**`> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]` render as styled alert boxes. Use them for important caveats or warnings.
**Formatting principles:**
- Lead with the answer, then support with visuals — don't bury conclusions after a diagram.
- Mermaid diagrams should be self-contained and labeled clearly; the reader may not have surrounding context if they screenshot it.
- Use KaTeX for any expression that would be awkward in plain text (fractions, subscripts, summations, Greek letters, etc.).
- Don't use rich formatting gratuitously — a one-line answer doesn't need a flowchart.
+26
View File
@@ -0,0 +1,26 @@
## Web Search Policy
You have tools for reading files, searching the codebase, and running commands.
Use them first. Web search is for information that doesn't exist in the local
workspace.
**Use local tools, not web search, for:**
- Anything in the codebase — file contents, function signatures, config values,
test results, git history, dependency versions (`read_file`, `search`, `bash`)
- Language syntax, standard library behavior, well-established patterns —
your training covers this
- Anything the user can answer faster than a search round-trip — ask them
**Use web search for:**
- Package versions, changelogs, or deprecation notices newer than your
knowledge cutoff
- CVEs, security advisories, or vulnerability details for specific versions
- API behavior or SDK changes you're uncertain about — verify rather than guess
- Anything the user explicitly asks you to search for
- Current status of external services, outages, or recent announcements
**When searching:**
- One query at a time. Evaluate results before searching again.
- Keep queries specific: `httpx 0.28 changelog` not `httpx python http client latest version changes`
- Link to sources when citing external information. Bare URLs are fine.
- Don't narrate the search — just do it and present what you found.
+39
View File
@@ -0,0 +1,39 @@
TOOL PATTERNS:
Modify existing file → read_file then edit_file:
read_file(path='config.py') → edit_file(path='config.py')
Modify multiple files → read_file then edit_file each:
read_file(path='a.py') → edit_file(path='a.py') → read_file(path='b.py') → edit_file(path='b.py')
Create new file → write_file (generate reasonable content even if the request is vague):
write_file(path='hello.py', content='...')
write_file(path='README.md', content='# Project\nDescription.')
Create a file then run it → write_file then bash:
write_file(path='fib.py', content='...') → bash(command='python fib.py')
Find something across files → search:
search(query='test_')
Find and modify → search then read_file then edit_file:
search(query='MAX_RETRIES') → read_file(path='found.py') → edit_file(path='found.py')
Plan, design, or architect something → explore codebase then plan_agent:
bash(command='ls') → read_file(path='app.py') → plan_agent(goal='add caching to the application')
plan_agent(goal='refactor database layer from monolith to service')
plan_agent(goal='restructure auth module')
Run a command, git, or tests → bash:
bash(command='git log -5')
bash(command='pytest')
Retrieve a URL → web_fetch:
web_fetch(url='https://example.com')
Search the web for information → web_search:
web_search(query='current population of Tokyo')
Look up command flags or documentation → man:
man(page='tar')
man(page='grep')
+11 -4
View File
@@ -4,7 +4,7 @@ Usage::
from turnstone.sdk import TurnstoneConsole
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
overview = client.overview()
print(f"Nodes: {overview.nodes}, Workstreams: {overview.workstreams}")
"""
@@ -73,7 +73,7 @@ class AsyncTurnstoneConsole(_BaseClient):
def __init__(
self,
base_url: str = "http://localhost:8081",
base_url: str = "http://localhost:8090",
token: str = "",
timeout: float = 30.0,
httpx_client: httpx.AsyncClient | None = None,
@@ -199,6 +199,7 @@ class AsyncTurnstoneConsole(_BaseClient):
resume_ws: str = "",
target_node: str = "",
user_id: str = "",
client_type: str = "",
) -> dict[str, Any]:
"""Create a workstream via the console's routing proxy.
@@ -224,6 +225,8 @@ class AsyncTurnstoneConsole(_BaseClient):
body["target_node"] = target_node
if user_id:
body["user_id"] = user_id
if client_type:
body["client_type"] = client_type
return await self._request("POST", "/v1/api/route/workstreams/new", json_body=body)
async def route_send(self, message: str, ws_id: str) -> dict[str, Any]:
@@ -958,19 +961,20 @@ class TurnstoneConsole:
Usage::
with TurnstoneConsole("http://localhost:8081", token="tok_xxx") as client:
with TurnstoneConsole("http://localhost:8090", token="ts_your_api_token") as client:
overview = client.overview()
print(f"Nodes: {overview.nodes}")
"""
def __init__(
self,
base_url: str = "http://localhost:8081",
base_url: str = "http://localhost:8090",
token: str = "",
timeout: float = 30.0,
ca_cert: str | None = None,
client_cert: str | None = None,
client_key: str | None = None,
token_factory: Callable[[], str] | None = None,
) -> None:
self._runner = _SyncRunner()
self._async = AsyncTurnstoneConsole(
@@ -980,6 +984,7 @@ class TurnstoneConsole:
ca_cert=ca_cert,
client_cert=client_cert,
client_key=client_key,
token_factory=token_factory,
)
# -- cluster overview ----------------------------------------------------
@@ -1059,6 +1064,7 @@ class TurnstoneConsole:
resume_ws: str = "",
target_node: str = "",
user_id: str = "",
client_type: str = "",
) -> dict[str, Any]:
return self._runner.run(
self._async.route_create_workstream(
@@ -1071,6 +1077,7 @@ class TurnstoneConsole:
resume_ws=resume_ws,
target_node=target_node,
user_id=user_id,
client_type=client_type,
)
)
+33
View File
@@ -300,6 +300,36 @@ class ClusterSnapshotEvent(ClusterEvent):
timestamp: float = 0.0
@dataclass
class NodeSnapshotEvent(ClusterEvent):
"""Full node state delivered on SSE connect to ``/v1/api/events/global``."""
type: str = "node_snapshot"
node_id: str = ""
workstreams: list[dict[str, Any]] = field(default_factory=list)
health: dict[str, Any] = field(default_factory=dict)
aggregate: dict[str, Any] = field(default_factory=dict)
@dataclass
class HealthChangedEvent(ClusterEvent):
"""Circuit breaker state transition on a server node."""
type: str = "health_changed"
circuit_state: str = ""
@dataclass
class AggregateEvent(ClusterEvent):
"""Periodic aggregate metrics from a server node."""
type: str = "aggregate"
total_tokens: int = 0
total_tool_calls: int = 0
active_count: int = 0
total_count: int = 0
# ---------------------------------------------------------------------------
# Type registries (built after all classes are defined)
# ---------------------------------------------------------------------------
@@ -354,5 +384,8 @@ _CLUSTER_REGISTRY: dict[str, type[ClusterEvent]] = {
ClusterWsClosedEvent,
ClusterWsRenameEvent,
ClusterSnapshotEvent,
NodeSnapshotEvent,
HealthChangedEvent,
AggregateEvent,
]
}
+31 -2
View File
@@ -4,7 +4,7 @@ Usage::
from turnstone.sdk import TurnstoneServer
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
ws = client.create_workstream(name="Analysis")
result = client.send_and_wait("Hello", ws.ws_id)
print(result.content)
@@ -37,6 +37,7 @@ from turnstone.sdk._base import _BaseClient
from turnstone.sdk._sync import _SyncRunner
from turnstone.sdk._types import TurnResult
from turnstone.sdk.events import (
ClusterEvent,
ContentEvent,
ErrorEvent,
ReasoningEvent,
@@ -98,6 +99,7 @@ class AsyncTurnstoneServer(_BaseClient):
auto_approve_tools: str = "",
user_id: str = "",
ws_id: str = "",
client_type: str = "",
) -> CreateWorkstreamResponse:
body: dict[str, Any] = {}
if name:
@@ -118,6 +120,8 @@ class AsyncTurnstoneServer(_BaseClient):
body["user_id"] = user_id
if ws_id:
body["ws_id"] = ws_id
if client_type:
body["client_type"] = client_type
return await self._request(
"POST",
"/v1/api/workstreams/new",
@@ -199,6 +203,22 @@ class AsyncTurnstoneServer(_BaseClient):
async for data in self._stream_sse("/v1/api/events/global"):
yield ServerEvent.from_dict(data)
async def stream_node_events(
self, *, expected_node_id: str = ""
) -> AsyncIterator[ClusterEvent]:
"""Iterate over node-level SSE events (snapshot + deltas).
Connects to ``/v1/api/events/global`` with the optional
``expected_node_id`` param for identity verification. Yields
``ClusterEvent`` instances (``NodeSnapshotEvent``, ``HealthChangedEvent``,
etc.) suitable for console collector consumption.
"""
params: dict[str, str] = {}
if expected_node_id:
params["expected_node_id"] = expected_node_id
async for data in self._stream_sse("/v1/api/events/global", params=params):
yield ClusterEvent.from_dict(data)
# -- high-level convenience ----------------------------------------------
async def send_and_wait(
@@ -412,7 +432,7 @@ class TurnstoneServer:
Usage::
with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
with TurnstoneServer("http://localhost:8080", token="ts_your_api_token") as client:
ws = client.create_workstream(name="Analysis")
result = client.send_and_wait("Hello", ws.ws_id)
print(result.content)
@@ -426,6 +446,7 @@ class TurnstoneServer:
ca_cert: str | None = None,
client_cert: str | None = None,
client_key: str | None = None,
token_factory: Callable[[], str] | None = None,
) -> None:
self._runner = _SyncRunner()
self._async = AsyncTurnstoneServer(
@@ -435,6 +456,7 @@ class TurnstoneServer:
ca_cert=ca_cert,
client_cert=client_cert,
client_key=client_key,
token_factory=token_factory,
)
# -- workstream management -----------------------------------------------
@@ -457,6 +479,7 @@ class TurnstoneServer:
auto_approve_tools: str = "",
user_id: str = "",
ws_id: str = "",
client_type: str = "",
) -> CreateWorkstreamResponse:
return self._runner.run(
self._async.create_workstream(
@@ -469,6 +492,7 @@ class TurnstoneServer:
auto_approve_tools=auto_approve_tools,
user_id=user_id,
ws_id=ws_id,
client_type=client_type,
)
)
@@ -509,6 +533,11 @@ class TurnstoneServer:
def stream_global_events(self) -> Iterator[ServerEvent]:
return self._runner.run_iter(self._async.stream_global_events())
def stream_node_events(self, *, expected_node_id: str = "") -> Iterator[ClusterEvent]:
return self._runner.run_iter(
self._async.stream_node_events(expected_node_id=expected_node_id)
)
# -- high-level convenience ----------------------------------------------
def send_and_wait(

Some files were not shown because too many files have changed in this diff Show More