Compare commits

...

69 Commits

Author SHA1 Message Date
Patrick Buckley a0ff22e137 release: v0.9.2
- fix: UI busy state during multi-tool-call turns (#216)
- feat: batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
- fix: Anthropic sub-agent streaming timeout (#218)
- fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
2026-03-29 05:56:40 -07:00
Patrick Buckley de4b3b3909 fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
When a cancel interrupts tool execution, the assistant message with
tool_use blocks is saved to DB before tools run, but
GenerationCancelled prevents tool results from being created. The
in-memory rollback removes the orphaned message, but the DB row
persists. On resume, Anthropic rejects the conversation with
"tool_use ids were found without tool_result blocks".

Fix: _convert_messages now peeks ahead after each assistant message
with tool_use blocks. If any tool_use IDs lack matching tool_result
messages, synthetic error results are injected (is_error: true,
"Tool execution was cancelled."). Transparent to all callers,
provider-specific (OpenAI is lenient about this).

5 new tests covering: single orphan, multiple orphans, partial
results, complete results (no synthesis), and trailing orphan.
2026-03-29 05:54:19 -07:00
Patrick Buckley 120d229b5f fix: Anthropic sub-agent streaming timeout (#218)
plan_agent and task_agent fail on Anthropic models with "Streaming is
required for operations that may take longer than 10 minutes" from
the SDK. The non-streaming create_completion path used
client.messages.create() which the SDK rejects for thinking-enabled
models.

Fix: use client.messages.stream() internally and call
get_final_message() to get the same Message object. Transparent to
all callers — fixes sub-agents, title generation, summarization,
web fetch, and judge create_completion calls.
2026-03-29 05:35:27 -07:00
Patrick Buckley c6f4c11870 feat: harness quick wins — batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
Five improvements from Opus self-evaluation of the turnstone harness:

1. Batch edit_file: edits array parameter for atomic multi-edit in a
   single tool call. Overlap detection, reverse-order application,
   mutual exclusivity with single-edit params.

2. Sandbox packages: new [sandbox] extras group with sympy, numpy,
   scipy, pytest — the sandbox already had graceful ImportError
   fallbacks, now the packages are actually installed.

3. Stderr labeling: bash tool output prefixes stderr lines with
   [stderr] so the model can distinguish errors from stdout.

4. JSON secret redaction: output guard now detects and redacts secrets
   in JSON format ("api_key": "...", "password": "...", etc.) with
   18 key patterns and 8-char minimum value length.

5. Model persisted on resume: workstream config now saves model and
   model_alias. Resume restores the original model via registry
   (same path as /model command), falling back to raw model name
   if the alias is no longer available.

24 new tests (23 in test_edit_file.py, 1 in test_sessions.py).
2026-03-29 05:21:08 -07:00
Patrick Buckley 979fab37a9 fix: UI busy state during multi-tool-call turns (#216)
stream_end fires per-segment (between tool calls), not per-turn.
The UI was using stream_end to transition to idle, causing a window
where the Send button appeared but the server worker thread was still
alive. User messages submitted during this window were silently
dropped. No Stop button was visible, so the user had no cancel path.

Root cause: state_change events (idle/thinking/running/error) were
only broadcast to the global SSE stream (console dashboard), never
to the per-workstream SSE that the browser UI listens to.

Fix: (1) server.py: on_state_change now also enqueues to the
per-workstream SSE listeners. (2) app.js: stream_end no longer
calls setBusy(false) — it only finalizes markdown rendering.
New state_change handler manages busy transitions: idle/error
set busy=false, thinking/running set busy=true.
2026-03-29 05:20:47 -07:00
Patrick Buckley da5bf90a4b feat: model detect button, capabilities API, and model dropdowns (#215)
* feat: model detect button, capabilities API, and model dropdowns

Admin Models tab: add Detect button that probes a model endpoint to
verify reachability, list available models, detect context_window, and
identify server type (llama.cpp/vLLM/SGLang/OpenAI/Anthropic). Add
static capability lookup endpoint for auto-filling form fields when
a known model name is entered. Add known-models endpoint for datalist
autocomplete suggestions.

Add "openai-compatible" as a third provider option for local servers,
keeping the OpenAI SDK under the hood but suppressing capability
auto-fill and known-model suggestions.

Replace free-text model input with a select dropdown in both console
and server new-workstream modals, populated from a new lightweight
GET /v1/api/models endpoint.

New endpoints:
- POST /v1/api/admin/model-definitions/detect
- GET /v1/api/admin/model-capabilities
- GET /v1/api/admin/model-capabilities/known
- GET /v1/api/models (both console and server)

* fix: accumulate signature_delta for Anthropic thinking blocks (#214)

The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.

* fix: address PR review — empty base_url, capability leak, response schemas

- Don't pass empty base_url to OpenAI client (falls back to SDK default)
- Return None from lookup_model_capabilities for openai-compatible provider
- Only use static capability table for known models in _detect_openai_compat,
  avoiding misleading 200k default for unknown local models
- Add AvailableModelInfo + ListAvailableModelsResponse schemas to both
  console_spec and server_spec
- Regenerate TypeScript SDK OpenAPI snapshots

* fix: apply same known-model guard to Anthropic context_window detection

Only report context_window from the static capability table when the
Anthropic model is actually known, matching the OpenAI path fix.

* ui: add autocomplete hint to Model ID label in admin modal

* fix: use explicit kwargs for OpenAI() to satisfy strict mypy
2026-03-29 03:50:21 -07:00
Patrick Buckley 70c18467cb fix: accumulate signature_delta for Anthropic thinking blocks (#214)
The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.
2026-03-29 03:10:49 -07:00
Patrick Buckley 801774bc4a fix: add diagnostic logging for silent tool call drops (#213)
When a local model generates tool calls that are silently dropped
(truncation, missing tool-call-parser), there was zero server-side
logging — making it impossible to diagnose from docker compose logs.

- OpenAI provider: log request params and response summary (debug)
- Session: log stream completion, tool call presence (info), and
  tool call discard with names when truncated (warning)

CLI unaffected — log level is WARNING there.
2026-03-29 02:25:43 -07:00
Patrick Buckley 497984b452 feat: database-backed model definitions with admin UI (#212)
Add model_definitions table (migration 028) enabling model management
via the admin console without SSH access or server restarts. Models
defined in the database coexist with config.toml models through a
per-node merge strategy — config.toml overrides DB for the same alias,
DB-only models coexist alongside, no cross-node contamination.

Storage layer:
- model_definitions table with CRUD (SQLite + PostgreSQL)
- MODEL_DEFINITION_MUTABLE allowlist, admin.models permission

ModelRegistry integration:
- load_model_registry() merges DB + config.toml + CLI models
- context_window=0 auto-detects from provider capability table
or inherits CLI-detected value (same as config.toml behavior)
- ModelRegistry.reload() with validation, TOCTOU-safe accessors
- internal_model_reload + internal_model_status server endpoints

Admin API + UI:
- 6 console endpoints (list, create, get, update, delete, reload)
with admin.models permission, audit trail, provider validation
- Models tab in System group with sky blue (--blue) accent color
- Provider badges (openai/anthropic), source badges (config/db)
- Write-only API keys (never readable, "***" sentinel on update)
- Sync-pending indicator, mobile responsive, focus-trapped modal

Also changes is_secret settings from write-blocked (403) to write-only
across all settings, making judge.api_key configurable via admin UI.
2026-03-29 01:58:08 -07:00
Patrick Buckley bdc1eba34c cleanup: drop vestigial tool_args column from conversations (migration 027) 2026-03-28 23:36:49 -07:00
Patrick Buckley 028c77cae5 fix: display tool errors inline in CLI (#210)
* fix: display tool errors inline in CLI

* fix: thread-safe stderr write with _print_lock and flush
2026-03-28 23:08:24 -07:00
Patrick Buckley 76d007d83f fix: TypeScript SDK DeleteSettingResponse type drift (#209)
* fix: TypeScript SDK DeleteSettingResponse type drift

* fix: export DeleteSettingResponse from SDK index
2026-03-28 23:08:09 -07:00
Patrick Buckley 3f432b8a42 fix: watch dispatch error handler missing stream_end and state cleanup (#208)
* fix: watch dispatch error handler missing stream_end and state cleanup

The watch dispatch run() closure was missing GenerationCancelled
handling, stream_end emission, on_state_change calls, and the
worker_thread identity guard that the send_message path has. This
left the web UI in a stale state when watch-dispatched sends failed.

* fix: address review feedback - on_stream_end, put_nowait, ws._lock, tests

* fix: ruff lint (unused pytest import)

* fix: send_message() use on_stream_end() instead of raw _enqueue
2026-03-28 23:07:47 -07:00
Patrick Buckley f74aa2264e refactor: add is_error to on_tool_result protocol, remove text heuris… (#207)
* refactor: add is_error to on_tool_result protocol, remove text heuristics

Add is_error keyword arg to SessionUI.on_tool_result() so tools
report errors structurally. Server and JS client no longer guess
from output text prefixes — each tool sets the flag at the source.

Bash tool: exit code >= 2 is error, exit code 1 is ambiguous (grep
no-match). History reconstruction keeps text heuristic as fallback
for pre-migration data.

Update SDKs (Python + TypeScript), test mocks, docs, and diagrams.

* fix: infinite recursion in _report_tool_result, signal exits, stale docs

* fix: add _tool_error_flags to test_load_skill ChatSession stubs
2026-03-28 22:09:52 -07:00
Patrick Buckley d00aae2429 fix: tool UX improvements (bash exit codes, previews, edit guard) (#206)
* fix: tool UX improvements (bash exit codes, previews, edit guard)

- Enable pipefail in bash tool so piped commands surface real exit codes
- Move exit code append before UI callback so web UI shows failures
- Remove preview truncation from edit_file, write_file, and math tools
- Add no-op guard to edit_file when old_string == new_string
- Fix collapsed tool output scroll — "click to expand" stays anchored

* fix: correct stale comment on edit_file preview

* fix: suggest re-reading file when edit_file old_string not found
2026-03-28 21:24:23 -07:00
Patrick Buckley 5e09940745 bump version to 0.9.1 2026-03-28 20:32:20 -07:00
renovate[bot] 72bd62d3d8 chore(deps): update dependency katex to v0.16.44 (#204)
* chore(deps): update dependency katex to v0.16.44

* 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-03-28 20:31:00 -07:00
Patrick Buckley d31f89b2e3 ci: let Renovate rebase over github-actions[bot] commits 2026-03-28 20:30:23 -07:00
Patrick Buckley 9bae8f1a10 ci: auto-download vendored JS files on Renovate PRs
Add vendor-js workflow that triggers on Renovate PRs touching
pyproject.toml — detects version changes, runs update-vendored-js.sh,
and commits the actual files back to the PR branch.  Supports manual
dispatch via pr_number input for one-off runs.
2026-03-28 20:28:06 -07:00
renovate[bot] a012561195 chore(deps): lock file maintenance (#205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 20:24:53 -07:00
Patrick Buckley 4198b59a0f fix: eager cancel_ref registration, SDK type drift, force-cancel tests (#203)
Providers now register the SDK stream handle eagerly (before returning
the iterator) instead of lazily inside a generator body. This closes
the window where cancel() couldn't abort a blocked HTTP read because
the stream handle wasn't populated yet.

- OpenAI: yield from → return (HTTP call + cancel_ref happen eagerly)
- Anthropic: split into eager __enter__ + _iter_with_cleanup generator
  with defensive __exit__ on __enter__ failure
- Console SDK: delete_setting() return type fixed from StatusResponse
  to DeleteSettingResponse (matching actual endpoint response)
- 2 new threaded force-cancel integration tests verifying orphaned
  threads don't mutate messages and new generations succeed
- Updated _CancelRef docstring, test robustness (assert on wait)
2026-03-28 20:05:57 -07:00
Patrick Buckley 4f6ef13ce9 fix: cancel button race condition with stream abort and force cancel (#202)
The cancel endpoint emitted a 'cancelled' SSE event before the worker
thread terminated. The frontend transitioned to "send" mode prematurely,
so the next send got rejected with "Already processing a request."

Backend:
- Providers expose SDK stream handle via cancel_ref parameter so
  cancel() can close the HTTP connection and unblock iteration
- Generation counter prevents orphaned threads from mutating messages
  or clearing cancel state after force cancel
- _check_cancelled() added between retry attempts in _try_stream
- Server polls (async, non-blocking) for cancelled worker to exit
- Force cancel (force:true) abandons stuck worker, keeps cancel event
  set so subprocesses are killed, guards against spurious SSE events

Frontend:
- 'cancelled' shows "Cancelling..." then escalates to "Force Stop"
  after 2s for a harder cancel that abandons the worker immediately
- 10s safety timeout auto-recovers if stream_end never arrives
- busy_error re-enables stop button instead of showing send
- Timeout cleanup in disconnectSSE, stream_end, and force .then()
- Layout shift prevention (min-width, white-space: nowrap)
- aria-label updates for accessibility

Tests:
- 7 new tests: stream close, error suppression, cancel_ref population,
  transport error conversion, non-cancel exception propagation, retry
  cancellation check
2026-03-28 19:26:37 -07:00
Patrick Buckley 52716ed611 feat: detect repeated tool calls and nudge model to try different approach (#201)
When a model calls the same tool with identical arguments as a previous
call, append a warning to the tool result and inject a metacognitive
nudge. This breaks loops where small local models get stuck repeating
the same action (e.g. running the same bash command 3+ times).

The repeat signature set is cleared after a warning fires, giving the
model a clean slate. Also cleared on conversation compaction.

Ref: #186
2026-03-28 16:18:12 -07:00
Patrick Buckley 6c9a7d7351 fix: harden tool call handling for local model servers (#200)
* fix: harden tool call handling for local model servers

Local models (Qwen 3.5 9B, etc.) via llama.cpp produce tool calls with
empty IDs, whitespace-padded names, and malformed JSON arguments. These
defensive gaps caused cascading conversation corruption and silent
failures.

- Strip whitespace from tool names in both main and agent paths
- Generate synthetic UUIDs when tool call IDs are empty/null
- Surface malformed tool call errors to the user via on_error
- Give the model actionable hints (expected JSON format, available tools)
  so it can self-correct on retry
- Surface metacognition nudge types to UI via on_info

Ref: #186, #117

* refactor: extract _ensure_tool_call_ids helper, include MCP tools in error

Address Copilot review feedback on PR #200:
- Extract duplicated ID fixup into _ensure_tool_call_ids() static method
- Tests now exercise the actual helper instead of reimplementing the logic
- Unknown tool error now includes MCP tool names alongside builtins
2026-03-28 15:48:11 -07:00
Patrick Buckley 3518f7953c fix: prevent 100% CPU spin from unreachable HTTP MCP servers (#199)
When an HTTP MCP server is unreachable and TCP connect fails immediately
(ECONNREFUSED, DNS failure), the anyio task group inside
streamablehttp_client produces a CancelledError that escapes
asyncio.wait_for and leaves orphaned cancel-scope tasks in an infinite
_deliver_cancellation loop (~800K callbacks/sec).

Three-part fix:
- TCP pre-flight probe (5s timeout) before entering the anyio transport
  context — fails fast on unreachable servers, avoiding the bug entirely
- Catch CancelledError in _connect_one with current_task().cancelling()
  check to distinguish stray anyio cancels from real shutdown
- _safe_close_stack helper with bounded timeout that never raises,
  preventing cleanup errors from masking the original exception
2026-03-28 15:19:04 -07:00
Patrick Buckley 48769e5a97 fix: gate read_resource and use_prompt tools on MCP server availability (#198)
These built-in tools were always sent to the LLM even when no MCP
servers were connected, wasting model turns on calls that would always
return errors. Now gated per-request in _get_active_tools() — same
pattern as the existing web_search gating — using resource_count and
prompt_count for granular filtering.
2026-03-28 14:38:53 -07:00
Patrick Buckley adb4ff6399 fix: resolve pre-existing test failures, stale type ignores, and warnings (#197)
- TLS: suppress no-any-return + unused-ignore on lacme mtls helpers
  (lacme stubs typed locally but not in CI)
- TLS: catch duplicate Prometheus metric registration specifically
  (not blanket ValueError) so real setup errors still propagate
- Discord: add missing storage=None to _make_bot mock (policy evaluation
  path accesses self.storage)
- Web search: patch _ddg_available in gating test so ddgs availability
  doesn't mask the Tavily-only test path
- Bridge stress: close real httpx client before replacing with mock so
  daemon threads don't make real HTTP calls or leak connections
- README: comment out tavily_key placeholder in example config
2026-03-28 14:24:53 -07:00
Patrick Buckley 131a1ec943 fix: stream tool errors in real-time with visual error indicator (#196)
* fix: stream tool errors in real-time with visual error indicator

Tool executors that hit errors (file not found, timeout, write failure,
etc.) were returning error strings without calling ui.on_tool_result(),
so no SSE event was emitted to the browser during streaming. Errors only
appeared after page refresh via history rebuild. Now all error paths
call on_tool_result() so errors stream in real-time.

Added visual error state: tool blocks with errors get a red left border,
red "✗ error" badge, and red error text — matching the existing denied
state pattern but distinct from it. Error detection uses prefix matching
("Error", "Command timed out", "Search timed out") both server-side
(is_error flag on SSE event + _build_history) and client-side (regex
fallback).

Affected tools: bash, read_file, write_file, edit_file, search, math,
man, memory, web_fetch, web_search, plus the run_one catch-all and
prepare-error paths.

* review: expand error prefix detection per copilot feedback

Add Unknown tool, JSON parse error, MCP prompt timed out, and MCP
prompt error to the is_error prefix list in on_tool_result, _build_history,
and the frontend regex. These error messages were confirmed in the
codebase but missing from the detection heuristic.
2026-03-28 00:21:11 -07:00
Patrick Buckley 1cded9b430 chore: bump version to 0.9.0 2026-03-27 21:24:49 -07:00
Patrick Buckley 62c741eb8a fix: prevent assistant messages with content=None from reaching OpenAI API (#195)
Replayed or cancelled conversations could produce assistant messages
with content=None and no tool_calls, which OpenAI-compatible APIs
reject with a 400.  Fix at three layers for defense in depth:

- session.py: use empty string instead of None when building assistant
  messages (streaming + cancellation paths)
- _utils.py: normalise content on DB load in reconstruct_messages()
- _openai.py: add _sanitize_messages() catch-all at provider boundary

Closes #194
2026-03-27 21:22:39 -07:00
Patrick Buckley 3362917e1e chore(deps): update vendored KaTeX 0.16.42 → 0.16.43 (#193) 2026-03-27 10:54:21 -07:00
renovate[bot] 698cbbf988 chore(deps): lock file maintenance (#192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:55:46 -07:00
renovate[bot] e47a08b7bc chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.2 (#191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:55:00 -07:00
renovate[bot] aaa427debd chore(deps): update dependency vitest to v4.1.2 (#190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:54:58 -07:00
renovate[bot] 611af76971 chore(deps): pin dependencies (#188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 20:54:49 -07:00
Patrick Buckley bbe28ecab3 fix: cancel LLM judge daemon when user approves/denies tools (#187)
When the LLM judge is enabled and the user makes rapid approval
decisions, judge daemon threads pile up competing for the inference
server, causing timeouts. Pass a threading.Event from session to the
judge — set on approval — so the daemon abandons remaining work
within ~1s, including shutting down the executor to kill in-flight
API calls.
2026-03-26 18:10:23 -07:00
Patrick Buckley 93a9fd3c28 bump: v0.8.9 — mTLS + ACME integration via lacme 2026-03-26 14:33:11 -07:00
Patrick Buckley 62ff3217d0 fix: TLS Docker end-to-end testing fixes (#185)
* fix: TLS Docker end-to-end testing fixes

Fixes discovered during Docker Compose TLS integration testing:

- Dockerfile: use --extra all (prevents missing optional deps)
- lacme 1.0.3: fixes CACertificateIssued event logging crash
- chmod PermissionError: guard for Docker volume mounts
- socket import: moved to top of main() (was inside TLS conditional,
  caused NameError in _default_node_id)
- redis.SSLConnection: ConnectionPool needs explicit connection_class,
  not ssl=True (which only works on Redis() directly)
- Empty redis password: pass None instead of "" to avoid AUTH error
- TURNSTONE_CONSOLE_URL: env var for Docker service discovery
  (0.0.0.0 bind address isn't reachable from other containers)
- HTTP01Handler: ACME client needs a challenge handler even when
  server auto-approves
- Docker overlay: tls-init as root with chmod, Redis conditional
  password, console Redis TLS flags, TURNSTONE_CONSOLE_URL

* feat: full mTLS end-to-end with lacme 1.0.4

Completes the mTLS chain across all services:

lacme 1.0.4:
- Dual EKU certs (serverAuth + clientAuth) — fixes mTLS rejection
- Configurable CA name (name="turnstone") — consistent store key

Bootstrap CA import:
- Console imports bootstrap CA from /certs volume on first boot
- Single trust root: bootstrap CA → console → all service certs

Bridge mTLS:
- TLSClient init when TURNSTONE_TLS_ENABLED set
- Auto-upgrades server URL from http:// to https://
- SSLContext passed to all 3 httpx clients via verify=

Console collector mTLS:
- upgrade_tls() method replaces httpx client with mTLS context
- Called in lifespan after cert issuance alongside proxy upgrade
- Fixes "Failed to poll node" when server serves HTTPS

Docker overlay:
- TURNSTONE_TLS_SANS on all services (Docker service names as SANs)
- TURNSTONE_TLS_ENABLED on bridge
- Channel service with Redis TLS flags
- TURNSTONE_CONSOLE_URL for service discovery
- Server healthcheck disabled (mTLS healthcheck deferred)
- Redis conditional password from env

Verified end-to-end: bootstrap → console CA → server HTTPS →
bridge mTLS → Redis TLS → channel Redis TLS → console collector
polls server over mTLS → workstream creation works through bridge

* fix: lint + copilot feedback on TLS Docker e2e

- SIM105: contextlib.suppress(PermissionError) for chmod
- F401: remove unused get_storage import in bridge
- Redis healthcheck: pass password when REDIS_PASSWORD is set

* fix: sort imports in admin.py and bridge.py

* fix: tls-init key permissions, healthcheck env, collector race

- tls-init: add set -e, chown to turnstone:turnstone with restrictive
  perms (keys 0600, certs 0640, dirs 0750) instead of world-readable
- Redis healthcheck: use container runtime $$REDIS_PASSWORD instead of
  Compose-time interpolation for consistency with --requirepass block
- collector upgrade_tls(): don't close old httpx client while concurrent
  poll threads may still be using it — let GC handle cleanup
2026-03-26 14:24:44 -07:00
Patrick Buckley b086390558 fix: TLS deferred work — wiring, security, tests, Docker overlay (#184)
* fix: TLS deferred work — wiring, security, tests, Docker overlay

Security fixes:
- PostgreSQL SSL: validate sslmode against known values, urlencode
  all params to prevent URL injection
- ConfigStore env seeding: removed redundant type coercion, delegate
  to validate_value() which handles all coercion correctly

Functional wiring:
- Database SSL: init_storage() passes SSL params to PostgreSQL URL
- Server: env var fallbacks for DB SSL (TURNSTONE_DB_SSLMODE etc.)
- Proxy mTLS: re-create proxy clients after TLS cert issuance
- Channel gateway: --ssl-certfile/keyfile/ca-certs CLI args, HTTPS
  advertise URL when SSL configured
- ConfigStore env seeding: TURNSTONE_{SECTION}_{KEY} seeds on first boot
- Console deregistration on shutdown (with debug logging)

Specs, tests, Docker:
- OpenAPI: 5 TLS admin endpoints in console_spec.py
- Auth enforcement test (401 without auth)
- SDK ValueError test (mismatched cert/key)
- Docker overlay: TURNSTONE_TLS_ENABLED, bridge --redis-tls, Redis
  healthcheck with client cert
- Removed stale type:ignore comments (lacme 1.0.2 type stubs)

* review: address copilot feedback on TLS deferred work

- ConfigStore env seeding: use config_store.set() instead of
  storage.set_system_setting() (correct API, updates cache)
- Remove unused defn variable (iterate SETTINGS keys only)
- Fix structlog call-arg error (positional args, not kwargs)
- Channel gateway: validate cert+key provided together
- Restore type:ignore[no-any-return] for CI mypy (lacme 1.0.2
  type stubs not in CI's mypy overrides yet)

* fix: rename _VALID_SSLMODES to lowercase (N806)
2026-03-25 22:43:35 -07:00
Patrick Buckley d08a57dfc2 feat: SDK TLS support, Docker Compose overlay, TLS docs (#183)
Python SDK:
- ca_cert, client_cert, client_key on all 4 client classes
- ValueError if only one of client_cert/client_key provided
- Passed to httpx verify=/cert=

TypeScript SDK:
- TlsOptions type exported (zero runtime code)
- Fix picomatch vulnerability (npm audit fix)

Docker Compose:
- deploy/docker-compose.tls.yml overlay with tls-init bootstrap
- Notes it's an overlay requiring a base compose file

Documentation:
- docs/tls.md: architecture, config, CLI, SDK examples, troubleshooting
- Fixed package name (@turnstone/sdk), Node.js 18+ note
2026-03-25 20:43:34 -07:00
Patrick Buckley 9fdf51ff3d feat: TLS admin UI, CLI cert management, Redis/PG TLS (#182)
Admin API (require admin.settings):
- GET /v1/api/admin/tls/certs, POST .../renew, DELETE .../certs/{domain}
- renew_cert() updates in-memory bundles immediately

Admin UI (instrument panel grid pattern):
- TLS tab with CA status bar, cert grid, renew/delete actions
- Uses admin-row/admin-colheaders grid system (consistent with 12+ tabs)
- showConfirmModal for destructive actions, aria-labels on buttons
- Expired certs show "EXPIRED" text prefix + red color (WCAG 1.4.1)
- Loading state, empty state, error state

CLI (turnstone-admin):
- tls-bootstrap: offline CA + cert issuance (dir perms 0700)
- tls-issue: ACME cert request with key perms 0600
- tls-ca-cert: SHA-256 fingerprint for TOFU verification
- tls-list: auth via --auth-token or config token

Redis/PG TLS:
- RedisBroker + AsyncRedisBroker: ssl params wired through
- broker_from_args + async_broker_from_args: forward TLS kwargs
- add_redis_args: --redis-tls CLI flags
- Config map: [redis] and [database] TLS passthrough
2026-03-25 18:55:11 -07:00
Patrick Buckley 45471894da refactor: adopt lacme 1.0.2 — eliminate loopback client + temp file boilerplate (#181)
lacme 1.0.2 ships four features that simplify turnstone's TLS code:

- RenewalManager CA-direct mode: console renewal now uses ca= param
  instead of a loopback ACME client. No network, no startup ordering
  dependency, no client lifecycle management.
- ACMEResponder serves /ca.pem natively: removed custom route handler
  and route ordering workaround.
- write_pem_files_persistent: replaced manual temp file creation,
  chmod, and atexit cleanup with lacme's secure PEM file helper.
- Removed port param from TLSManager (was only for loopback URL).

Net: ~50 lines removed, two tech debt items resolved.
2026-03-25 18:02:26 -07:00
Patrick Buckley c0b5952573 feat: mTLS service clients with ACME auto-provisioning (#180)
TLSClient class for service nodes — discovers console via services
table, fetches CA cert, requests cert via ACME, provides SSL contexts:

- Console self-registers in services table for discovery
- Services auto-discover console URL from DB (no extra config)
- Initial cert request over plain HTTP (ACME provides integrity)
- Auto-renewal via RenewalManager in server lifespan
- Unauthenticated /acme/ca.pem endpoint for node bootstrapping

RenewalManager fix (was passing client=None):
- Console creates loopback ACME client for self-renewal
- Proper async lifecycle (aenter/aexit) with clean shutdown

Integration points wired:
- Server: TLS init before uvicorn, temp PEM files (0o600, atexit
  cleanup), auto-renewal in lifespan
- Bridge: tls_verify + tls_cert params on all 3 httpx clients
- Console collector: tls_verify + tls_cert params on httpx client
- Console proxy: mTLS context from TLSManager on proxy clients
- Channel gateway: optional SSL params on uvicorn.Config
- Console main(): reads tls.enabled, creates TLSManager, passes
  to create_app with console_url

6 tests for TLSClient (discovery, defaults, backward compat)
2026-03-25 17:48:34 -07:00
Patrick Buckley b9d5b5b671 feat: console CA + ACME server via lacme (#179)
TLSManager class owns the internal CA, ACME responder, and cert
lifecycle:
- CertificateAuthority with DB-backed storage (StorageStore adapter)
- ACMEResponder mounted at /acme (auto_approve for internal network)
- Dual cert issuance: internal CA for mTLS, optional external ACME CA
  for frontend HTTPS (Let's Encrypt via acme_directory setting)
- Auto-renewal via RenewalManager with clean async shutdown
- Expired cert detection on reload (re-issues instead of loading stale)
- EventDispatcher wired to structlog + lacme Prometheus metrics
- GET /v1/api/admin/tls/ca.pem — root cert download
- GET /v1/api/admin/tls/ca — CA status + cert inventory
- Console lifespan: init CA, issue certs, start renewal, stop on shutdown
- 11 async tests covering CA lifecycle, cert persistence, SSL contexts,
  endpoints, and event wiring
2026-03-25 16:35:55 -07:00
Patrick Buckley 274c97135e feat: TLS storage backend + config for lacme integration (#178)
Storage layer for mTLS certificate management via lacme:

- Migration 026: tls_account_keys, tls_ca, tls_certificates tables
- 8 protocol methods on StorageBackend (save/load account keys, CA,
  certs; list/delete certs)
- SQLite and PostgreSQL implementations with dialect-specific upserts
- StorageStore adapter bridging lacme's Store protocol to turnstone
  storage (bytes↔str PEM conversion, CertBundle↔dict mapping)
- Settings registry: tls.enabled (bool), tls.acme_directory (string)
- Config.toml: [redis] TLS and [database] SSL passthrough params
- lacme>=1.0.1 as optional [tls] dependency
- 20 unit tests covering storage CRUD + adapter + crypto roundtrip
2026-03-25 16:07:05 -07:00
Patrick Buckley e87f8e19c2 feat: adopt eval-optimized system prompt for plan_agent pattern
Update plan_agent tool pattern to show codebase exploration before
delegating to the planning agent. This pattern achieved 100% pass
rate (160/160 runs) on the eval suite — up from 98% on the previous
prompt.
2026-03-25 13:32:01 -07:00
Patrick Buckley bb221f4dab chore: bump v0.8.8, update vendored katex 0.16.40 → 0.16.42 2026-03-25 12:00:26 -07:00
Patrick Buckley 361876b17a feat: eval pipeline improvements + tool description optimization (#174)
Eval pipeline:
- --optimize-tools mode freezes system prompt, optimizes tool descriptions only
- Analyst sees available tool list (prevents hallucinated "tool not in schema")
- Analyst sees current tool descriptions in --optimize-tools mode
- Three-layer timeout defense: httpx timeout + _cancelled event + client.close()
- Filter MCP-only tools (read_resource, use_prompt) from headless eval
- Pattern-over-rules framing in optimizer, analyst, and observer prompts
- Tool description diffs logged after each iteration
- Tool optimizer failure retries instead of stopping the loop

Tool renames (avoid chat template channel collision on local models):
- create_plan -> plan_agent
- task -> task_agent

Tool descriptions (from eval-driven optimization, 79% -> 98%):
- bash: environment question examples, disambiguation from write_file/man
- edit_file: multi-file workflow, prerequisite clarification, docstring example
- man: "questions about flags are tool-use tasks" prefix
- math: simple example up front
- plan_agent: "delegate to sub-agent" framing, negative boundary for direct edits
- read_file: multi-file workflow hint
- search: trigger phrases, prerequisite clarification, disambiguation
- write_file: immediate action framing, placeholder example

System prompt: enriched tool patterns from eval results, added identity opener

Test suite: plan-before-refactor -> plan-when-asked (simplified)

Docs: comprehensive eval.md rewrite covering all current features
2026-03-25 11:58:10 -07:00
renovate[bot] 5d26cd6593 chore(deps): update dependency katex to v0.16.42 (#175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:59 -07:00
renovate[bot] 2d4420e00d chore(deps): lock file maintenance (#177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:47 -07:00
renovate[bot] b20548583d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.1 (#176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 11:57:44 -07:00
Patrick Buckley f63b2915cc review: address copilot feedback on user_id trust check
Remove console-proxy from trusted_sources — end-user tokens via the
console proxy already carry the real user_id in the JWT, so they must
not be able to override it via the request body (impersonation risk).
Only bridge and console service identities are trusted to forward
user_id on behalf of users. Add 5 tests covering the trust boundary.
2026-03-24 03:13:23 -07:00
Patrick Buckley bf85bbea94 fix: resolve user_id to username in usage and audit displays
Usage tab grouped by user showed raw hex user_id instead of username.
Audit tab showed truncated hex. Now both API endpoints resolve user_id
to username via list_users() lookup before returning the response.
2026-03-24 03:13:23 -07:00
Patrick Buckley 803d8ee8f9 docs: document user_id propagation through MQ path
Update security.md with trusted service user_id forwarding. Update
MQ protocol diagram to include user_id field on CreateWorkstreamMessage.
Update console data flow diagram to show user_id in message and bridge
forwarding.
2026-03-24 03:13:23 -07:00
Patrick Buckley 17b5961a70 fix: propagate user_id through MQ workstream creation path
Console create_workstream was constructing CreateWorkstreamMessage
without setting user_id, so workstreams created via console→MQ→bridge
→server had empty user_id in usage events. Now the console extracts
user_id from auth_result and passes it through the MQ message. The
bridge forwards it in the HTTP payload, and the server accepts it
from trusted service callers (bridge, console-proxy).
2026-03-24 03:13:23 -07:00
Patrick Buckley 037308f3b1 fix: propagate user identity through console proxy
Console proxy previously used a fixed service identity (console-proxy)
with full {read,write,approve} scopes for all proxied requests, losing
the real user's identity at the proxy boundary. Now mints per-request
short-lived JWTs carrying the authenticated user's actual user_id,
scopes, and permissions so upstream servers record correct audit
attribution and enforce scope narrowing as defense in depth.
2026-03-24 03:13:23 -07:00
Patrick Buckley d7a9895855 feat: tool description optimization + OOM and logging fixes (#172)
* feat: tool description optimization + OOM and logging fixes

Tool description optimization (three-phase pipeline):
- Tool optimizer (phase 2) modifies tool descriptions to resolve
  confusion, gated by --optimize-tools and wrong_tool detection
- _apply_tool_overrides deep-copies modified tools, never mutates TOOLS
- _propose_tool_overrides validates JSON, deep-merges parameter overrides
- tool_overrides on EvolutionNode, plumbed through full eval pipeline
- --save-tools writes best overrides back to turnstone/tools/*.json
- Prompt optimizer informed when tool descriptions have been modified
- TSV tool_changes column

OOM fix (session lifecycle):
- HeadlessSession created inside retry loop, not outside — timed-out
  orphan threads no longer pin old sessions in memory
- Session ref cleared immediately after extracting results
- Previous behavior leaked unbounded memory per timeout (~515GB OOM)

Logging fix:
- Removed _suppress_stdout entirely — redirected fd 1 process-wide,
  causing main thread print() to vanish during slow API calls
- NullUI already discards session output; tools return strings

New CLI: --optimize-tools, --tool-optimizer-model/base-url, --save-tools

* review: address copilot feedback on tool optimization
2026-03-23 22:47:06 -07:00
Patrick Buckley 0ba49b8bb7 review: address copilot feedback on eval pipeline
- Record prompt_variant in parallel execution path (was serial-only)
- Handle "Subprocess error:" and "Skipped (fast-fail)" in failure
  classifier instead of mis-bucketing as missing_tool
- Build case_id->case_def dict once in _build_failure_analysis,
  _run_analyst, _propose_prompt_modification (was O(n²) linear scan)
- Enforce original prompt at slot 0 of cached user_prompts variants
- Use wall clock time for TSV elapsed_s instead of sum of run times
2026-03-23 19:44:32 -07:00
Patrick Buckley 51b5b3ee74 feat: multi-agent eval pipeline with tree search, analyst, diversifier
UCB tree search for prompt optimization (arXiv:2603.18620):
- EvolutionNode dataclass, UCB1 selection, rolling mean scores
- Replaces fragile linear chain with backtracking via tree
- Holdout set separation prevents optimizer overfitting
- Improvement-based delta feedback to optimizer

Multi-agent optimization pipeline:
- Analyst agent (phase 1): multi-turn with math/bash tools, identifies
  semantic failure patterns, computes statistics across test results
- Optimizer (phase 2): uses analyst diagnosis to edit developer prompt
- Observer: tunes optimizer strategy every 3 iterations
- Diversifier: generates paraphrased prompt variants for phrasing
  robustness, with dedup, delta generation, and JSON caching

Failure classification:
- 8 failure mode buckets (no_tool_call, wrong_tool, missing_tool,
  wrong_args, extra_tools, timeout, error, json_dump)
- Consistency signals (systematic, flaky, marginal)
- Rule-based pre-analysis feeds into analyst as structured input

Logging and observability:
- Config summary at startup (models, case count, runs)
- Per-case diversifier progress with dedup stats
- UCB selection reasoning, node score updates, tree growth
- Extended TSV: node_score, elapsed_s, prompt_len, iter_tokens,
  cumul_tokens columns plus 4-decimal precision

Infrastructure:
- Thread-safe fd-level stdout suppression (os.dup2)
- Prompt variants plumbed through parallel execution path
- Cached variants auto-detected from tests.json user_prompts field

New CLI flags: --explore-constant, --analyst-model/base-url,
--diversifier-model/base-url, --diversify N, --save-variants
2026-03-23 19:44:32 -07:00
Patrick Buckley c3b0ddeba7 fix: add ddgs to mypy ignore_missing_imports for CI compat 2026-03-23 19:11:50 -07:00
Patrick Buckley a533e1c783 fix: use fd-level stdout redirect in eval to avoid thread race
_suppress_stdout() was setting sys.stdout = StringIO() which is
process-global. When send_headless runs in a ThreadPoolExecutor and
blocks on an API call inside the suppression context, the main
thread's print() calls silently go to the StringIO and vanish.

Switch to os.dup2 fd-level redirect which is thread-safe.
2026-03-23 18:15:45 -07:00
Patrick Buckley d8bc78556f bump: v0.8.7 2026-03-23 17:27:23 -07:00
Patrick Buckley 4f5854e768 fix: remove stale type: ignore on ddgs import 2026-03-23 17:10:27 -07:00
Patrick Buckley bf2dc04cb3 feat: UCB tree search for eval prompt optimization
Replace linear optimization chain with UCB1 evolution tree. Each
iteration selects the most promising node to extend, preventing
irrecoverable collapse from bad edits. Also adds improvement-based
delta feedback to the optimizer and optional holdout set separation.

Inspired by "Learning to Self-Evolve" (arXiv:2603.18620).
2026-03-23 17:09:22 -07:00
Patrick Buckley bb894d073b fix: SQLite migrations use batch_alter_table for compat
Migrations 014, 021-024 used bare op.add_column/alter_column/drop_column
which fails on SQLite (no ALTER of constraints). Switch to
batch_alter_table and enable render_as_batch in env.py. Migration 014
also moved UniqueConstraint inline into create_table.
2026-03-23 17:09:13 -07:00
Patrick Buckley b3934a2d14 bump: v0.8.6
Hotfix: DDG web search returning empty results.

- Switch dependency from duckduckgo-search (deprecated shim, empty
  results) to ddgs>=9.0 (actively maintained successor)
- Include ddg extra in Docker image for free web search fallback
- Update all user-facing install instructions to reference ddgs
2026-03-23 16:17:28 -07:00
Patrick Buckley 3d02cf66b4 review: update stale duckduckgo-search references to ddgs 2026-03-23 16:09:03 -07:00
Patrick Buckley 7631b88792 fix: switch DDG dependency from duckduckgo-search to ddgs
duckduckgo-search 8.x is a deprecated shim that returns empty results
(upstream temporarily disabled HTML/Lite backends, Bing backend broken).
The package was renamed to ddgs in v9.x which works correctly.
2026-03-23 16:09:03 -07:00
Patrick Buckley a07172b0c0 fix: include ddg extra in Docker image for free web search fallback 2026-03-23 15:23:12 -07:00
205 changed files with 14158 additions and 1527 deletions
+34 -107
View File
@@ -5,55 +5,40 @@
"helpers:pinGitHubActionDigests",
":separateMajorReleases"
],
"labels": [
"dependencies"
"gitIgnoredAuthors": [
"41898282+github-actions[bot]@users.noreply.github.com"
],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"prHourlyLimit": 2,
"schedule": [
"before 9am on Monday"
],
"schedule": ["before 9am on Monday"],
"timezone": "America/New_York",
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 9am on Monday"
]
"schedule": ["before 9am on Monday"]
},
"customManagers": [
{
"customType": "regex",
"description": "Track vendored KaTeX version",
"managerFilePatterns": [
"/pyproject\\.toml$/"
],
"matchStrings": [
"katex-(?<currentValue>[\\d.]+)/"
],
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["katex-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "katex",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored Highlight.js version",
"managerFilePatterns": [
"/pyproject\\.toml$/"
],
"matchStrings": [
"hljs-(?<currentValue>[\\d.]+)/"
],
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["hljs-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "highlight.js",
"datasourceTemplate": "npm"
},
{
"customType": "regex",
"description": "Track vendored Mermaid version",
"managerFilePatterns": [
"/pyproject\\.toml$/"
],
"matchStrings": [
"mermaid-(?<currentValue>[\\d.]+)/"
],
"managerFilePatterns": ["/pyproject\\.toml$/"],
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
"depNameTemplate": "mermaid",
"datasourceTemplate": "npm"
}
@@ -62,14 +47,8 @@
{
"description": "LLM SDKs — always review manually",
"groupName": "LLM SDKs",
"matchPackageNames": [
"openai",
"anthropic",
"mcp"
],
"schedule": [
"before 9am on Monday"
],
"matchPackageNames": ["openai", "anthropic", "mcp"],
"schedule": ["before 9am on Monday"],
"automerge": false
},
{
@@ -83,73 +62,38 @@
"httpx-sse",
"pydantic"
],
"schedule": [
"before 9am on Wednesday"
],
"schedule": ["before 9am on Wednesday"],
"automerge": true,
"matchUpdateTypes": [
"patch"
]
"matchUpdateTypes": ["patch"]
},
{
"description": "Database layer",
"groupName": "Database",
"matchPackageNames": [
"sqlalchemy",
"alembic",
"psycopg"
],
"schedule": [
"before 9am on Wednesday"
],
"matchPackageNames": ["sqlalchemy", "alembic", "psycopg"],
"schedule": ["before 9am on Wednesday"],
"automerge": true,
"matchUpdateTypes": [
"patch"
]
"matchUpdateTypes": ["patch"]
},
{
"description": "Security-critical — always review manually",
"groupName": "Security",
"matchPackageNames": [
"PyJWT",
"pyjwt",
"bcrypt"
],
"matchPackageNames": ["PyJWT", "pyjwt", "bcrypt"],
"automerge": false
},
{
"description": "Infrastructure dependencies",
"groupName": "Infrastructure",
"matchPackageNames": [
"structlog",
"redis",
"croniter",
"discord.py"
],
"schedule": [
"before 9am on the first day of the month"
],
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": [
"patch"
]
"matchUpdateTypes": ["patch"]
},
{
"description": "Vendored JS — requires manual file download after merge",
"description": "Vendored JS — CI workflow downloads files automatically",
"groupName": "Vendored JS",
"matchPackageNames": [
"katex",
"highlight.js",
"mermaid"
],
"schedule": [
"before 9am on the first day of the month"
],
"automerge": false,
"prBodyNotes": [
"This PR updates version references only.",
"After merging, run `scripts/update-vendored-js.sh <lib> <version>` to download the actual files."
]
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
{
"description": "Dev/test tooling",
@@ -162,46 +106,29 @@
"pytest-cov",
"pre-commit"
],
"schedule": [
"before 9am on the first day of the month"
],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": [
"patch"
]
"matchUpdateTypes": ["patch"]
},
{
"description": "Docker base images",
"groupName": "Docker Images",
"matchManagers": [
"dockerfile",
"docker-compose"
],
"schedule": [
"before 9am on the first day of the month"
],
"matchManagers": ["dockerfile", "docker-compose"],
"schedule": ["before 9am on the first day of the month"],
"automerge": false
},
{
"description": "TypeScript SDK dev dependencies",
"groupName": "TypeScript SDK",
"matchFileNames": [
"sdk/typescript/**"
],
"schedule": [
"before 9am on the first day of the month"
],
"matchFileNames": ["sdk/typescript/**"],
"schedule": ["before 9am on the first day of the month"],
"automerge": true,
"matchUpdateTypes": [
"patch"
]
"matchUpdateTypes": ["patch"]
},
{
"description": "GitHub Actions — group all action updates",
"groupName": "GitHub Actions",
"matchManagers": [
"github-actions"
],
"matchManagers": ["github-actions"],
"automerge": false
}
]
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
python-version: "3.14"
- run: pip install -e ".[test,mq,postgres]"
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
env:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
python-version: "3.14"
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+86
View File
@@ -0,0 +1,86 @@
name: Complete Vendored JS Updates
# When Renovate bumps a vendored JS version in pyproject.toml, this
# workflow downloads the actual files and commits them to the PR branch
# so the PR is merge-ready without manual intervention.
#
# Note: the commit is made with GITHUB_TOKEN, so it won't re-trigger CI
# automatically. The reviewer should re-run CI once this workflow passes,
# or Renovate's next rebase will trigger it.
on:
pull_request:
paths:
- pyproject.toml
workflow_dispatch:
inputs:
pr_number:
description: "PR number to update"
required: true
type: number
permissions:
contents: write
pull-requests: read
jobs:
vendor-js:
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Resolve PR head ref
id: ref
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
else
ref="${{ github.head_ref }}"
fi
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.ref.outputs.head_ref }}
- name: Detect vendored JS changes
id: detect
run: |
updates=()
for lib in katex hljs mermaid; do
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
[[ -z "$version" ]] && continue
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
updates+=("${lib}:${version}")
done
if [[ ${#updates[@]} -eq 0 ]]; then
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "found=true" >> "$GITHUB_OUTPUT"
printf '%s\n' "${updates[@]}" > /tmp/updates.txt
echo "Libs to update:"
cat /tmp/updates.txt
fi
- name: Download vendored files
if: steps.detect.outputs.found == 'true'
run: |
while IFS=: read -r lib version; do
echo "::group::Updating ${lib} to ${version}"
bash scripts/update-vendored-js.sh "$lib" "$version"
echo "::endgroup::"
done < /tmp/updates.txt
- name: Commit and push
if: steps.detect.outputs.found == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "chore: download vendored JS files"
git push
+3 -3
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.10.12 /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
# System dependencies for psycopg (PostgreSQL client library)
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
@@ -25,12 +25,12 @@ ENV UV_COMPILE_BYTECODE=1
# Install dependencies first (cached layer — only re-runs when deps change)
COPY pyproject.toml uv.lock README.md LICENSE ./
RUN uv sync --frozen --no-install-project --no-dev \
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic
--extra all
# Install the project itself
COPY turnstone/ turnstone/
RUN uv sync --frozen --no-dev \
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic
--extra all
# Add venv to PATH so entry points are found
ENV PATH="/app/.venv/bin:$PATH"
+1 -1
View File
@@ -290,7 +290,7 @@ All entry points read `~/.config/turnstone/config.toml`. CLI flags override conf
[api]
base_url = "http://localhost:8000/v1"
api_key = ""
tavily_key = "" # only needed for local/vLLM models without native search
# tavily_key = "" # only needed for local/vLLM models without native search
[model]
name = "" # empty = auto-detect
+146
View File
@@ -0,0 +1,146 @@
# TLS overlay — enables mTLS across the turnstone cluster.
#
# Usage (requires base compose.yaml with production profile):
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
#
# The tls-init service bootstraps a CA and issues a cert for Redis.
# All turnstone services auto-provision their own certs via the
# console's ACME endpoint.
services:
# Bootstrap: create CA + Redis cert before anything starts.
# Runs as root to create directories in the volume, then chowns
# to turnstone:turnstone with restrictive perms (keys 0600).
tls-init:
build: .
user: root
command:
- sh
- -c
- |
set -e
turnstone-admin tls-bootstrap --out /certs --issue redis
chown -R turnstone:turnstone /certs
find /certs -type d -exec chmod 750 {} +
find /certs -type f -name '*key.pem' -exec chmod 600 {} +
find /certs -type f ! -name '*key.pem' -exec chmod 640 {} +
volumes:
- tls-certs:/certs
networks:
- turnstone-net
restart: "no"
# Console: runs the internal CA + ACME server
console:
depends_on:
tls-init:
condition: service_completed_successfully
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "console"
TURNSTONE_CONSOLE_URL: "http://console:8090"
command:
- turnstone-console
- --host=0.0.0.0
- --port=8090
- --redis-host=redis
- --redis-port=6379
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
- --redis-tls
- --redis-tls-ca=/certs/ca.pem
# Server: auto-provisions certs via console ACME, serves HTTPS
server:
depends_on:
console:
condition: service_healthy
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "server"
# Disable healthcheck — server serves HTTPS with mTLS which the
# stdlib healthcheck script can't satisfy. The base compose
# healthcheck uses plain HTTP which won't work on an HTTPS listener.
# TODO: wire healthcheck with client cert from /certs volume
healthcheck:
disable: true
# Bridge: mTLS to server + Redis TLS
bridge:
depends_on:
console:
condition: service_healthy
server:
condition: service_started
redis:
condition: service_healthy
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "bridge"
command:
- turnstone-bridge
- --server-url=http://server:8080
- --redis-host=redis
- --redis-port=6379
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
- --redis-tls
- --redis-tls-ca=/certs/ca.pem
# Channel: Redis TLS
channel:
depends_on:
console:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- tls-certs:/certs:ro
environment:
TURNSTONE_TLS_ENABLED: "true"
TURNSTONE_TLS_SANS: "channel"
command:
- sh
- -c
- >-
turnstone-channel
--redis-host=redis
--redis-port=6379
--redis-tls
--redis-tls-ca=/certs/ca.pem
--http-host=0.0.0.0
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
# Redis: TLS with certs from bootstrap
redis:
depends_on:
tls-init:
condition: service_completed_successfully
volumes:
- tls-certs:/certs:ro
command:
- sh
- -c
- |
ARGS="--tls-port 6379 --port 0 \
--tls-cert-file /certs/certs/redis/cert.pem \
--tls-key-file /certs/certs/redis/key.pem \
--tls-ca-cert-file /certs/ca.pem \
--tls-auth-clients no"
if [ -n "$$REDIS_PASSWORD" ]; then
ARGS="$$ARGS --requirepass $$REDIS_PASSWORD"
fi
exec redis-server $$ARGS
healthcheck:
test: ["CMD-SHELL", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli --tls --cacert /certs/ca.pem -a $$REDIS_PASSWORD ping; else redis-cli --tls --cacert /certs/ca.pem ping; fi"]
interval: 5s
timeout: 3s
retries: 5
volumes:
tls-certs:
+23 -8
View File
@@ -386,10 +386,10 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
{"type": "tool_output_chunk", "call_id": "call_abc123", "chunk": "Building project...\n"}
```
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr.
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr. The `is_error` field is `true` when the tool execution failed (e.g. bash exit code >= 2 or signal, file not found, timeout). Exit code 1 is ambiguous (e.g. `grep` no-match) and is not flagged. User denials are tracked separately via a `denied` flag. Clients should use `is_error` instead of text-prefix heuristics.
```json
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n"}
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false}
```
**`status`** -- token usage statistics, sent after each model turn.
@@ -452,9 +452,12 @@ after `/clear` or `/new` commands).
{"type": "clear_ui"}
```
**`cancelled`** -- the generation was cancelled by the user (via the Stop
button or `POST /v1/api/cancel`). The client should finalize any in-progress
assistant message with whatever partial content was streamed.
**`cancelled`** -- a cancel request was acknowledged (via the Stop button or
`POST /v1/api/cancel`). This signals that cancellation is in progress, not
that it is complete. The worker thread may still be finishing — wait for
`stream_end` before transitioning to a ready state. The client should clear
any in-progress assistant rendering but not re-enable the send button until
`stream_end` arrives.
```json
{"type": "cancelled"}
@@ -793,23 +796,35 @@ containing the resumed session's messages.
Cancels the active generation in a workstream. Sets a cooperative cancellation
flag that is checked at multiple points in the generation loop (per streaming
chunk, before tool execution, inside bash commands). The session transitions to
`idle` state and preserves any partial content already streamed.
chunk, before tool execution, inside bash commands). Also closes the underlying
HTTP stream to the LLM provider, unblocking any pending read immediately.
The session transitions to `idle` state and preserves any partial content
already streamed.
If the workstream is waiting for tool approval or plan review, the pending
prompt is automatically denied/rejected to unblock the worker thread.
Calling this endpoint when the workstream is already idle is a harmless no-op.
**Force cancel:** When `force` is `true`, the server abandons the stuck worker
thread immediately and transitions the workstream to `idle`. The abandoned
thread continues to wind down in the background (killing any running
subprocesses and exiting at the next cancellation checkpoint). During this
wind-down it may emit a final `stream_end` event which the server suppresses
for the orphaned thread. Use force cancel when cooperative cancel has not
resolved within a few seconds — the web UI offers this as a "Force Stop"
button automatically.
**Request body:**
```json
{"ws_id": "abc123"}
{"ws_id": "abc123", "force": false}
```
| Field | Type | Required | Description |
|--------|--------|----------|----------------------|
| `ws_id`| string | yes | Target workstream ID |
| `force`| bool | no | Abandon stuck worker immediately (default: `false`) |
**Response:**
+2 -2
View File
@@ -91,7 +91,7 @@ turnstone/
_config.py Base ChannelConfig dataclass
discord/ Discord adapter (bot, cog, views, streaming, config)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
katex-0.16.40/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
katex-0.16.44/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
@@ -242,7 +242,7 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
+1 -1
View File
@@ -365,7 +365,7 @@ SSE streams (`/v1/api/events`, `/v1/api/events/global`) are proxied as raw byte
### Authentication
The proxy forwards the user's JWT to upstream server nodes — it extracts the token from the incoming request's cookie (or `Authorization` header) and adds it as a `Bearer` header on the proxied request. Since all services share the same `TURNSTONE_JWT_SECRET`, the user's JWT is valid on every node without re-authentication. The console's own auth middleware also checks proxy routes — `POST` requests to proxy write endpoints (`/v1/api/send`, `/v1/api/approve`, etc.) require `write` scope, preventing read-only tokens from escalating via proxy. The static `--auth-token` / `proxy_auth_token` is used as a fallback when no user JWT is present.
The proxy mints a short-lived (5-minute) JWT per request carrying the real user's `user_id`, `scopes`, and `permissions` with `aud: turnstone-server`. The user's console JWT (`aud: turnstone-console`) cannot be forwarded directly — it would be rejected by the server's audience validation — so the console re-signs a new server-audience JWT from the validated `AuthResult`. This preserves audit attribution (the upstream server sees the real user, not a service identity) and enforces scope narrowing as defense in depth (a read-only console user's proxied request carries only `read` scope). The JWT `src` claim is set to `"console-proxy"` for audit traceability. When no user context is available (auth disabled), the proxy falls back to a `ServiceTokenManager` with service identity `console-proxy`. The static `--auth-token` / `proxy_auth_token` is used as a final fallback.
---
+1 -1
View File
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
+ on_content_token(text: str)
+ on_stream_end()
+ approve_tools(items: list) → (bool, str|None)
+ on_tool_result(call_id: str, name: str, output: str)
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
+ on_tool_output_chunk(call_id: str, chunk: str)
+ on_status(usage: dict, ctx_window: int, effort: str)
+ on_plan_review(content: str) → str
+2 -1
View File
@@ -133,7 +133,8 @@ group loop [while tool_calls present]
note right of TP
bash: on_tool_output_chunk(call_id, line)
called per stdout line,
then on_tool_result(call_id, name, output).
then on_tool_result(call_id, name, output, is_error).
is_error=True when execution failed.
call_id routes chunks/results to correct
tool div during parallel execution.
Other tools: on_tool_result() only.
+1 -1
View File
@@ -127,7 +127,7 @@ partition "Phase 3: Execute" #E3F2FD {
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
:ui.on_tool_result(call_id, name, output) for each;
:ui.on_tool_result(call_id, name, output, is_error) for each;
if (plan tool was executed?) then (yes)
:ui.on_plan_review(output);
+2
View File
@@ -61,6 +61,7 @@ package "Inbound Messages (Client → Bridge)" as InPkg #FFF3E0 {
+ target_node: str = ""
+ initial_message: str = ""
+ skill: str = ""
+ user_id: str = ""
}
class CloseWorkstreamMessage {
@@ -146,6 +147,7 @@ package "Outbound Events (Bridge → Client)" as OutPkg #E3F2FD {
+ call_id: str
+ name: str
+ output: str
+ is_error: bool
}
class PlanReviewEvent {
type = "plan_review"
+12 -1
View File
@@ -40,12 +40,23 @@ running --> error : Exception during\ntool execution
error --> thinking : New send() call\n_emit_state("thinking")
thinking --> idle : cancel() called\n_emit_state("idle")
thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
running --> idle : cancel() called\n_emit_state("idle")
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
note left of idle
**Cancel escalation:**
1. **Cooperative**: cancel() sets event + closes
SDK stream → worker exits at next checkpoint
2. **Force**: force=true abandons the worker
thread, emits stream_end immediately.
Orphaned thread still kills subprocesses
but skips message mutations (generation
counter prevents stale writes).
end note
note right of thinking
**Emitted via:**
session._emit_state(state)
+3 -2
View File
@@ -154,7 +154,7 @@ activate Server #FFECB3
Server -> CC : _pick_best_node() or\nget_node_detail(node_id)
CC --> Server : node validated
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task"}
Server -> Server : Build CreateWorkstreamMessage\n{target_node:"nodeA", name:"new-task",\nuser_id: from auth_result}
Server -> Redis : RPUSH turnstone:inbound:nodeA\n(directed queue)
Server --> Browser : {status:"ok", correlation_id:"abc",\ntarget_node:"nodeA"}
@@ -163,7 +163,8 @@ deactivate Server
note right of Redis
Bridge on Node-A picks up the
message from its directed queue,
POSTs to /v1/api/workstreams/new,
POSTs to /v1/api/workstreams/new
(forwarding user_id in payload),
registers ownership, publishes
ws_created to cluster channel.
end note
+11
View File
@@ -176,4 +176,15 @@ note bottom of SH
Both share JWT signing secret
end note
note left of JWT
**Console Proxy Token Minting**
When proxying requests to server nodes:
1. Console AuthMiddleware validates user JWT (aud: turnstone-console)
2. Proxy mints new JWT (aud: turnstone-server)
with real user_id, scopes, permissions
3. src: "console-proxy" for audit traceability
4. 5-minute expiry (fresh per request)
5. Fallback: ServiceTokenManager if no user context
end note
@enduml
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e605963c649574c7bf987b2d338257c78bc1fc68b524ac8276bb25365035e06
size 594096
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
size 594676
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
size 319702
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
size 325245
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43844b07d36beb04db871f6795a3f3be17852a6a484fdc0ea207403bd7f512a6
size 274286
oid sha256:72b3932ce99a860f5069544cd8423d3cdae3a51f6566db262b19ced19c780eb0
size 274374
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2636e2d4d2f84f26f93de6e984b780f6ad5bf8d3618a0202ea0a2ee80859c5b5
size 312409
oid sha256:d831c5e10266f0232262b6a29b0ea8e45b3cc63df75f716a80f18462b4f85e66
size 319125
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
size 200083
oid sha256:7bf27afa267d5b8d6da38e83213ed1b8e87639d5105a0a1ccc2e5a4bf4d3b67e
size 185282
+254 -68
View File
@@ -2,7 +2,8 @@
`turnstone-eval` is the evaluation and prompt optimization system for turnstone. It
runs test cases against the LLM, scores tool call sequences against expected
actions, and optionally uses the model to self-optimize the developer prompt.
actions, and optionally uses a multi-agent pipeline to optimize the developer
prompt and tool descriptions.
Source: `turnstone/eval.py`
@@ -10,15 +11,24 @@ Source: `turnstone/eval.py`
## Overview
The system works in an iterative loop:
The system uses UCB tree search to explore prompt variants:
1. Run each test case N times against the current developer prompt.
2. Score each run by comparing the actual tool call sequence to expected actions.
3. If not all tests pass, use the model to rewrite the prompt based on failures.
4. Repeat until all tests pass or max iterations are reached.
1. Maintain an **evolution tree** of prompt variants, starting from the initial prompt.
2. Each iteration, **UCB1 selects** the most promising node to evaluate.
3. Run each test case N times against the selected prompt.
4. Score each run by comparing the actual tool call sequence to expected actions.
5. If not all tests pass, run a **three-phase optimization pipeline**:
- Phase 1: Analyst diagnoses semantic failure patterns
- Phase 2: Tool optimizer adjusts tool descriptions (when `--optimize-tools`)
- Phase 3: Prompt optimizer proposes a child variant (when not `--optimize-tools`)
6. Add the child to the tree and repeat until all tests pass or max iterations reached.
When optimization is disabled (`--no-optimize`), only step 1 and 2 execute
(a single iteration).
This approach (inspired by [Learning to Self-Evolve](https://arxiv.org/abs/2603.18620))
prevents irrecoverable collapse from bad edits — UCB naturally backtracks to
high-scoring ancestors instead of following a linear chain.
When optimization is disabled (`--no-optimize`), only steps 2-4 execute
(a single iteration evaluating the root node).
---
@@ -65,6 +75,7 @@ Test suites are JSON files with this structure:
| `match_mode` | no | `"ordered_subset"` | How to match actual vs expected actions (see Scoring). |
| `max_turns` | no | `10` | Maximum conversation turns before stopping. |
| `n_runs` | no | suite default or 3 | Per-case override for number of runs. |
| `holdout` | no | `false` | If `true`, this case is evaluated but excluded from optimizer feedback. Used to measure progress without overfitting. |
### Expected Action Specs
@@ -135,6 +146,7 @@ deterministic, non-interactive execution suitable for automated testing.
| Stdout | Normal | Suppressed during execution |
| Tool logging | Display only | Structured `tool_call_log` |
| System prompt | Built-in developer prompt | Overridable via constructor |
| Cancellation | N/A | `_cancelled` event for timeout cleanup |
### NullUI
@@ -156,20 +168,34 @@ def send_headless(
Runs a complete multi-turn conversation:
1. Appends the user message.
2. Calls the model API (non-streaming).
3. If tool calls are returned, executes them (with stdout suppressed) and
2. Checks `_cancelled` event — stops if set (timeout cleanup).
3. Calls the model API (non-streaming).
4. If tool calls are returned, executes them (with stdout suppressed) and
logs each call to `self.tool_call_log`.
4. Repeats up to `max_turns` or until the model responds without tool calls.
5. Returns the tool call log: list of dicts with keys `tool`, `args`,
5. Repeats up to `max_turns` or until the model responds without tool calls.
6. Returns the tool call log: list of dicts with keys `tool`, `args`,
`result` (truncated to 500 chars), and `turn`.
Parallel tool calls are capped at 10 per turn to prevent degenerate repetition.
### Timeout and Cancellation
Each test runs in a `ThreadPoolExecutor(max_workers=1)` with a per-test
timeout (`--test-timeout`). Each attempt gets its own `OpenAI` client with
a matching httpx read timeout. On timeout, three layers of defense prevent
zombie connections:
1. **httpx timeout**: Per-request read timeout aborts the HTTP call and
releases the server slot.
2. **`_cancelled` event**: Prevents the orphan thread from starting new turns.
3. **`run_client.close()`**: Closes the connection pool to abort any
in-flight request.
### Retry Logic
`send_headless()` is called inside `_run_single_test()` with retry logic:
3 attempts with exponential backoff (sleep `2^attempt` seconds) on any
exception. This prevents transient API errors from poisoning eval scores.
exception. `TimeoutError` is re-raised immediately (no retry).
---
@@ -180,68 +206,175 @@ Each test case runs in isolation:
1. A fresh temp directory is created.
2. Setup files are written to the temp directory.
3. The working directory is changed to the temp directory.
4. A new `HeadlessSession` is created with the current developer prompt.
5. `send_headless()` runs the user prompt through the conversation loop.
6. The tool log is scored against expected actions.
7. The temp directory is cleaned up.
4. A per-attempt `OpenAI` client is created with httpx timeout matching `--test-timeout`.
5. A new `HeadlessSession` is created with the current developer prompt.
6. `send_headless()` runs the user prompt through the conversation loop.
7. The tool log is scored against expected actions.
8. The temp directory is cleaned up.
The memory database is also isolated per test (an ephemeral SQLite database
in the temp directory) so tests do not pollute each other or the user's
real memory store.
### Parallel Execution
With `--parallel N` (N > 1), tests run in a `ProcessPoolExecutor` with N
workers. Each subprocess creates its own `OpenAI` client. This is suitable
for remote API endpoints but will overwhelm local inference servers. The
default (`--parallel 1`) runs tests serially.
---
## Optimization Loop
## Model Roles
`run_optimization()` is the main entry point for iterative prompt optimization.
The eval pipeline uses up to five separate model roles, each independently
configurable. All roles inherit from the test model by default, with a
cascade chain:
```
test model (--base-url, --model)
└─ optimizer (--optimizer-*)
├─ observer (--observer-*)
├─ analyst (--analyst-*)
├─ diversifier (--diversifier-*)
└─ tool optimizer (--tool-optimizer-*)
```
| Role | Purpose | When it runs |
|------|---------|--------------|
| **Test** | The model being evaluated | Every iteration |
| **Analyst** | Diagnoses semantic failure patterns with tool use | When pass rate < 100% |
| **Optimizer** | Rewrites the developer prompt | Every iteration (unless `--optimize-tools`) |
| **Tool optimizer** | Rewrites tool descriptions | When `--optimize-tools` is set |
| **Observer** | Tunes the optimizer's strategy | Every 3 iterations |
| **Diversifier** | Generates prompt paraphrases | Once before the loop (when `--diversify N`) |
Typical setup: local model for test, Opus for analyst, Sonnet for
optimizer/observer/diversifier.
---
## Optimization Pipeline
### Flow
```
for iteration in 0..max_iterations:
1. Run all test cases n_runs times with current prompt
2. Score and aggregate results
3. Save intermediate results to JSON
4. If all tests pass -> stop
5. Every 3 iterations (at iteration 2, 5, 8, ...):
-> Observer reviews optimizer strategy
-> Reset prompt to best-performing iteration
6. Propose new prompt via optimizer model call
7. If prompt unchanged -> stop
8. Continue with new prompt
1. UCB select → pick the most promising tree node
2. Run all test cases n_runs times with selected node's prompt
3. Update node score (rolling mean) and visit count
4. Save intermediate results + tree state to JSON
5. If all tests pass → stop
6. Phase 1: Analyst diagnoses semantic failure patterns
7. Phase 2 (--optimize-tools only): Tool optimizer adjusts descriptions
8. Phase 3 (default only): Prompt optimizer proposes new prompt
9. Every 3 iterations: Observer tunes the optimizer's strategy
10. Add child node to tree (if prompt or tools changed)
```
### Prompt Proposal (`_propose_prompt_modification`)
### Phase 1: Analyst (`_run_analyst`)
Uses the model to rewrite the developer prompt based on test results:
A multi-turn agent with `math` (Python) and `bash` tools for computing
statistics. It receives per-case results with failure classifications and
produces a structured diagnosis:
- **Input**: Current prompt, test case definitions, per-case results with
actual vs expected tool sequences, and a history of the last 3 iterations.
- **Optimizer system prompt** (`OPTIMIZER_SYSTEM`): Instructs the model to
act as a text rewriter. Key guidance includes:
- Address critical failure modes (text-only responses, write_file vs edit_file,
unnecessary search before create, missing plan calls).
- Preserve phrasing that drives 100% pass rate on passing tests.
- Use direct imperative style with concrete tool call examples.
- Stay within 130% of original prompt length.
- **Output**: The rewritten prompt text (stripped of reasoning tags and code fences).
- **Failure patterns**: Shared root causes across failing cases
- **Success/failure contrast**: What distinguishes passing from failing cases
- **Consistency signals**: Systematic (0%), flaky (1-79%), marginal (80-99%)
- **Recommended fixes**: Priority-ordered patterns/examples to add or adjust
### Observer System (`_observe_and_update_optimizer`)
The analyst is instructed to frame fixes as patterns and examples, not
imperative rules — this feeds cleaner signal to the optimizer.
Every 3 iterations, a meta-level "observer" reviews the optimizer's strategy:
In `--optimize-tools` mode, the analyst receives the current tool descriptions
(with any overrides applied) and focuses on tool confusion and description
issues rather than system prompt patterns.
- Analyzes the iteration history: score trends, regressions, prompt length changes,
### Phase 2: Tool Optimizer (`_propose_tool_overrides`)
Runs when `--optimize-tools` is set. Receives the current tool descriptions,
confusion failures (where the model picked the wrong tool), and the analyst's
diagnosis. Returns a JSON override dict that modifies tool descriptions.
Overrides are validated against known tool names — only `description` and
`parameters` changes are accepted (no tool renaming at eval time).
After each iteration, changed descriptions are logged as old → new diffs
for easy visual inspection.
### Phase 3: Prompt Optimizer (`_propose_prompt_modification`)
Skipped in `--optimize-tools` mode. Receives the current prompt, test
results with per-case pass rates and deltas from the parent node, and the
analyst's diagnosis. Returns a rewritten prompt.
The optimizer is instructed to prefer patterns over rules — concrete tool
chain examples teach better than imperative directives like "ALWAYS" or
"NEVER." If the current prompt contains rule-heavy language, the optimizer
is guided to replace it with examples.
### Two Optimization Surfaces
The system supports alternating between two optimization surfaces:
1. **System prompt optimization** (default): Freeze tool descriptions,
optimize the developer prompt. Run until scores plateau.
2. **Tool description optimization** (`--optimize-tools`): Freeze the system
prompt, optimize tool descriptions only. Run until scores plateau.
Each surface lifts the floor for the other — tool description improvements
may unlock system prompt gains that weren't reachable before, and vice versa.
### Observer (`_observe_and_update_optimizer`)
Every 3 iterations, a meta-level observer reviews the optimizer's strategy:
- Analyzes iteration history: score trends, regressions, prompt length changes,
and diffs between iterations.
- Summarizes the optimizer's behavioral patterns (list style, header usage, length).
- Uses `OBSERVER_SYSTEM` to rewrite the optimizer's own system prompt.
- Detects whether the optimizer is producing rule-heavy or pattern-based output.
- Rewrites the optimizer's own system prompt to correct course.
- Rejects degenerate outputs (over 200% of input length).
- After updating the optimizer prompt, resets the developer prompt to the
best-performing iteration so far.
This two-level optimization (optimizer + observer) helps the system escape
local minima and adjust its rewriting strategy.
### Prompt Diversification
### Result Persistence
When `--diversify N` is set, the diversifier generates N paraphrased variants
of each test case's user prompt before the optimization loop. Each run cycles
through variants (round-robin), testing robustness across phrasings.
Variants can be cached back to the test suite JSON with `--save-variants`,
and auto-loaded on subsequent runs even without `--diversify`.
---
## Evolution Tree
The optimization maintains a tree of prompt variants (`EvolutionNode`), where
each node stores its prompt text, tool overrides, aggregated score, and visit
count. The root node (ID 0) contains the initial prompt.
**UCB1 selection**: Each iteration picks the node with the highest Upper
Confidence Bound score: `R_bar + C * sqrt(ln(N) / v)`, where `R_bar` is the
node's mean score, `N` is total visits across all nodes, `v` is the node's
visit count, and `C` is the exploration constant (`--explore-constant`,
default sqrt(2)). Unvisited nodes are always selected first.
### Holdout Cases
Test cases with `"holdout": true` are evaluated every iteration but excluded
from the optimizer's feedback. This prevents the optimizer from overfitting
to specific test cases. Node scores are computed from holdout cases only
(when present). If fewer than 2 non-holdout cases remain, holdout is disabled.
### Improvement-Based Feedback
The optimizer sees delta scores (`delta=+20%`) alongside absolute pass rates,
showing how each case improved relative to the parent node's evaluation. This
provides a cleaner signal than absolute scores alone — the optimizer can
distinguish beneficial edits from harmful ones regardless of starting point.
---
## Result Persistence
After each iteration, results are written to the output JSON file. The
structure is:
@@ -251,9 +384,15 @@ structure is:
"meta": {
"model": "model-name",
"base_url": "http://localhost:8000/v1",
"optimizer_model": "claude-opus-4-6",
"observer_model": "claude-opus-4-6",
"started": "2025-01-01T00:00:00",
"test_suite": "tests.json",
"n_runs_default": 3
"n_runs_default": 3,
"explore_constant": 1.414,
"holdout_ids": [],
"diversify": 10,
"prompt_variants": {"case_id": ["variant1", "variant2"]}
},
"iterations": [
{
@@ -261,7 +400,11 @@ structure is:
"prompt": "the developer prompt used",
"prompt_diff": null,
"optimizer_system": "the optimizer system prompt",
"analyst": "analyst diagnosis output",
"tool_overrides": {"bash": {"description": "..."}},
"timestamp": "2025-01-01T00:01:00",
"tree_node_id": 0,
"tree_child_id": 1,
"cases": {
"test_name": {
"runs": [
@@ -287,9 +430,21 @@ structure is:
"overall_pass_rate": 0.8,
"overall_avg_score": 0.87,
"json_dumps": 0,
"per_case_pass_rates": {"test_name": 1.0, ...}
"per_case_pass_rates": {"test_name": 1.0}
}
}
],
"tree": [
{
"node_id": 0,
"parent_id": null,
"prompt": "initial prompt",
"tool_overrides": {},
"score": 0.85,
"visit_count": 3,
"children": [1, 2],
"iteration": 0
}
]
}
```
@@ -306,26 +461,57 @@ turnstone-eval tests.json # evaluate + optimize
turnstone-eval tests.json --no-optimize # evaluate only (single iteration)
turnstone-eval tests.json --n-runs 5 --max-iter 10 # more thorough evaluation
turnstone-eval tests.json --prompt custom.txt # start from a custom prompt
turnstone-eval tests.json --optimize-tools # optimize tool descriptions only
turnstone-eval tests.json --diversify 10 # test with prompt variants
turnstone-eval tests.json -v # verbose per-turn logging
```
### Multi-model setup (local test model, cloud optimizer)
```
turnstone-eval tests.json \
--base-url http://localhost:8000/v1 \
--optimizer-base-url https://api.anthropic.com \
--optimizer-model claude-sonnet-4-6 \
--analyst-model claude-opus-4-6
```
### All Options
| Flag | Default | Description |
|---------------------|-------------------------------|-------------|
| `test_file` | (positional, required) | Path to test cases JSON file. |
| `--base-url` | `http://localhost:8000/v1` | API base URL. |
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
| `--temperature` | 0.7 | Sampling temperature. |
| `--max-tokens` | 32768 | Max completion tokens. |
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
| `--context-window` | 131072 | Context window size. |
| `--output` | `eval_results.json` | Output results file path. |
| `-v`, `--verbose` | false | Show detailed per-turn logging (API calls, tool args, results). |
| Flag | Default | Description |
|-------------------------|----------------------------|-------------|
| `test_file` | (positional, required) | Path to test cases JSON file. |
| `--base-url` | `http://localhost:8000/v1` | API base URL for the test model. |
| `--model` | auto-detect | Model name. Auto-detected from the API if not specified. |
| `--prompt` | turnstone built-in prompt | Path to initial prompt text file. |
| `--n-runs` | from tests.json or 3 | Number of runs per test case. |
| `--max-iter` | 5 | Maximum optimization iterations. |
| `--no-optimize` | false | Run evaluation only (sets max-iter to 1). |
| `--temperature` | 0.7 | Sampling temperature. |
| `--max-tokens` | 32768 | Max completion tokens. |
| `--reasoning-effort` | `medium` | Reasoning effort: `low`, `medium`, or `high`. |
| `--context-window` | 131072 | Context window size. |
| `--output` | `eval_results.json` | Output results file path. |
| `-v`, `--verbose` | false | Show detailed per-turn logging. |
| `--explore-constant` | 1.414 (sqrt(2)) | UCB exploration constant C. |
| `--test-timeout` | 300 | Per-test timeout in seconds. |
| `--suite-timeout` | 0 (unlimited) | Total suite timeout in seconds. |
| `--no-fast-fail` | false | Disable early termination on all-zero initial runs. |
| `--parallel` | 1 (serial) | Parallel workers (0=auto, N=use N workers). |
| `--optimizer-model` | same as `--model` | Model for prompt optimization. |
| `--optimizer-base-url` | same as `--base-url` | Base URL for optimizer model. |
| `--observer-model` | same as optimizer | Model for meta-optimization (observer). |
| `--observer-base-url` | same as optimizer | Base URL for observer model. |
| `--analyst-model` | same as optimizer | Model for failure analysis. |
| `--analyst-base-url` | same as optimizer | Base URL for analyst model. |
| `--diversify` | 0 (disabled) | Generate N prompt variants per test case. |
| `--diversifier-model` | same as optimizer | Model for prompt diversification. |
| `--diversifier-base-url`| same as optimizer | Base URL for diversifier model. |
| `--save-variants` | false | Save generated variants back to test suite JSON. |
| `--optimize-tools` | false | Optimize tool descriptions only (freeze system prompt). |
| `--tool-optimizer-model` | same as optimizer | Model for tool description optimization. |
| `--tool-optimizer-base-url` | same as optimizer | Base URL for tool optimizer model. |
| `--save-tools` | false | Write optimized tool descriptions back to `turnstone/tools/*.json`. |
### Precedence for n_runs
+2 -2
View File
@@ -75,7 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
| | `command(*, ws_id, command)` | `StatusResponse` |
| | `cancel(ws_id)` | `StatusResponse` |
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
| | `stream_global_events()` | `Iterator[ServerEvent]` |
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
@@ -127,7 +127,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `reasoning` | `ReasoningEvent` | `text` |
| `tool_info` | `ToolInfoEvent` | `items` |
| `approve_request` | `ApproveRequestEvent` | `items` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
| `plan_review` | `PlanReviewEvent` | `content` |
+35 -8
View File
@@ -457,14 +457,30 @@ without any database.
### Proxy auth forwarding
When the console proxies requests to server nodes (via `/node/{id}/...`
routes), it uses a dedicated **service proxy token** with
`aud: turnstone-server` and `write` scope. The user's console JWT
(which has `aud: turnstone-console`) is **not** forwarded — it would be
rejected by the server's audience validation.
routes), it mints a **short-lived user-scoped JWT** with
`aud: turnstone-server` carrying the real user's `user_id`, `scopes`,
and `permissions`. The user's console JWT (which has
`aud: turnstone-console`) is **not** forwarded directly — it would be
rejected by the server's audience validation. Instead, the console
re-signs a new JWT targeted at the server audience.
The proxy token is managed by a `ServiceTokenManager` that auto-rotates
1-hour JWTs, refreshing at 80% of lifetime. If `--auth-token` is
provided, that static token is used instead.
Each proxied request gets a fresh JWT (5-minute expiry). This ensures:
- **Audit attribution** — the upstream server records the real user in
`ctx_user_id` and audit events, not a generic service identity.
- **Scope narrowing** — a read-only console user's proxied request
carries only `read` scope, not the full `{read, write, approve}` set.
The server enforces this as defense in depth.
- **Permission forwarding** — granular RBAC permissions from the
console JWT are carried through to the server.
The JWT `src` claim is set to `"console-proxy"`, allowing servers to
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.
### Service-to-service authentication
@@ -475,7 +491,7 @@ auto-rotating JWTs when communicating with server nodes:
|---------|----------|-------|----------|---------|
| Bridge | `bridge` | `approve` | `turnstone-server` | Tool approval proxy, message relay |
| Console collector | `console-collector` | `read` | `turnstone-server` | Node health polling |
| Console proxy | `console-proxy` | `write` | `turnstone-server` | Proxied API calls |
| Console proxy (fallback) | `console-proxy` | `approve` | `turnstone-server` | Proxied API calls when no user context |
| Channel notify | `system` | `write` | `turnstone-channel` | Notification delivery to channel gateway |
Service tokens use 1-hour expiry with automatic refresh via
@@ -483,6 +499,17 @@ Service tokens use 1-hour expiry with automatic refresh via
httpx event hooks to ensure rotated tokens are picked up on SSE
reconnects.
### User identity in MQ-dispatched workstreams
When the console creates a workstream via MQ (the normal path), the
authenticated user's `user_id` is embedded in the
`CreateWorkstreamMessage`. The bridge forwards this `user_id` in the
HTTP payload when calling the server's `POST /v1/api/workstreams/new`.
The server accepts a `user_id` from the request body **only when the
caller is a trusted service** — identified by `token_source` matching
`bridge`, `console-proxy`, or `console`. Regular API callers cannot
override `user_id`; the server always uses their JWT identity.
Note that the channel gateway uses a distinct JWT audience
(`turnstone-channel`) from the server (`turnstone-server`) and console
(`turnstone-console`). A server-scoped JWT cannot authenticate to the
+221
View File
@@ -0,0 +1,221 @@
# TLS / mTLS
Turnstone supports end-to-end transport encryption with mutual TLS (mTLS) for
inter-service communication, powered by [lacme](https://pypi.org/project/lacme/).
---
## Quick Start (Docker Compose)
```bash
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
```
This:
1. Bootstraps an internal CA and issues certs for Redis/PostgreSQL
2. Starts the console with TLS enabled (internal CA + ACME server)
3. Server nodes auto-provision certs via the console's ACME endpoint
4. All inter-service communication uses mTLS
---
## Architecture
```
Console (CA + ACME Server)
+-- CertificateAuthority (owns root key, signs certs)
+-- ACMEResponder (mounted at /acme, RFC 8555)
+-- GET /acme/ca.pem (root cert for node bootstrapping)
|
| ACME protocol (auto-approve, no challenge validation)
+-----------+-----------+
| | |
Server(s) Bridge Channel GW
(auto-cert (mTLS (mTLS
+ renewal) client) client)
```
**Two cert paths on the console:**
- **Internal cert** (mTLS): Always from the internal CA. Used for cluster
service mesh communication.
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
---
## Configuration
### Settings (ConfigStore / Admin Settings tab)
| Setting | Default | Description |
|---------|---------|-------------|
| `tls.enabled` | `false` | Master switch for internal mTLS |
| `tls.acme_directory` | `""` | External ACME CA URL for console frontend cert |
### Bootstrap Config (config.toml)
These are needed before storage is available:
```toml
[redis]
tls = false
tls_ca = "" # path to CA cert
tls_cert = "" # path to client cert
tls_key = "" # path to client key
[database]
sslmode = "prefer" # disable, allow, prefer, require, verify-full
sslrootcert = "" # path to CA cert
sslcert = "" # path to client cert
sslkey = "" # path to client key
```
### Hardcoded Defaults
| Parameter | Value | Notes |
|-----------|-------|-------|
| CA common name | "Turnstone CA" | |
| CA validity | 10 years | |
| Cert validity | 48 hours | Short-lived, auto-renewed |
| Renewal interval | 24 hours | Half of validity |
| ACME auto-approve | true | Internal network, no challenge validation |
---
## CLI
### Offline Bootstrap
Create a CA and infrastructure certs without a running console:
```bash
# Bootstrap CA + Redis + PostgreSQL certs
turnstone-admin tls-bootstrap --out /certs --issue redis --issue postgres
# Output:
# /certs/ca.pem (CA root certificate)
# /certs/certs/redis/ (Redis cert + key)
# /certs/certs/postgres/ (PostgreSQL cert + key)
```
The output directory is chmod 0700 (contains the CA private key).
### Online Cert Issuance
Request certs from a running console's ACME endpoint:
```bash
# Download CA root cert (TOFU — verify fingerprint)
turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
# Request a cert for a domain
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
# List issued certs
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
```
### Console URL Discovery
If `--console-url` is not provided, the CLI discovers it from the `services`
table in the shared database. The console registers itself on startup.
---
## Admin UI
The **TLS** tab in the console admin panel (System group) shows:
- CA status (common name, certificate count)
- Certificate table (domain, SANs, issued, expires)
- Force-renew and delete actions per certificate
---
## SDK
### Python
```python
from turnstone.sdk import TurnstoneServer
client = TurnstoneServer(
base_url="https://server:8080",
token="tok_xxx",
ca_cert="/path/to/ca.pem",
client_cert="/path/to/cert.pem",
client_key="/path/to/key.pem",
)
```
### TypeScript
```typescript
import { TurnstoneServer } from "@turnstone/sdk";
import { Agent } from "undici";
import * as fs from "fs";
const agent = new Agent({
connect: {
ca: fs.readFileSync("/path/to/ca.pem"),
cert: fs.readFileSync("/path/to/cert.pem"),
key: fs.readFileSync("/path/to/key.pem"),
},
});
const client = new TurnstoneServer({
baseUrl: "https://server:8080",
token: "tok_xxx",
// Node.js 18+ uses undici under the hood
fetch: (url, init) =>
fetch(url, { ...init, dispatcher: agent } as RequestInit),
});
```
---
## How It Works
### Node Bootstrap Flow
1. Node starts, connects to shared database (plain connection)
2. Discovers console URL from `services` table
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
5. Starts auto-renewal (24h interval, re-issues before expiry)
6. All subsequent inter-service communication uses mTLS
### Console Startup Flow
1. Read `tls.enabled` from ConfigStore
2. Initialize CA (load from DB or generate new root key)
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
4. Issue console certs (internal + optional frontend)
5. Start CA-direct auto-renewal (no network, signs directly)
6. Register console URL in services table with heartbeat
---
## Troubleshooting
### Cert expired / mTLS connection refused
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
restart the service to re-request a cert.
### "No console service found"
The console registers itself in the `services` table on startup. If the console
hasn't started or the registration expired (1 hour TTL), nodes can't discover
it. Use `--console-url` explicitly.
### Let's Encrypt for console frontend
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
in the admin Settings tab. The console will request a publicly trusted cert
for its HTTPS endpoint. Internal mTLS still uses the private CA.
### Verifying the cert chain
```bash
openssl s_client -connect server:8080 -CAfile ca.pem
```
+7 -2
View File
@@ -102,10 +102,15 @@ Each item's `execute` callable is invoked:
- Errored or denied items return their error/denial message without executing.
- The `bash` tool streams stdout incrementally: each line calls
`ui.on_tool_output_chunk(call_id, line)` as it is produced, then the final
combined output (stdout + stderr) is delivered via `ui.on_tool_result(call_id, name, output)`.
combined output (stdout + stderr) is delivered via
`ui.on_tool_result(call_id, name, output, is_error=...)`.
The `call_id` links `tool_info`/`approve_request` items to their streaming chunks and
final result, enabling correct routing when multiple bash tools run in parallel.
Other tools deliver results atomically via `ui.on_tool_result(call_id, name, output)` only.
The `is_error` flag is `True` when the tool execution failed (e.g. bash exit code >= 2
or signal, file not found, timeout). Exit code 1 is ambiguous and not flagged; user
denials are tracked separately. This removes the need for text-prefix heuristics.
Other tools deliver results atomically via
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
- Special post-execution gate for `plan`: the plan output is shown to the user
for review, and the user can reject or annotate it.
+14 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.8.5"
version = "0.9.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
@@ -51,9 +51,11 @@ console = ["redis>=7.2", "croniter>=3.0"]
sim = ["redis>=7.2"]
anthropic = ["anthropic>=0.39"]
postgres = ["psycopg[binary]>=3.2"]
ddg = ["duckduckgo-search>=8.0"]
ddg = ["ddgs>=9.0"]
discord = ["discord.py>=2.4", "redis>=7.2"]
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg]"]
tls = ["lacme>=1.0.4"]
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls,sandbox]"]
[project.scripts]
turnstone = "turnstone.cli:main"
@@ -78,7 +80,7 @@ include = [
"turnstone/console/static/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.40/**/*",
"turnstone/shared_static/katex-0.16.44/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.13.0/**/*",
"turnstone/sdk/py.typed",
@@ -165,6 +167,14 @@ ignore_missing_imports = true
module = ["frontmatter", "frontmatter.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["ddgs", "ddgs.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["lacme", "lacme.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["turnstone.channels.discord.*"]
disallow_subclassing_any = false
+955 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "0.8.4",
"version": "0.9.1",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
@@ -2103,6 +2103,27 @@
}
}
},
"/v1/api/models": {
"get": {
"summary": "List enabled model aliases for workstream creation",
"operationId": "v1_api_models_get",
"tags": [
"Models"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListAvailableModelsResponse"
}
}
}
}
}
}
},
"/v1/api/skills": {
"get": {
"summary": "List available skills (summary)",
@@ -3002,7 +3023,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResponse"
"$ref": "#/components/schemas/DeleteSettingResponse"
}
}
}
@@ -3453,6 +3474,473 @@
}
}
},
"/v1/api/admin/model-definitions": {
"get": {
"summary": "List model definitions with live status from cluster nodes",
"operationId": "v1_api_admin_model-definitions_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListModelDefinitionsResponse"
}
}
}
}
}
},
"post": {
"summary": "Create a model definition",
"operationId": "v1_api_admin_model-definitions_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateModelDefinitionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/reload": {
"post": {
"summary": "Tell all nodes to re-read model definitions from DB and rebuild registry",
"operationId": "v1_api_admin_model-definitions_reload_post",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelReloadResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/{definition_id}": {
"get": {
"summary": "Get a single model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"put": {
"summary": "Update a model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_put",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateModelDefinitionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"delete": {
"summary": "Delete a model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_delete",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/detect": {
"post": {
"summary": "Probe a model endpoint: verify reachability, list models, detect context window and server type",
"operationId": "v1_api_admin_model-definitions_detect_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DetectModelRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DetectModelResponse"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-capabilities": {
"get": {
"summary": "Look up static capabilities for a known model",
"operationId": "v1_api_admin_model-capabilities_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "provider",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider name"
},
{
"name": "model",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Model ID to look up"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelCapabilitiesResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-capabilities/known": {
"get": {
"summary": "List known model name prefixes for a provider",
"operationId": "v1_api_admin_model-capabilities_known_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "provider",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider name"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnownModelsResponse"
}
}
}
}
}
}
},
"/v1/api/admin/tls/ca": {
"get": {
"summary": "CA status: initialization state, CN, cert count, cert inventory",
"operationId": "v1_api_admin_tls_ca_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/v1/api/admin/tls/ca.pem": {
"get": {
"summary": "Download CA root certificate (PEM format)",
"operationId": "v1_api_admin_tls_ca.pem_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/v1/api/admin/tls/certs": {
"get": {
"summary": "List all issued TLS certificates",
"operationId": "v1_api_admin_tls_certs_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/v1/api/admin/tls/certs/{domain}/renew": {
"post": {
"summary": "Force-renew a certificate by domain",
"operationId": "v1_api_admin_tls_certs_{domain}_renew_post",
"tags": [
"Admin"
],
"parameters": [
{
"name": "domain",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/tls/certs/{domain}": {
"delete": {
"summary": "Delete a certificate by domain",
"operationId": "v1_api_admin_tls_certs_{domain}_delete",
"tags": [
"Admin"
],
"parameters": [
{
"name": "domain",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/health": {
"get": {
"summary": "Console health check",
@@ -3507,6 +3995,34 @@
"title": "StatusResponse",
"type": "object"
},
"DeleteSettingResponse": {
"description": "DELETE /v1/api/admin/settings/{key} response.",
"properties": {
"status": {
"default": "ok",
"examples": [
"ok"
],
"title": "Status",
"type": "string"
},
"key": {
"description": "Dotted setting key that was reset",
"title": "Key",
"type": "string"
},
"default": {
"description": "Registry default value the setting reverted to",
"title": "Default"
}
},
"required": [
"key",
"default"
],
"title": "DeleteSettingResponse",
"type": "object"
},
"AuthLoginRequest": {
"description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.",
"properties": {
@@ -6360,6 +6876,443 @@
"title": "McpReloadResponse",
"type": "object"
},
"ModelDefinitionInfo": {
"properties": {
"definition_id": {
"title": "Definition Id",
"type": "string"
},
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"context_window": {
"default": 32768,
"title": "Context Window",
"type": "integer"
},
"capabilities": {
"default": "{}",
"title": "Capabilities",
"type": "string"
},
"enabled": {
"default": true,
"title": "Enabled",
"type": "boolean"
},
"source": {
"default": "",
"title": "Source",
"type": "string"
},
"created_by": {
"default": "",
"title": "Created By",
"type": "string"
},
"created": {
"default": "",
"title": "Created",
"type": "string"
},
"updated": {
"default": "",
"title": "Updated",
"type": "string"
}
},
"required": [
"definition_id",
"alias",
"model"
],
"title": "ModelDefinitionInfo",
"type": "object"
},
"CreateModelDefinitionRequest": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"context_window": {
"default": 32768,
"title": "Context Window",
"type": "integer"
},
"capabilities": {
"additionalProperties": true,
"title": "Capabilities",
"type": "object"
},
"enabled": {
"default": true,
"title": "Enabled",
"type": "boolean"
}
},
"required": [
"alias",
"model"
],
"title": "CreateModelDefinitionRequest",
"type": "object"
},
"UpdateModelDefinitionRequest": {
"properties": {
"alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Alias"
},
"model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Model"
},
"provider": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Provider"
},
"base_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Base Url"
},
"api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Api Key"
},
"context_window": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Context Window"
},
"capabilities": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Capabilities"
},
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Enabled"
}
},
"title": "UpdateModelDefinitionRequest",
"type": "object"
},
"ListModelDefinitionsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
},
"title": "Models",
"type": "array"
}
},
"required": [
"models"
],
"title": "ListModelDefinitionsResponse",
"type": "object"
},
"ModelReloadResponse": {
"properties": {
"status": {
"default": "ok",
"title": "Status",
"type": "string"
},
"results": {
"additionalProperties": true,
"title": "Results",
"type": "object"
}
},
"title": "ModelReloadResponse",
"type": "object"
},
"DetectModelRequest": {
"properties": {
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"model": {
"default": "",
"title": "Model",
"type": "string"
},
"definition_id": {
"default": "",
"title": "Definition Id",
"type": "string"
}
},
"title": "DetectModelRequest",
"type": "object"
},
"DetectModelResponse": {
"properties": {
"reachable": {
"default": false,
"title": "Reachable",
"type": "boolean"
},
"model_found": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Model Found"
},
"available_models": {
"items": {
"type": "string"
},
"title": "Available Models",
"type": "array"
},
"context_window": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Context Window"
},
"server_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Server Type"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Error"
}
},
"title": "DetectModelResponse",
"type": "object"
},
"ModelCapabilitiesResponse": {
"properties": {
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
},
"known": {
"default": false,
"title": "Known",
"type": "boolean"
},
"capabilities": {
"additionalProperties": true,
"title": "Capabilities",
"type": "object"
}
},
"required": [
"model",
"provider"
],
"title": "ModelCapabilitiesResponse",
"type": "object"
},
"KnownModelsResponse": {
"properties": {
"provider": {
"title": "Provider",
"type": "string"
},
"models": {
"items": {
"type": "string"
},
"title": "Models",
"type": "array"
}
},
"required": [
"provider"
],
"title": "KnownModelsResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
}
},
"required": [
"alias",
"model",
"provider"
],
"title": "AvailableModelInfo",
"type": "object"
},
"ListAvailableModelsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/AvailableModelInfo"
},
"title": "Models",
"type": "array"
}
},
"title": "ListAvailableModelsResponse",
"type": "object"
},
"RegistrySearchResponse": {
"properties": {
"servers": {
+64 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.8.4",
"version": "0.9.1",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -458,6 +458,27 @@
}
}
},
"/v1/api/models": {
"get": {
"summary": "List available model aliases",
"operationId": "v1_api_models_get",
"tags": [
"Models"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListAvailableModelsResponse"
}
}
}
}
}
}
},
"/v1/api/auth/login": {
"post": {
"summary": "Authenticate with a token",
@@ -1223,6 +1244,12 @@
"description": "Target workstream ID",
"title": "Ws Id",
"type": "string"
},
"force": {
"default": false,
"description": "Force cancel: abandon the stuck worker thread immediately. Use when cooperative cancel has not resolved within a few seconds.",
"title": "Force",
"type": "boolean"
}
},
"required": [
@@ -1977,6 +2004,42 @@
],
"title": "ListSkillSummaryResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
}
},
"required": [
"alias",
"model",
"provider"
],
"title": "AvailableModelInfo",
"type": "object"
},
"ListAvailableModelsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/AvailableModelInfo"
},
"title": "Models",
"type": "array"
}
},
"title": "ListAvailableModelsResponse",
"type": "object"
}
}
}
+165 -130
View File
@@ -20,6 +20,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
@@ -32,6 +33,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -43,6 +45,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -55,26 +58,28 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.120.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -82,9 +87,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"cpu": [
"arm64"
],
@@ -99,9 +104,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"cpu": [
"arm64"
],
@@ -116,9 +121,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"cpu": [
"x64"
],
@@ -133,9 +138,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"cpu": [
"x64"
],
@@ -150,9 +155,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"cpu": [
"arm"
],
@@ -167,13 +172,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -184,13 +192,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -201,13 +212,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -218,13 +232,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -235,13 +252,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -252,13 +272,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -269,9 +292,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"cpu": [
"arm64"
],
@@ -286,9 +309,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"cpu": [
"wasm32"
],
@@ -303,9 +326,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"cpu": [
"arm64"
],
@@ -320,9 +343,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"cpu": [
"x64"
],
@@ -337,9 +360,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"dev": true,
"license": "MIT"
},
@@ -387,31 +410,31 @@
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.1",
"@vitest/spy": "4.1.2",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -432,26 +455,26 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.1",
"@vitest/utils": "4.1.2",
"pathe": "^2.0.3"
},
"funding": {
@@ -459,14 +482,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"@vitest/utils": "4.1.1",
"@vitest/pretty-format": "4.1.2",
"@vitest/utils": "4.1.2",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -475,9 +498,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -485,15 +508,15 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"@vitest/pretty-format": "4.1.2",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -739,6 +762,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -760,6 +786,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -781,6 +810,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -802,6 +834,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -912,9 +947,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -954,14 +989,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.120.0",
"@rolldown/pluginutils": "1.0.0-rc.10"
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -970,21 +1005,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/siginfo": {
@@ -1085,16 +1120,16 @@
}
},
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.10",
"rolldown": "1.0.0-rc.12",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -1163,19 +1198,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.1",
"@vitest/mocker": "4.1.1",
"@vitest/pretty-format": "4.1.1",
"@vitest/runner": "4.1.1",
"@vitest/snapshot": "4.1.1",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"@vitest/expect": "4.1.2",
"@vitest/mocker": "4.1.2",
"@vitest/pretty-format": "4.1.2",
"@vitest/runner": "4.1.2",
"@vitest/snapshot": "4.1.2",
"@vitest/spy": "4.1.2",
"@vitest/utils": "4.1.2",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1186,7 +1221,7 @@
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
@@ -1203,10 +1238,10 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.1",
"@vitest/browser-preview": "4.1.1",
"@vitest/browser-webdriverio": "4.1.1",
"@vitest/ui": "4.1.1",
"@vitest/browser-playwright": "4.1.2",
"@vitest/browser-preview": "4.1.2",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/ui": "4.1.2",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+16
View File
@@ -1,6 +1,15 @@
import { TurnstoneAPIError } from "./errors.js";
import { parseSSEStream } from "./sse.js";
export interface TlsOptions {
/** Path to CA certificate PEM file (Node.js only). */
caCert?: string;
/** Path to client certificate PEM file for mTLS (Node.js only). */
clientCert?: string;
/** Path to client key PEM file for mTLS (Node.js only). */
clientKey?: string;
}
export interface ClientOptions {
/** Server base URL (e.g. "http://localhost:8080"). */
baseUrl: string;
@@ -8,6 +17,13 @@ export interface ClientOptions {
token?: string;
/** Custom fetch implementation (defaults to globalThis.fetch). */
fetch?: typeof globalThis.fetch;
/**
* TLS certificate paths for documentation and tooling.
* The SDK does not read these directly pass a custom `fetch`
* configured with your runtime's TLS agent (e.g. Node.js https.Agent).
* See docs/tls.md for examples.
*/
tls?: TlsOptions;
}
export interface RequestOptions {
+5 -1
View File
@@ -44,6 +44,7 @@ import type {
OrgInfo,
RoleInfo,
ScheduleInfo,
DeleteSettingResponse,
SettingInfo,
StatusResponse,
ToolPolicyInfo,
@@ -394,7 +395,10 @@ export class TurnstoneConsole extends BaseClient {
});
}
async deleteSetting(key: string, nodeId?: string): Promise<StatusResponse> {
async deleteSetting(
key: string,
nodeId?: string,
): Promise<DeleteSettingResponse> {
const params: Record<string, string> = {};
if (nodeId) params.node_id = nodeId;
return this.request("DELETE", `/v1/api/admin/settings/${key}`, {
+1
View File
@@ -59,6 +59,7 @@ export interface ToolResultEvent {
call_id: string;
name: string;
output: string;
is_error?: boolean;
}
export interface ToolOutputChunkEvent {
+2 -1
View File
@@ -19,7 +19,7 @@
// Clients
export { TurnstoneServer } from "./server.js";
export { TurnstoneConsole } from "./console.js";
export type { ClientOptions } from "./base.js";
export type { ClientOptions, TlsOptions } from "./base.js";
// Errors
export { TurnstoneAPIError } from "./errors.js";
@@ -98,6 +98,7 @@ export type {
AuthLoginResponse,
AuthStatusResponse,
AuthSetupResponse,
DeleteSettingResponse,
StatusResponse,
ErrorResponse,
ClusterOverviewResponse,
+7 -4
View File
@@ -93,10 +93,13 @@ export class TurnstoneServer extends BaseClient {
});
}
async cancel(wsId: string): Promise<StatusResponse> {
return this.request("POST", "/v1/api/cancel", {
json: { ws_id: wsId },
});
async cancel(
wsId: string,
opts?: { force?: boolean },
): Promise<StatusResponse> {
const body: Record<string, unknown> = { ws_id: wsId };
if (opts?.force) body.force = true;
return this.request("POST", "/v1/api/cancel", { json: body });
}
// -- Streaming ------------------------------------------------------------
+6
View File
@@ -10,6 +10,12 @@ export interface StatusResponse {
status: string;
}
export interface DeleteSettingResponse {
status: string;
key: string;
default: unknown;
}
export interface AuthLoginRequest {
token: string;
}
+344 -48
View File
@@ -1,5 +1,5 @@
{
"description": "turnstone behavior tests tool selection, sequencing, and multi-step reasoning",
"description": "turnstone behavior tests \u2014 tool selection, sequencing, and multi-step reasoning",
"defaults": {
"n_runs": 5,
"max_turns": 15
@@ -8,35 +8,91 @@
{
"id": "read-before-edit",
"description": "Must read_file before edit_file on the same path",
"user_prompt": "Fix the typo in config.py change 'recieve' to 'receive'",
"user_prompt": "Fix the typo in config.py \u2014 change 'recieve' to 'receive'",
"setup": {
"files": {
"config.py": "# Config module\ndef recieve_data(source):\n \"\"\"Recieve data from source.\"\"\"\n return source.read()\n"
}
},
"expected_actions": [
{ "tool": "read_file", "args": { "path": "config.py" } },
{ "tool": "edit_file", "args": { "path": "config.py" } }
{
"tool": "read_file",
"args": {
"path": "config.py"
}
},
{
"tool": "edit_file",
"args": {
"path": "config.py"
}
}
],
"match_mode": "ordered_subset"
"match_mode": "ordered_subset",
"user_prompts": [
"Fix the typo in config.py \u2014 change 'recieve' to 'receive'",
"In config.py, correct the misspelling of 'recieve' to 'receive'",
"Please update config.py by replacing 'recieve' with the correct spelling 'receive'",
"There's a typo in config.py: 'recieve' should be 'receive'. Please fix it.",
"Could you change 'recieve' to 'receive' in config.py?",
"Go ahead and fix 'recieve' \u2192 'receive' in config.py",
"I need the word 'recieve' corrected to 'receive' in the file config.py",
"config.py has a spelling error \u2014 'recieve' needs to be changed to 'receive'",
"Kindly rectify the typographical error in config.py, replacing 'recieve' with 'receive'",
"Hey, swap 'recieve' for 'receive' in config.py"
]
},
{
"id": "write-file-not-bash",
"description": "Use write_file for file creation, not bash echo/cat",
"user_prompt": "Create a file called hello.py that prints hello world",
"expected_actions": [
{ "tool": "write_file", "args_pattern": { "path": "hello\\.py" } }
{
"tool": "write_file",
"args_pattern": {
"path": "hello\\.py"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Create a file called hello.py that prints hello world",
"Make a hello.py file that outputs hello world",
"Please write a Python file named hello.py which prints hello world",
"I need a file called hello.py that prints hello world",
"Could you create hello.py with code that prints hello world?",
"Write hello.py \u2014 it should print hello world",
"Generate a hello.py file that outputs \"hello world\"",
"I'd like you to create a file named hello.py that prints hello world",
"Set up a file called hello.py to print hello world",
"Kindly produce a hello.py file whose purpose is to print hello world"
]
},
{
"id": "bash-for-commands",
"description": "Use bash for running system commands",
"user_prompt": "What Python version is installed?",
"expected_actions": [
{ "tool": "bash", "args_pattern": { "command": "python" } }
{
"tool": "bash",
"args_pattern": {
"command": "python"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"What Python version is installed?",
"Check which version of Python is currently installed",
"Can you tell me the installed Python version?",
"python --version please",
"I need to know what version of Python is on this system",
"Which Python version do we have?",
"Could you look up the Python version that's installed here?",
"Determine the currently installed Python version",
"What's the Python version on this machine?",
"Please check the Python version"
]
},
{
"id": "search-for-patterns",
@@ -49,13 +105,30 @@
}
},
"expected_actions": [
{ "tool": "search", "args_pattern": { "query": "test_" } }
{
"tool": "search",
"args_pattern": {
"query": "test_"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Find all functions that start with 'test_' in the project",
"List every function in the project whose name begins with 'test_'",
"I need to locate all functions prefixed with 'test_' across the project",
"Could you search the project for any functions starting with 'test_'?",
"Show me all the test_ prefixed functions in this project",
"Hunt down every function that has a 'test_' prefix in the codebase",
"I'm looking for all functions named test_* throughout the project",
"Search the entire project for functions whose names start with test_",
"What functions beginning with 'test_' exist in this project?",
"Please identify all functions with the 'test_' prefix in the project files"
]
},
{
"id": "multi-file-edit",
"description": "Read and edit multiple files must read before editing each, and edit both",
"description": "Read and edit multiple files \u2014 must read before editing each, and edit both",
"user_prompt": "Change the default port from 8000 to 9000 in both server.py and config.py",
"setup": {
"files": {
@@ -64,12 +137,32 @@
}
},
"expected_actions": [
{ "tool": "read_file" },
{ "tool": "read_file" },
{ "tool": "edit_file" },
{ "tool": "edit_file" }
{
"tool": "read_file"
},
{
"tool": "read_file"
},
{
"tool": "edit_file"
},
{
"tool": "edit_file"
}
],
"match_mode": "ordered_subset"
"match_mode": "ordered_subset",
"user_prompts": [
"Change the default port from 8000 to 9000 in both server.py and config.py",
"Update the default port to 9000 instead of 8000 in server.py and config.py",
"Could you modify the port number from 8000 to 9000 in both config.py and server.py?",
"Please replace port 8000 with 9000 in server.py and config.py",
"I need the default port switched from 8000 to 9000 in both server.py and config.py",
"In server.py and config.py, the default port should be changed from 8000 to 9000",
"Swap out port 8000 for 9000 in config.py and server.py",
"Would you mind updating the default port value from 8000 to 9000 across both server.py and config.py?",
"The default port in server.py and config.py needs to be 9000 instead of 8000 \u2014 please make that change",
"Go ahead and change 8000 to 9000 for the default port in both server.py and config.py"
]
},
{
"id": "search-then-edit",
@@ -83,51 +176,147 @@
}
},
"expected_actions": [
{ "tool": "search", "args_pattern": { "query": "MAX_RETRIES" } },
{ "tool": "read_file" },
{ "tool": "edit_file", "args_pattern": { "old_string": "3" } }
{
"tool": "search",
"args_pattern": {
"query": "MAX_RETRIES"
}
},
{
"tool": "read_file"
},
{
"tool": "edit_file",
"args_pattern": {
"old_string": "3"
}
}
],
"match_mode": "ordered_subset"
"match_mode": "ordered_subset",
"user_prompts": [
"Find where MAX_RETRIES is defined and change it from 3 to 5",
"Locate the definition of MAX_RETRIES and update its value from 3 to 5",
"Could you search for where MAX_RETRIES is defined and modify it from 3 to 5?",
"I need MAX_RETRIES changed from 3 to 5 \u2014 find where it's defined and update it",
"Please find the MAX_RETRIES definition and bump it from 3 to 5",
"Hunt down MAX_RETRIES in the codebase and change its value from 3 to 5",
"Where is MAX_RETRIES set to 3? Change it to 5.",
"Search the code for the MAX_RETRIES definition and alter it from 3 to 5",
"I'd like you to locate MAX_RETRIES (currently 3) and set it to 5 instead",
"Go find MAX_RETRIES and switch it from 3 to 5"
]
},
{
"id": "bash-git-log",
"description": "Use bash for git commands, not other tools",
"user_prompt": "Show me the git log for the last 5 commits",
"expected_actions": [
{ "tool": "bash", "args_pattern": { "command": "git\\s+log" } }
{
"tool": "bash",
"args_pattern": {
"command": "git\\s+log"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Show me the git log for the last 5 commits",
"Display the 5 most recent git commits",
"Can you pull up the git log limited to the last five commits?",
"I need to see the git log showing only the previous 5 commits",
"git log for the 5 latest commits, please",
"Would you mind showing me the last five entries in the git log?",
"Print out the most recent 5 commits from the git log",
"I'd like to review the git log \u2014 just the last 5 commits",
"Show the recent 5 commit history using git log",
"Could you display the git commit history for the past five commits?"
]
},
{
"id": "write-then-run",
"description": "Create a script and run it to verify it works",
"user_prompt": "Create a Python script called fib.py that prints the first 10 Fibonacci numbers, then run it to verify",
"expected_actions": [
{ "tool": "write_file", "args_pattern": { "path": "fib\\.py" } },
{ "tool": "bash", "args_pattern": { "command": "python" } }
{
"tool": "write_file",
"args_pattern": {
"path": "fib\\.py"
}
},
{
"tool": "bash",
"args_pattern": {
"command": "python"
}
}
],
"match_mode": "ordered_subset"
"match_mode": "ordered_subset",
"user_prompts": [
"Create a Python script called fib.py that prints the first 10 Fibonacci numbers, then run it to verify",
"Write a Python file named fib.py that outputs the first 10 Fibonacci numbers, and execute it to confirm it works",
"Please make fib.py \u2013 a Python script printing the first ten Fibonacci numbers \u2013 then run it to check the output",
"I need a Python script fib.py that prints the first 10 Fibonacci numbers. Execute it afterwards to verify correctness.",
"Could you create fib.py to display the first 10 Fibonacci numbers in Python, and then run it to make sure it works?",
"Draft a script called fib.py in Python that outputs the first ten Fibonacci numbers, then execute it to validate",
"Hey, write me a fib.py that prints the first 10 Fibonacci numbers and run it so we can see it works",
"Generate a Python program fib.py which prints the initial 10 Fibonacci numbers, and verify by running it",
"Kindly produce a Python script named fib.py to print the first 10 Fibonacci numbers, then execute the script to confirm its output",
"Make a file fib.py containing Python code to print the first 10 Fibonacci numbers. Then run it to verify."
]
},
{
"id": "no-bash-for-file-write",
"description": "Should NOT use bash (echo/cat/heredoc) to create files only write_file",
"description": "Should NOT use bash (echo/cat/heredoc) to create files \u2014 only write_file",
"user_prompt": "Create a new file called README.md with a title and description of this project",
"expected_actions": [
{ "tool": "write_file", "args_pattern": { "path": "README" } }
{
"tool": "write_file",
"args_pattern": {
"path": "README"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Create a new file called README.md with a title and description of this project",
"Make a README.md file that includes a project title and description",
"I need a README.md created with a title and a brief description of the project",
"Please generate a README.md file containing the project's title and description",
"Could you set up a README.md with a title and project description?",
"Write a README.md that has a title and describes this project",
"Go ahead and create README.md \u2014 it should have a title and a description of the project",
"I'd like you to produce a new README.md file featuring a project title and description",
"Kindly establish a README.md file incorporating both a title and a description for this project",
"Spin up a README.md with a project title and description in it"
]
},
{
"id": "plan-before-refactor",
"description": "Use the plan tool before a large refactoring task",
"user_prompt": "I need to refactor this codebase to separate the database layer from the API layer. Use the plan tool to think through the approach before making any changes.",
"id": "plan-when-asked",
"description": "Call the plan tool when the user asks to plan",
"user_prompt": "Plan how to add user authentication to this app.",
"setup": {
"files": {
"app.py": "import sqlite3\nfrom flask import Flask, jsonify\n\napp = Flask(__name__)\nDB = 'data.db'\n\ndef get_db():\n return sqlite3.connect(DB)\n\n@app.route('/users')\ndef list_users():\n db = get_db()\n users = db.execute('SELECT * FROM users').fetchall()\n db.close()\n return jsonify(users)\n\n@app.route('/users/<int:uid>')\ndef get_user(uid):\n db = get_db()\n user = db.execute('SELECT * FROM users WHERE id=?', (uid,)).fetchone()\n db.close()\n return jsonify(user)\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
"app.py": "from flask import Flask, jsonify\n\napp = Flask(__name__)\n\n@app.route('/users')\ndef list_users():\n return jsonify([{'id': 1, 'name': 'Alice'}])\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
}
},
"expected_actions": [{ "tool": "create_plan" }],
"match_mode": "subset"
"expected_actions": [
{
"tool": "plan_agent"
}
],
"match_mode": "subset",
"user_prompts": [
"Plan how to add user authentication to this app.",
"Make a plan for adding pagination to the API endpoints.",
"Plan out how to add error handling to this application.",
"I need a plan for adding logging to this codebase.",
"Plan the approach for adding unit tests to this app.",
"How would you approach adding user authentication to this app? Lay out a plan.",
"I'd like you to outline a strategy for implementing user authentication in this application.",
"Could you come up with a plan for integrating user authentication into this app?",
"Think through the steps needed to add user auth to this app and present a plan.",
"Draft a plan for incorporating user authentication functionality into this application."
]
},
{
"id": "edit-not-rewrite",
@@ -139,10 +328,32 @@
}
},
"expected_actions": [
{ "tool": "read_file", "args": { "path": "utils.py" } },
{ "tool": "edit_file", "args": { "path": "utils.py" } }
{
"tool": "read_file",
"args": {
"path": "utils.py"
}
},
{
"tool": "edit_file",
"args": {
"path": "utils.py"
}
}
],
"match_mode": "ordered_subset"
"match_mode": "ordered_subset",
"user_prompts": [
"Add a docstring to the process_data function in utils.py",
"Please add a docstring to the process_data function in utils.py",
"Could you write a docstring for process_data in utils.py?",
"Insert a docstring into the process_data function found in utils.py",
"I need a docstring added to process_data in utils.py",
"Put a docstring on the process_data function in utils.py",
"The process_data function in utils.py is missing a docstring \u2014 please add one",
"Would you mind adding a docstring to process_data in utils.py?",
"In utils.py, the process_data function needs a docstring",
"Add documentation via a docstring to the process_data function within utils.py"
]
},
{
"id": "bash-run-tests",
@@ -154,45 +365,130 @@
}
},
"expected_actions": [
{ "tool": "bash", "args_pattern": { "command": "pytest|python.*test" } }
{
"tool": "bash",
"args_pattern": {
"command": "pytest|python.*test"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Run the tests",
"Execute the test suite",
"Please go ahead and run the tests",
"Could you run the tests for me?",
"I need the tests to be run",
"Kick off the tests",
"Let's run the tests",
"Fire up the tests",
"Go ahead and execute the tests",
"I'd like you to run the tests"
]
},
{
"id": "web-fetch-url",
"description": "Use web_fetch when asked to retrieve content from a URL",
"user_prompt": "Fetch the contents of https://example.com and summarize what's on the page",
"expected_actions": [
{ "tool": "web_fetch", "args_pattern": { "url": "example\\.com" } }
{
"tool": "web_fetch",
"args_pattern": {
"url": "example\\.com"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Fetch the contents of https://example.com and summarize what's on the page",
"Go to https://example.com and give me a summary of what you find there",
"Could you pull up https://example.com and tell me what the page is about?",
"Retrieve the content from https://example.com, then provide a summary of it",
"I need you to grab https://example.com and summarize its contents for me",
"Please access https://example.com and give me an overview of the page",
"What's on https://example.com? Fetch it and summarize for me.",
"Download the page at https://example.com and provide a brief summary",
"I'd like a summary of whatever is at https://example.com \u2014 please fetch it first",
"Hit https://example.com and let me know what's there in summary form"
]
},
{
"id": "man-page-lookup",
"description": "Use man tool to look up command documentation",
"user_prompt": "Look up the man page for tar and tell me what the --xattrs flag does",
"expected_actions": [
{ "tool": "man", "args_pattern": { "page": "tar" } }
{
"tool": "man",
"args_pattern": {
"page": "tar"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Look up the man page for tar and tell me what the --xattrs flag does",
"What does the --xattrs flag do in tar? Check the man page for me.",
"Could you pull up the man page for tar and explain the --xattrs option?",
"I need to know what --xattrs does in tar \u2014 can you check the man page?",
"Check tar's man page and let me know the purpose of the --xattrs flag.",
"Please consult the tar man page and describe what the --xattrs flag is for.",
"Hey, look at the tar man page real quick \u2014 what's --xattrs do?",
"I'd like you to read the tar man page and summarize the --xattrs option for me.",
"Would you mind checking the man page for tar to find out what --xattrs means?",
"Look into the tar manual and explain the --xattrs flag to me."
]
},
{
"id": "math-calculation",
"description": "Use the math tool for precise calculations, not bash or mental math",
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
"expected_actions": [
{ "tool": "math", "args_pattern": { "code": "2.*64" } }
{
"tool": "math",
"args_pattern": {
"code": "2.*64"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"What is 2^64 - 1? Use the math tool to calculate it precisely.",
"Calculate 2^64 - 1 for me using the math tool, please.",
"I need the exact value of 2^64 - 1. Please use the math tool.",
"Could you use the math tool to compute 2^64 minus 1 precisely?",
"Use the math tool to tell me what 2^64 - 1 equals.",
"I'm curious: what's 2^64 - 1? Compute it with the math tool.",
"Please precisely determine 2^64 - 1 via the math tool.",
"Mind using the math tool to figure out 2^64 - 1 exactly?",
"I'd like to know the precise result of 2^64 - 1 \u2014 use the math tool for this.",
"Leverage the math tool to give me an exact answer for 2^64 - 1."
]
},
{
"id": "web-search-query",
"description": "Use web_search for general knowledge lookups, not web_fetch",
"user_prompt": "Search the web for the current population of Tokyo",
"expected_actions": [
{ "tool": "web_search", "args_pattern": { "query": "Tokyo" } }
{
"tool": "web_search",
"args_pattern": {
"query": "Tokyo"
}
}
],
"match_mode": "subset"
"match_mode": "subset",
"user_prompts": [
"Search the web for the current population of Tokyo",
"What's Tokyo's current population? Look it up on the web.",
"Could you do a web search to find out how many people currently live in Tokyo?",
"Please search online for Tokyo's present-day population.",
"I need you to look up the current population of Tokyo on the web.",
"Find me Tokyo's current population via a web search.",
"Web search: what is the current population of Tokyo?",
"I'd like to know Tokyo's current population\u2014can you search the web for that?",
"Look up how many people live in Tokyo right now using a web search.",
"Do a web search for the population of Tokyo as of now."
]
}
]
}
+46
View File
@@ -1237,6 +1237,52 @@ class TestJWTAudienceIssuer:
result = validate_jwt(token, self.SECRET, audience="")
assert result is not None
def test_create_jwt_expiry_seconds(self):
import jwt as pyjwt
from turnstone.core.auth import create_jwt
token = create_jwt("user1", frozenset({"read"}), "test", self.SECRET, expiry_seconds=300)
payload = pyjwt.decode(
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
assert payload["exp"] - payload["iat"] == 300
def test_create_jwt_expiry_seconds_overrides_hours(self):
import jwt as pyjwt
from turnstone.core.auth import create_jwt
token = create_jwt(
"user1",
frozenset({"read"}),
"test",
self.SECRET,
expiry_hours=24,
expiry_seconds=60,
)
payload = pyjwt.decode(
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
# expiry_seconds takes precedence over expiry_hours
assert payload["exp"] - payload["iat"] == 60
def test_create_jwt_expiry_seconds_rejects_zero(self):
import pytest
from turnstone.core.auth import create_jwt
with pytest.raises(ValueError, match="expiry_seconds must be positive"):
create_jwt("user1", frozenset({"read"}), "test", self.SECRET, expiry_seconds=0)
def test_create_jwt_expiry_seconds_rejects_negative(self):
import pytest
from turnstone.core.auth import create_jwt
with pytest.raises(ValueError, match="expiry_seconds must be positive"):
create_jwt("user1", frozenset({"read"}), "test", self.SECRET, expiry_seconds=-1)
class TestServiceTokenManager:
SECRET = "test-secret-that-is-at-least-32-chars"
+7 -1
View File
@@ -39,7 +39,13 @@ def _make_bridge(**overrides) -> Bridge:
approval_timeout=1,
)
defaults.update(overrides)
return Bridge(**defaults)
bridge = Bridge(**defaults)
# Replace real httpx client with a mock so daemon threads spawned by
# _handle_approval / _handle_plan_review don't make real HTTP calls
# after the test's patch context exits.
bridge._http.close()
bridge._http = MagicMock()
return bridge
def _approval_items(tool_name: str = "bash") -> list[dict]:
+402 -2
View File
@@ -1,5 +1,6 @@
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
import contextlib
import threading
import time
from dataclasses import dataclass, field
@@ -7,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import ChatSession, GenerationCancelled
from turnstone.core.session import ChatSession, GenerationCancelled, _CancelRef
class NullUI:
@@ -36,7 +37,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
@@ -407,3 +408,402 @@ class TestStreamFlushBeforeToolCalls:
stream_end_idx = next(i for i, e in enumerate(events) if e[0] == "stream_end")
late_content = [e for e in events[stream_end_idx + 1 :] if e[0] == "content"]
assert late_content == [], f"Content after stream_end: {late_content}"
class TestStreamAbort:
"""Tests for cancel() closing the underlying SDK stream."""
def test_cancel_closes_cancel_stream(self, tmp_db):
"""cancel() calls .close() on the stored SDK stream handle."""
session = _make_session()
mock_stream = MagicMock()
session._cancel_stream = mock_stream
session.cancel()
mock_stream.close.assert_called_once()
assert session._cancel_event.is_set()
def test_cancel_without_stream_is_safe(self, tmp_db):
"""cancel() with no active stream just sets the event."""
session = _make_session()
assert session._cancel_stream is None
session.cancel() # Should not raise
assert session._cancel_event.is_set()
def test_cancel_stream_close_error_suppressed(self, tmp_db):
"""Errors from stream.close() are suppressed."""
session = _make_session()
mock_stream = MagicMock()
mock_stream.close.side_effect = RuntimeError("already closed")
session._cancel_stream = mock_stream
session.cancel() # Should not raise
assert session._cancel_event.is_set()
def test_cancel_ref_populated_after_first_chunk(self, tmp_db):
"""_cancel_ref is populated by the provider after the first chunk
arrives (lazy generator evaluation)."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
sdk_stream = MagicMock()
def fake_provider_stream():
# Simulate provider appending to cancel_ref before first yield
session._cancel_ref.append(sdk_stream)
yield FakeChunk(content_delta="hi", finish_reason="stop")
with (
patch.object(
session,
"_create_stream_with_retry",
return_value=fake_provider_stream(),
),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# After stream completes, cancel_stream should be cleared
assert session._cancel_stream is None
assert len(session._cancel_ref) == 0
def test_transport_error_during_cancel_becomes_generation_cancelled(self, tmp_db):
"""When cancel() closes the stream, the resulting transport error
is converted to GenerationCancelled."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
def stream_that_errors():
yield FakeChunk(content_delta="Hello")
session._cancel_event.set()
raise ConnectionError("stream closed")
with (
patch.object(
session,
"_create_stream_with_retry",
return_value=stream_that_errors(),
),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# Should complete as cancelled, not error
assert "idle" in ui.states
assert any("cancelled" in i.lower() for i in ui.infos)
# Partial content preserved
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert len(assistant_msgs) == 1
assert assistant_msgs[0]["content"] == "Hello"
def test_non_cancel_exception_not_swallowed(self, tmp_db):
"""Exceptions during streaming that aren't caused by cancel
should propagate normally."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
def stream_that_errors():
yield FakeChunk(content_delta="Hello")
raise ValueError("unexpected error")
with (
patch.object(
session,
"_create_stream_with_retry",
return_value=stream_that_errors(),
),
patch.object(session, "_full_messages", return_value=[]),
pytest.raises(ValueError, match="unexpected error"),
):
session.send("test")
def test_check_cancelled_between_retries(self, tmp_db):
"""_try_stream checks for cancellation between retry attempts."""
session = _make_session()
session.cancel()
with pytest.raises(GenerationCancelled):
session._try_stream(
client=MagicMock(),
model="test",
msgs=[],
)
class TestCancelRef:
"""Tests for the _CancelRef list proxy."""
def test_append_sets_cancel_stream(self, tmp_db):
"""Appending a stream handle to _CancelRef sets _cancel_stream eagerly."""
session = _make_session()
mock_stream = MagicMock()
assert session._cancel_stream is None
session._cancel_ref.append(mock_stream)
assert session._cancel_stream is mock_stream
def test_append_closes_stream_when_already_cancelled(self, tmp_db):
"""If cancel is already set when a stream is appended, it is closed immediately."""
session = _make_session()
session.cancel() # Set cancel event before stream is created
mock_stream = MagicMock()
session._cancel_ref.append(mock_stream)
mock_stream.close.assert_called_once()
def test_append_does_not_close_stream_when_not_cancelled(self, tmp_db):
"""Stream is not closed if cancel hasn't been requested."""
session = _make_session()
mock_stream = MagicMock()
session._cancel_ref.append(mock_stream)
mock_stream.close.assert_not_called()
assert session._cancel_stream is mock_stream
def test_append_close_error_suppressed(self, tmp_db):
"""Errors from stream.close() during eager close are suppressed."""
session = _make_session()
session.cancel()
mock_stream = MagicMock()
mock_stream.close.side_effect = RuntimeError("already closed")
session._cancel_ref.append(mock_stream) # Should not raise
def test_cancel_ref_is_cancel_ref_instance(self, tmp_db):
"""ChatSession._cancel_ref is a _CancelRef instance."""
session = _make_session()
assert isinstance(session._cancel_ref, _CancelRef)
def test_cancel_ref_cleared_after_stream_ends(self, tmp_db):
"""_cancel_ref is cleared in the send() finally block after streaming."""
ui = NullUI()
session = _make_session(ui=ui)
mock_stream = MagicMock()
session._cancel_ref.append(mock_stream)
assert len(session._cancel_ref) == 1
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
with (
patch.object(
session,
"_create_stream_with_retry",
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# After send() completes, _cancel_ref is cleared in the finally block
assert len(session._cancel_ref) == 0
class TestForceCancelGeneration:
"""Tests for per-generation tracking that prevents orphaned-thread side-effects."""
def test_check_cancelled_raises_for_orphaned_generation(self, tmp_db):
"""_check_cancelled raises GenerationCancelled when my_generation is stale."""
session = _make_session()
session._generation = 2 # Simulate two generations having run
with pytest.raises(GenerationCancelled):
session._check_cancelled(my_generation=1) # Generation 1 is orphaned
def test_check_cancelled_ok_for_current_generation(self, tmp_db):
"""_check_cancelled does not raise when my_generation matches current."""
session = _make_session()
session._generation = 3
session._check_cancelled(my_generation=3) # Should not raise
def test_force_cancel_orphaned_thread_does_not_mutate_messages(self, tmp_db):
"""An abandoned generation (force-cancel) cannot append to session.messages."""
ui = NullUI()
session = _make_session(ui=ui)
# We can't trivially test the full threading scenario in a unit test,
# so directly verify that _check_cancelled raises when my_generation
# is stale, which is what guards _stream_response against orphaned
# (force-cancelled) threads continuing to mutate messages.
session._generation = 5
with pytest.raises(GenerationCancelled):
session._check_cancelled(my_generation=4) # orphaned generation
def test_new_cancel_event_per_generation_in_send(self, tmp_db):
"""send() replaces _cancel_event with a fresh Event each generation."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
original_event = session._cancel_event
with (
patch.object(
session,
"_create_stream_with_retry",
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("test")
# After send() completes, _cancel_event should be a NEW Event
# (not the same object as before the call).
assert session._cancel_event is not original_event
assert not session._cancel_event.is_set()
class TestForceCancelThreaded:
"""Force cancel with actual threads — verifies orphaned thread behavior."""
def test_force_cancel_orphan_does_not_mutate_messages(self, tmp_db):
"""After force cancel + new send(), the orphaned thread must not
append stale content to session.messages."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = ""
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
barrier = threading.Event()
old_done = threading.Event()
def slow_stream():
yield FakeChunk(content_delta="Old content")
barrier.set() # signal: first chunk delivered
time.sleep(2) # simulate stuck stream
yield FakeChunk(content_delta=" more", finish_reason="stop")
# Start generation 1 (will get stuck)
with (
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
patch.object(session, "_full_messages", return_value=[]),
):
def run_old():
with contextlib.suppress(Exception):
session.send("old message")
old_done.set()
t1 = threading.Thread(target=run_old, daemon=True)
t1.start()
assert barrier.wait(timeout=5), "stream did not start"
# Force cancel: simulate what the server does
session.cancel()
# Increment generation as new send() would
session._generation += 1
session._cancel_event = threading.Event()
# Wait for old thread to notice generation mismatch and exit
assert old_done.wait(timeout=10), "orphaned thread did not exit"
# The orphaned thread should NOT have appended its content
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
# May have partial content from before cancel, but NOT the full
# "Old content more" that would appear without the generation guard
for msg in assistant_msgs:
assert "more" not in msg.get("content", "")
def test_force_cancel_then_new_send_succeeds(self, tmp_db):
"""A new send() after force cancel works cleanly."""
ui = NullUI()
session = _make_session(ui=ui)
@dataclass
class FakeChunk:
content_delta: str = ""
reasoning_delta: str = ""
tool_call_deltas: list = field(default_factory=list)
usage: None = None
finish_reason: str = "stop"
info_delta: str = ""
provider_blocks: list = field(default_factory=list)
barrier = threading.Event()
def stuck_stream():
yield FakeChunk(content_delta="stuck")
barrier.set()
time.sleep(2)
yield FakeChunk(content_delta=" end", finish_reason="stop")
# Start stuck generation
with (
patch.object(session, "_create_stream_with_retry", return_value=stuck_stream()),
patch.object(session, "_full_messages", return_value=[]),
):
t = threading.Thread(target=lambda: session.send("old"), daemon=True)
t.start()
assert barrier.wait(timeout=5), "stream did not start"
# Force cancel
session.cancel()
# New generation should work
fresh_stream = iter([FakeChunk(content_delta="Fresh response")])
with (
patch.object(session, "_create_stream_with_retry", return_value=fresh_stream),
patch.object(session, "_full_messages", return_value=[]),
):
session.send("new message")
# The new generation should have completed successfully
assert "idle" in ui.states
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
assert any("Fresh response" in m.get("content", "") for m in assistant_msgs)
+1
View File
@@ -390,6 +390,7 @@ class TestApprovalVerdictDisplay:
bot.config.streaming_edit_interval = 1.5
bot.config.auto_approve = False
bot.config.auto_approve_tools = []
bot.storage = None
bot._streaming = {}
bot._pending_approval_msgs = {}
bot._notify_reply_channels = {}
+230
View File
@@ -1699,6 +1699,236 @@ class TestSSEProxy:
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Proxy auth header propagation
# ---------------------------------------------------------------------------
class TestProxyAuthHeaders:
"""Verify _proxy_auth_headers mints user-scoped JWTs for proxy requests."""
SECRET = "test-secret-that-is-at-least-32-chars"
def _make_request(
self, *, auth_result=None, jwt_secret="", proxy_token_mgr=None, proxy_auth_token=""
):
"""Build a minimal fake request for _proxy_auth_headers."""
class _State:
pass
class _AppState:
pass
class _App:
state = _AppState()
class _Request:
state = _State()
app = _App()
req = _Request()
req.state.auth_result = auth_result
req.app.state.jwt_secret = jwt_secret
req.app.state.proxy_token_mgr = proxy_token_mgr
req.app.state.proxy_auth_token = proxy_auth_token
return req
def test_mints_user_jwt(self):
"""Real user auth_result → JWT with correct sub, scopes, src, aud, permissions."""
import jwt as pyjwt
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import JWT_AUD_SERVER, AuthResult
auth = AuthResult(
user_id="alice",
scopes=frozenset({"read", "write"}),
token_source="jwt",
permissions=frozenset({"admin.users"}),
)
req = self._make_request(auth_result=auth, jwt_secret=self.SECRET)
headers = _proxy_auth_headers(req)
assert "Authorization" in headers
token = headers["Authorization"].removeprefix("Bearer ")
payload = pyjwt.decode(token, self.SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER)
assert payload["sub"] == "alice"
assert set(payload["scopes"].split(",")) == {"read", "write"}
assert payload["src"] == "console-proxy"
assert payload["aud"] == JWT_AUD_SERVER
assert payload["permissions"] == "admin.users"
def test_narrows_scopes(self):
"""Read-only user → JWT carries only read scope, not full {read,write,approve}."""
import jwt as pyjwt
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import JWT_AUD_SERVER, AuthResult
auth = AuthResult(
user_id="viewer",
scopes=frozenset({"read"}),
token_source="jwt",
)
req = self._make_request(auth_result=auth, jwt_secret=self.SECRET)
headers = _proxy_auth_headers(req)
token = headers["Authorization"].removeprefix("Bearer ")
payload = pyjwt.decode(token, self.SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER)
assert payload["scopes"] == "read"
def test_short_expiry(self):
"""Minted JWT expires in 300 seconds, not hours."""
import jwt as pyjwt
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="alice",
scopes=frozenset({"read"}),
token_source="jwt",
)
req = self._make_request(auth_result=auth, jwt_secret=self.SECRET)
headers = _proxy_auth_headers(req)
token = headers["Authorization"].removeprefix("Bearer ")
payload = pyjwt.decode(
token, self.SECRET, algorithms=["HS256"], options={"verify_aud": False}
)
assert payload["exp"] - payload["iat"] == 300
def test_fallback_no_user(self):
"""No auth_result → falls back to ServiceTokenManager."""
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import ServiceTokenManager
mgr = ServiceTokenManager(
user_id="console-proxy",
scopes=frozenset({"read", "write", "approve"}),
source="console",
secret=self.SECRET,
)
req = self._make_request(proxy_token_mgr=mgr)
headers = _proxy_auth_headers(req)
assert "Authorization" in headers
assert headers["Authorization"] == f"Bearer {mgr.token}"
def test_fallback_no_secret(self):
"""auth_result present but empty jwt_secret → falls back to ServiceTokenManager."""
from turnstone.console.server import _proxy_auth_headers
from turnstone.core.auth import AuthResult, ServiceTokenManager
auth = AuthResult(
user_id="alice",
scopes=frozenset({"read"}),
token_source="jwt",
)
mgr = ServiceTokenManager(
user_id="console-proxy",
scopes=frozenset({"read", "write", "approve"}),
source="console",
secret=self.SECRET,
)
req = self._make_request(auth_result=auth, jwt_secret="", proxy_token_mgr=mgr)
headers = _proxy_auth_headers(req)
# 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."""
from turnstone.console.server import _proxy_auth_headers
req = self._make_request(proxy_auth_token="static-tok-123")
headers = _proxy_auth_headers(req)
assert headers == {"Authorization": "Bearer static-tok-123"}
# ---------------------------------------------------------------------------
# Server: trusted user_id forwarding on create_workstream
# ---------------------------------------------------------------------------
class TestCreateWorkstreamUserIdTrust:
"""Verify that only trusted service tokens can forward user_id in create_workstream."""
def _extract_uid(self, body: dict, auth_result) -> str:
"""Replicate the trust check from server.py:create_workstream."""
auth = auth_result
uid: str = getattr(auth, "user_id", "") or ""
trusted_sources = {"bridge", "console"}
if (
body.get("user_id")
and isinstance(body["user_id"], str)
and auth is not None
and auth.token_source in trusted_sources
):
uid = body["user_id"]
return uid
def test_bridge_can_forward_user_id(self):
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="bridge",
scopes=frozenset({"approve"}),
token_source="bridge",
)
uid = self._extract_uid({"user_id": "real-user-abc"}, auth)
assert uid == "real-user-abc"
def test_console_service_can_forward_user_id(self):
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="console",
scopes=frozenset({"approve"}),
token_source="console",
)
uid = self._extract_uid({"user_id": "real-user-abc"}, auth)
assert uid == "real-user-abc"
def test_console_proxy_user_cannot_override_user_id(self):
"""End-user tokens via console-proxy must NOT override user_id."""
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="real-user-abc",
scopes=frozenset({"read", "write"}),
token_source="console-proxy",
)
uid = self._extract_uid({"user_id": "impersonated-user"}, auth)
# Should use JWT identity, NOT the body override
assert uid == "real-user-abc"
def test_direct_user_cannot_override_user_id(self):
"""Direct JWT login must NOT override user_id."""
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="real-user-abc",
scopes=frozenset({"read", "write"}),
token_source="password",
)
uid = self._extract_uid({"user_id": "impersonated-user"}, auth)
assert uid == "real-user-abc"
def test_no_body_user_id_uses_jwt(self):
from turnstone.core.auth import AuthResult
auth = AuthResult(
user_id="bridge",
scopes=frozenset({"approve"}),
token_source="bridge",
)
uid = self._extract_uid({"name": "test-ws"}, auth)
assert uid == "bridge"
# ---------------------------------------------------------------------------
# Collector — MCP aggregation in get_overview()
# ---------------------------------------------------------------------------
+460
View File
@@ -0,0 +1,460 @@
"""Tests for edit_file tool — single edit and batch edit modes."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from turnstone.core.session import ChatSession
@pytest.fixture
def session(tmp_db, mock_openai_client):
"""Create a ChatSession wired to a temp database."""
return ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
@pytest.fixture
def sample_file(tmp_path):
"""Create a sample file and return its path."""
p = tmp_path / "test.py"
p.write_text("line1\nline2\nline3\nline4\nline5\n")
return str(p)
def _mark_read(session: ChatSession, path: str) -> None:
"""Simulate a prior read_file so the edit guard passes."""
resolved = os.path.realpath(os.path.expanduser(path))
session._read_files.add(resolved)
# ── Single edit (backward compat) ────────────────────────────────────
class TestSingleEdit:
def test_basic_replace(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
assert result["func_name"] == "edit_file"
call_id, msg = session._exec_edit_file(result)
assert call_id == "c1"
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nreplaced\nline3\nline4\nline5\n"
def test_missing_path(self, session):
result = session._prepare_edit_file(
"c1",
{
"old_string": "a",
"new_string": "b",
},
)
assert result.get("error")
assert "missing path" in result["error"]
def test_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"new_string": "b",
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_identical_strings(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "line1",
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_old_string_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "nonexistent",
"new_string": "replaced",
},
)
assert result.get("error")
assert "not found" in result["error"]
def test_must_read_first(self, session, sample_file):
# Don't call _mark_read — should fail
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
},
)
assert result.get("error")
assert "must read_file" in result["error"]
def test_multiple_occurrences_without_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
},
)
assert result.get("error")
assert "found 2 times" in result["error"]
def test_near_line_disambiguates(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
"near_line": 3,
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(path) as f:
assert f.read() == "foo\nbar\nbaz\n"
def test_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line3\n",
"new_string": "",
},
)
assert result["needs_approval"]
assert "deletion" in result["preview"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline2\nline4\nline5\n"
# ── Batch edits ──────────────────────────────────────────────────────
class TestBatchEdit:
def test_two_edits_applied_atomically(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
assert "2 edits" in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(sample_file) as f:
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
def test_three_edits_middle_of_file(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2", "new_string": "second"},
{"old_string": "line3", "new_string": "third"},
{"old_string": "line4", "new_string": "fourth"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 3 edits" in msg
with open(sample_file) as f:
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
def test_overlapping_edits_rejected(self, session, tmp_path):
p = tmp_path / "overlap.txt"
p.write_text("abcdefgh\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "abcdef", "new_string": "XXX"},
{"old_string": "defgh", "new_string": "YYY"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "overlap" in msg.lower()
# File should be untouched
with open(path) as f:
assert f.read() == "abcdefgh\n"
def test_batch_edit_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "nonexistent", "new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "not found" in result["error"]
def test_batch_edit_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "old_string" in result["error"]
def test_batch_edit_identical_strings(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "line1"},
],
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_batch_with_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\nbaz\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "foo", "new_string": "first_foo", "near_line": 1},
{"old_string": "foo", "new_string": "second_foo", "near_line": 3},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(path) as f:
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
def test_batch_with_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2\n", "new_string": ""},
{"old_string": "line4\n", "new_string": ""},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline3\nline5\n"
def test_single_item_edits_array(self, session, sample_file):
"""An edits array with one item should work like a single edit."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line3", "new_string": "middle"},
],
},
)
assert result["needs_approval"]
# Single edit — no "(N edits)" count in header
assert "edits)" not in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
# ── Mutual exclusivity ──────────────────────────────────────────────
class TestMutualExclusivity:
def test_both_single_and_batch_rejected(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
"edits": [
{"old_string": "line2", "new_string": "also_replaced"},
],
},
)
assert result.get("error")
assert "not both" in result["error"]
def test_neither_single_nor_batch(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_empty_edits_array_falls_through_to_single(self, session, sample_file):
"""An empty edits array should be treated as no batch."""
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [],
},
)
# Falls through to single-edit path, which requires old_string
assert result.get("error")
assert "old_string" in result["error"]
# ── TOCTOU edge cases ───────────────────────────────────────────────
class TestExecEdgeCases:
def test_file_changed_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
# Modify the file after prepare
with open(sample_file, "w") as f:
f.write("completely different content\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
os.unlink(sample_file)
call_id, msg = session._exec_edit_file(result)
assert "Error" in msg
def test_batch_file_changed_partial_match(self, session, sample_file):
"""If file changes so one edit fails, none should be applied."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
# Remove line5 between prepare and exec
with open(sample_file, "w") as f:
f.write("line1\nline2\nline3\nline4\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
# line1 should NOT have been edited (atomic failure)
with open(sample_file) as f:
assert "line1" in f.read()
+29 -16
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
@@ -179,10 +180,13 @@ class TestErrorHandling:
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
judge = _make_judge(provider)
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
)
assert result is None
def test_provider_error_heuristic_still_returned(self):
@@ -211,10 +215,13 @@ class TestErrorHandling:
result_mock.content = ""
judge = _make_judge(provider)
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
)
assert result is None
@@ -254,10 +261,13 @@ class TestMultiTurnToolUse:
provider.create_completion.side_effect = [turn1, turn2]
judge = _make_judge(provider)
verdict = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
)
with ThreadPoolExecutor(max_workers=1) as pool:
verdict = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
)
assert verdict is not None
assert verdict.tier == "llm"
assert provider.create_completion.call_count == 2
@@ -299,10 +309,13 @@ class TestMultiTurnToolUse:
]
judge = _make_judge(provider)
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
)
with ThreadPoolExecutor(max_workers=1) as pool:
judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
)
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
assert provider.create_completion.call_count == 5
+2
View File
@@ -54,6 +54,7 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
session._notify_on_complete = "{}"
session.messages = []
session._config = {}
session._tool_error_flags = {}
# Stub set_skill to just record the call
session._set_skill_called: list[str | None] = []
@@ -423,6 +424,7 @@ class TestSkillCatalogDisclosure:
session._tool_search = None
session._mcp_client = None
session._notify_on_complete = "{}"
session._tool_error_flags = {}
# Memory stubs
session._memory_config = MagicMock()
+2
View File
@@ -573,7 +573,9 @@ def _fake_request(*nodes: dict[str, Any], proxy_client: Any = None) -> MagicMock
collector.get_nodes.return_value = (list(nodes), len(nodes))
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
req = MagicMock()
req.state.auth_result = None
req.app.state.collector = collector
req.app.state.jwt_secret = ""
req.app.state.proxy_client = proxy_client or AsyncMock()
req.app.state.proxy_token_mgr = None
req.app.state.proxy_auth_token = "tok"
+185
View File
@@ -401,6 +401,64 @@ class TestSessionIntegration:
prepared = session._prepare_tool(tc)
assert "error" in prepared
assert "Unknown tool" in prepared["error"]
# Error lists available tools so the model can self-correct
assert "bash" in prepared["error"]
# Surfaces warning to user
session.ui.on_error.assert_called_once()
assert "nonexistent" in session.ui.on_error.call_args[0][0]
def test_prepare_tool_strips_whitespace_from_name(self, tmp_db):
"""Local models may produce tool names with leading/trailing whitespace."""
session = self._make_session(mcp_client=None)
tc = {
"id": "call_strip",
"function": {"name": " bash\n", "arguments": '{"command": "echo hi"}'},
}
prepared = session._prepare_tool(tc)
assert prepared["func_name"] == "bash"
assert "error" not in prepared
def test_prepare_tool_malformed_json_surfaces_error(self, tmp_db):
"""Malformed JSON args should surface a warning to the user and
give the model a hint about expected format."""
session = self._make_session(mcp_client=None)
tc = {
"id": "call_bad",
"function": {"name": "bash", "arguments": "{command: echo hi}"},
}
prepared = session._prepare_tool(tc)
assert "error" in prepared
assert "JSON parse error" in prepared["error"]
assert "command" in prepared["error"] # hint about expected key
assert "Please retry" in prepared["error"]
# User-facing warning
session.ui.on_error.assert_called_once()
assert "Malformed tool call" in session.ui.on_error.call_args[0][0]
def test_ensure_tool_call_ids_dict(self, tmp_db):
"""_ensure_tool_call_ids fills empty IDs on streaming-style dict."""
from turnstone.core.session import ChatSession
tool_calls_acc = {
0: {"id": "", "function": {"name": "bash", "arguments": "{}"}},
1: {"id": "", "function": {"name": "read_file", "arguments": "{}"}},
}
ChatSession._ensure_tool_call_ids(tool_calls_acc)
ids = [tc["id"] for tc in tool_calls_acc.values()]
assert all(id_.startswith("call_") for id_ in ids)
assert len(set(ids)) == 2 # unique
def test_ensure_tool_call_ids_list(self, tmp_db):
"""_ensure_tool_call_ids fills empty IDs on list (agent path)."""
from turnstone.core.session import ChatSession
tool_calls = [
{"id": None, "function": {"name": "bash", "arguments": "{}"}},
{"id": "call_existing", "function": {"name": "bash", "arguments": "{}"}},
]
ChatSession._ensure_tool_call_ids(tool_calls)
assert tool_calls[0]["id"].startswith("call_")
assert tool_calls[1]["id"] == "call_existing" # preserved
def test_mcp_command_no_client(self, tmp_db):
session = self._make_session(mcp_client=None)
@@ -1368,3 +1426,130 @@ class TestShutdownCleanup:
assert mgr.get_prompts() == []
assert mgr._resource_map == {}
assert mgr._prompt_map == {}
# ---------------------------------------------------------------------------
# TCP probe and unreachable server handling
# ---------------------------------------------------------------------------
class TestTCPProbe:
"""MCPClientManager._tcp_probe should fail fast on unreachable servers."""
def test_tcp_probe_unreachable_raises_connection_error(self):
"""Unreachable host raises ConnectionError, not TimeoutError."""
mgr = MCPClientManager({})
async def _run():
with pytest.raises(ConnectionError, match="unreachable"):
await mgr._tcp_probe("test-server", "http://127.0.0.1:1")
asyncio.run(_run())
def test_tcp_probe_parses_url_correctly(self):
"""Port and host are extracted from the URL."""
mgr = MCPClientManager({})
async def _run():
# Non-routable port — should fail with ConnectionError
with pytest.raises(ConnectionError):
await mgr._tcp_probe("srv", "https://127.0.0.1:1/mcp")
asyncio.run(_run())
def test_tcp_probe_default_port_http(self):
"""Default port 80 used for http:// URLs without explicit port."""
mgr = MCPClientManager({})
async def _run():
# Will fail (nothing on port 80), but should not crash on parsing
with pytest.raises(ConnectionError):
await mgr._tcp_probe("srv", "http://127.0.0.1")
asyncio.run(_run())
def test_tcp_probe_dns_failure(self):
"""Unresolvable hostname raises ConnectionError."""
mgr = MCPClientManager({})
async def _run():
with pytest.raises(ConnectionError):
await mgr._tcp_probe("srv", "http://this.host.does.not.exist.invalid:8080/mcp")
asyncio.run(_run())
class TestConnectOneUnreachable:
"""_connect_one should handle unreachable HTTP servers gracefully."""
def test_unreachable_http_server_raises_connection_error(self):
"""Unreachable HTTP MCP server raises ConnectionError without spinning."""
mgr = MCPClientManager({})
mgr._loop = asyncio.new_event_loop()
async def _run():
with pytest.raises(ConnectionError, match="unreachable"):
await mgr._connect_one(
"bad-server",
{
"type": "http",
"url": "http://127.0.0.1:1/mcp",
},
)
mgr._loop.run_until_complete(_run())
mgr._loop.close()
# Server should NOT be in sessions (connection failed)
assert "bad-server" not in mgr._sessions
def test_connect_all_continues_after_unreachable_server(self):
"""_connect_all logs error and continues to next server."""
mgr = MCPClientManager(
{
"bad": {"type": "http", "url": "http://127.0.0.1:1/mcp"},
}
)
loop = asyncio.new_event_loop()
loop.run_until_complete(mgr._connect_all())
loop.close()
assert "bad" not in mgr._sessions
assert "bad" in mgr._last_error
class TestSafeCloseStack:
"""_safe_close_stack should suppress errors from broken anyio scopes."""
def test_suppresses_runtime_error(self):
"""RuntimeError from broken cancel scope is suppressed."""
async def _run():
stack = AsyncExitStack()
await stack.__aenter__()
# Simulate a broken close that raises RuntimeError
async def _broken_close():
raise RuntimeError("Attempted to exit cancel scope in a different task")
stack.aclose = _broken_close
# Should not raise
await MCPClientManager._safe_close_stack(stack)
asyncio.run(_run())
def test_suppresses_cancelled_error(self):
"""CancelledError during close is suppressed."""
async def _run():
stack = AsyncExitStack()
await stack.__aenter__()
async def _cancel_close():
raise asyncio.CancelledError()
stack.aclose = _cancel_close
await MCPClientManager._safe_close_stack(stack)
asyncio.run(_run())
+20
View File
@@ -4,6 +4,7 @@ from turnstone.core.metacognition import (
NUDGE_COMPLETION,
NUDGE_CORRECTION,
NUDGE_DENIAL,
NUDGE_REPEAT,
NUDGE_RESUME,
NUDGE_START,
NUDGE_TOOL_ERROR,
@@ -288,3 +289,22 @@ class TestToolErrorNudge:
def test_not_with_zero_memories(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
class TestRepeatNudge:
def test_format(self):
assert format_nudge("repeat") == NUDGE_REPEAT
def test_fires(self):
state: dict[str, float] = {}
assert should_nudge("repeat", state, message_count=5) is True
def test_cooldown(self):
state: dict[str, float] = {}
assert should_nudge("repeat", state, message_count=5) is True
assert should_nudge("repeat", state, message_count=6) is False
def test_no_memory_requirement(self):
"""Repeat nudge should fire even with zero memories."""
state: dict[str, float] = {}
assert should_nudge("repeat", state, message_count=5, memory_count=0) is True
+163
View File
@@ -0,0 +1,163 @@
"""Tests for model definition storage CRUD operations."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from turnstone.core.storage._sqlite import SQLiteBackend
def _make_id() -> str:
return uuid.uuid4().hex
class TestModelDefinitionStorage:
def test_create_and_get(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did,
alias="test-model",
model="gpt-5",
provider="openai",
base_url="https://api.openai.com/v1",
api_key="sk-test",
context_window=128000,
)
m = db.get_model_definition(did)
assert m is not None
assert m["alias"] == "test-model"
assert m["model"] == "gpt-5"
assert m["provider"] == "openai"
assert m["base_url"] == "https://api.openai.com/v1"
assert m["api_key"] == "sk-test"
assert m["context_window"] == 128000
assert m["capabilities"] == "{}"
assert m["enabled"] is True
def test_get_by_alias(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="by-alias", model="gpt-5")
m = db.get_model_definition_by_alias("by-alias")
assert m is not None
assert m["definition_id"] == did
def test_get_by_alias_not_found(self, db: SQLiteBackend) -> None:
assert db.get_model_definition_by_alias("nope") is None
def test_get_not_found(self, db: SQLiteBackend) -> None:
assert db.get_model_definition("nonexistent") is None
def test_list_empty(self, db: SQLiteBackend) -> None:
assert db.list_model_definitions() == []
def test_list_all(self, db: SQLiteBackend) -> None:
db.create_model_definition(definition_id=_make_id(), alias="alpha", model="gpt-5")
db.create_model_definition(
definition_id=_make_id(), alias="beta", model="claude-opus-4-6", provider="anthropic"
)
models = db.list_model_definitions()
assert len(models) == 2
assert models[0]["alias"] == "alpha" # ordered by alias
assert models[1]["alias"] == "beta"
def test_list_enabled_only(self, db: SQLiteBackend) -> None:
db.create_model_definition(
definition_id=_make_id(), alias="enabled-model", model="gpt-5", enabled=True
)
db.create_model_definition(
definition_id=_make_id(), alias="disabled-model", model="gpt-5", enabled=False
)
enabled = db.list_model_definitions(enabled_only=True)
assert len(enabled) == 1
assert enabled[0]["alias"] == "enabled-model"
def test_update_basic_fields(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did, alias="orig", model="gpt-5", base_url="http://old"
)
ok = db.update_model_definition(did, alias="renamed", base_url="http://new")
assert ok is True
m = db.get_model_definition(did)
assert m is not None
assert m["alias"] == "renamed"
assert m["base_url"] == "http://new"
def test_update_boolean_conversion(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="booltest", model="gpt-5")
db.update_model_definition(did, enabled=False)
m = db.get_model_definition(did)
assert m is not None
assert m["enabled"] is False
def test_update_not_found(self, db: SQLiteBackend) -> None:
ok = db.update_model_definition("nonexistent", alias="x")
assert ok is False
def test_update_ignores_disallowed_fields(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did, alias="guard", model="gpt-5", created_by="admin"
)
original = db.get_model_definition(did)
assert original is not None
original_created = original["created"]
# created_by and created are not in the mutable allowlist
db.update_model_definition(did, created_by="evil", created="2000-01-01T00:00:00")
m = db.get_model_definition(did)
assert m is not None
assert m["created_by"] == "admin" # unchanged
assert m["created"] == original_created # unchanged
def test_delete(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="delme", model="gpt-5")
ok = db.delete_model_definition(did)
assert ok is True
assert db.get_model_definition(did) is None
def test_delete_not_found(self, db: SQLiteBackend) -> None:
ok = db.delete_model_definition("nonexistent")
assert ok is False
def test_create_duplicate_alias(self, db: SQLiteBackend) -> None:
db.create_model_definition(definition_id=_make_id(), alias="unique", model="gpt-5")
# Second create with same alias but different ID should be no-op (OR IGNORE)
did2 = _make_id()
db.create_model_definition(definition_id=did2, alias="unique", model="gpt-5")
assert db.get_model_definition(did2) is None
def test_create_idempotent_same_id(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5")
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5-mini")
m = db.get_model_definition(did)
assert m is not None
assert m["model"] == "gpt-5" # original preserved
def test_capabilities_json(self, db: SQLiteBackend) -> None:
did = _make_id()
caps = '{"supports_vision": true, "supports_web_search": false}'
db.create_model_definition(
definition_id=did, alias="caps-test", model="gpt-5", capabilities=caps
)
m = db.get_model_definition(did)
assert m is not None
assert m["capabilities"] == caps
def test_defaults(self, db: SQLiteBackend) -> None:
"""Verify default values for optional fields."""
did = _make_id()
db.create_model_definition(definition_id=did, alias="defaults", model="gpt-5")
m = db.get_model_definition(did)
assert m is not None
assert m["provider"] == "openai"
assert m["base_url"] == ""
assert m["api_key"] == ""
assert m["context_window"] == 32768
assert m["capabilities"] == "{}"
assert m["enabled"] is True
assert m["created_by"] == ""
+235
View File
@@ -0,0 +1,235 @@
"""Tests for probe_model_endpoint() and lookup_model_capabilities()."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.model_registry import probe_model_endpoint
from turnstone.core.providers import list_known_models, lookup_model_capabilities
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_model(
model_id: str,
*,
owned_by: str = "test",
meta: dict[str, Any] | None = None,
) -> MagicMock:
m = MagicMock()
m.id = model_id
dumped: dict[str, Any] = {"owned_by": owned_by}
if meta is not None:
dumped["meta"] = meta
m.model_dump.return_value = dumped
return m
def _mock_client(*models: MagicMock) -> MagicMock:
fast = MagicMock()
fast.models.list.return_value = MagicMock(data=list(models))
client = MagicMock()
client.with_options.return_value = fast
return client
# ---------------------------------------------------------------------------
# probe_model_endpoint
# ---------------------------------------------------------------------------
class TestProbeModelEndpoint:
@patch("turnstone.core.providers.create_client")
def test_probe_success(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
m2 = _mock_model("model-b")
mock_cc.return_value = _mock_client(m1, m2)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["reachable"] is True
assert result["available_models"] == ["model-a", "model-b"]
assert result["error"] is None
@patch("turnstone.core.providers.create_client")
def test_target_found(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint(
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
)
assert result["model_found"] is True
@patch("turnstone.core.providers.create_client")
def test_target_not_found(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint(
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
)
assert result["model_found"] is False
assert result["available_models"] == ["model-a"]
@patch("turnstone.core.providers.create_client")
def test_no_target_model_found_is_none(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["model_found"] is None
@patch("turnstone.core.providers.create_client")
def test_context_window_llama_cpp(self, mock_cc: MagicMock) -> None:
m = _mock_model("qwen-32b", meta={"n_ctx_train": 131072})
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["context_window"] == 131072
assert result["server_type"] == "llama.cpp"
@patch("turnstone.core.providers.create_client")
def test_server_type_openai(self, mock_cc: MagicMock) -> None:
m = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "https://api.openai.com/v1", "sk-test")
assert result["server_type"] == "openai"
@patch("turnstone.core.providers.create_client")
def test_server_type_sglang(self, mock_cc: MagicMock) -> None:
m = _mock_model("meta-llama/Llama-3", owned_by="sglang")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:30000/v1", "key")
assert result["server_type"] == "sglang"
@patch("turnstone.core.providers.create_client")
def test_server_type_vllm(self, mock_cc: MagicMock) -> None:
m = _mock_model("org/model-name")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["server_type"] == "vllm"
@patch("turnstone.core.providers.create_client")
def test_server_type_generic(self, mock_cc: MagicMock) -> None:
m = _mock_model("my-model")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["server_type"] == "openai-compatible"
@patch("turnstone.core.providers.create_client")
def test_anthropic_provider(self, mock_cc: MagicMock) -> None:
m = _mock_model("claude-sonnet-4-6")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint(
"anthropic",
"https://api.anthropic.com",
"sk-ant-test",
target_model="claude-sonnet-4-6",
)
assert result["reachable"] is True
assert result["server_type"] == "anthropic"
assert result["context_window"] == 200000
@patch("turnstone.core.providers.create_client")
def test_connection_failure(self, mock_cc: MagicMock) -> None:
mock_cc.side_effect = OSError("Connection refused")
result = probe_model_endpoint("openai", "http://bad:1234/v1", "key")
assert result["reachable"] is False
assert "Connection refused" in (result["error"] or "")
@patch("turnstone.core.providers.create_client")
def test_empty_model_list(self, mock_cc: MagicMock) -> None:
mock_cc.return_value = _mock_client() # no models
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["reachable"] is True
assert result["available_models"] == []
assert "No models found" in (result["error"] or "")
@patch("turnstone.core.providers.create_client")
def test_context_window_openai_static_table(self, mock_cc: MagicMock) -> None:
"""When base_url is api.openai.com and model is known, use static table."""
m = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint(
"openai", "https://api.openai.com/v1", "sk-test", target_model="gpt-5"
)
assert result["context_window"] == 400000
# ---------------------------------------------------------------------------
# lookup_model_capabilities
# ---------------------------------------------------------------------------
class TestLookupModelCapabilities:
def test_known_openai_model(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
assert caps["context_window"] == 400000
assert caps["supports_temperature"] is False
def test_known_anthropic_model(self) -> None:
caps = lookup_model_capabilities("anthropic", "claude-opus-4-6")
assert caps is not None
assert caps["context_window"] == 200000
assert caps["thinking_mode"] == "adaptive"
def test_unknown_model_returns_none(self) -> None:
caps = lookup_model_capabilities("openai", "totally-unknown-model")
assert caps is None
def test_tuples_converted_to_lists(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
for val in caps.values():
assert not isinstance(val, tuple), f"Found tuple: {val}"
def test_reasoning_effort_values_are_list(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
assert isinstance(caps["reasoning_effort_values"], list)
assert "medium" in caps["reasoning_effort_values"]
def test_openai_compatible_returns_none(self) -> None:
caps = lookup_model_capabilities("openai-compatible", "my-local-model")
assert caps is None
def test_invalid_provider_raises(self) -> None:
with pytest.raises(ValueError, match="Unknown provider"):
lookup_model_capabilities("bad-provider", "gpt-5")
# ---------------------------------------------------------------------------
# list_known_models
# ---------------------------------------------------------------------------
class TestListKnownModels:
def test_openai_models(self) -> None:
models = list_known_models("openai")
assert "gpt-5" in models
assert isinstance(models, list)
assert models == sorted(models)
def test_anthropic_models(self) -> None:
models = list_known_models("anthropic")
assert "claude-opus-4-6" in models
def test_openai_compatible_returns_empty(self) -> None:
assert list_known_models("openai-compatible") == []
def test_unknown_provider_returns_empty(self) -> None:
assert list_known_models("bad-provider") == []
+276 -1
View File
@@ -10,6 +10,7 @@ import pytest
from turnstone.core.model_registry import (
ModelConfig,
ModelRegistry,
_resolve_env_vars,
detect_model,
load_model_registry,
)
@@ -319,6 +320,280 @@ class TestLoadModelRegistry:
assert alt_cfg.api_key == "my-key"
# ---------------------------------------------------------------------------
# load_model_registry with DB storage
# ---------------------------------------------------------------------------
class _MockStorage:
"""Minimal storage mock returning canned model definitions."""
def __init__(self, rows: list[dict[str, Any]] | None = None) -> None:
self._rows = rows or []
self.calls: list[str] = []
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
self.calls.append("list_model_definitions")
if enabled_only:
return [r for r in self._rows if r.get("enabled", True)]
return list(self._rows)
class TestLoadModelRegistryWithDB:
def test_db_models_loaded(self) -> None:
"""DB model definitions are loaded into the registry."""
storage = _MockStorage(
[
{
"alias": "cloud-gpt",
"model": "gpt-5",
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-db",
"context_window": 128000,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("cloud-gpt")
cfg = reg.get_config("cloud-gpt")
assert cfg.model == "gpt-5"
assert cfg.source == "db"
def test_config_overrides_db(self) -> None:
"""Config.toml entry overrides DB entry with same alias."""
storage = _MockStorage(
[
{
"alias": "shared",
"model": "db-model",
"provider": "openai",
"base_url": "http://db/v1",
"api_key": "sk-db",
"context_window": 32768,
"capabilities": "{}",
"enabled": True,
}
]
)
fake_cfg: dict[str, Any] = {
"models": {
"shared": {
"model": "config-model",
"base_url": "http://config/v1",
},
},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
cfg = reg.get_config("shared")
assert cfg.model == "config-model"
assert cfg.source == "config"
def test_db_only_models_coexist(self) -> None:
"""DB models coexist alongside config.toml models."""
storage = _MockStorage(
[
{
"alias": "db-only",
"model": "db-model",
"provider": "anthropic",
"base_url": "",
"api_key": "sk-db",
"context_window": 200000,
"capabilities": "{}",
"enabled": True,
}
]
)
fake_cfg: dict[str, Any] = {
"models": {
"config-only": {"model": "config-model"},
},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("db-only")
assert reg.has_alias("config-only")
assert reg.has_alias("default")
assert reg.get_config("db-only").source == "db"
assert reg.get_config("config-only").source == "config"
def test_source_field_set(self) -> None:
"""Source field correctly distinguishes origin."""
storage = _MockStorage(
[
{
"alias": "from-db",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.get_config("from-db").source == "db"
assert reg.get_config("default").source == ""
def test_disabled_db_models_excluded(self) -> None:
"""Disabled DB models are not loaded."""
storage = _MockStorage(
[
{
"alias": "disabled",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": "{}",
"enabled": False,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert not reg.has_alias("disabled")
def test_db_capabilities_parsed(self) -> None:
"""JSON capabilities from DB are parsed into dict."""
storage = _MockStorage(
[
{
"alias": "caps-model",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": '{"supports_vision": true}',
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.get_config("caps-model").capabilities == {"supports_vision": True}
def test_db_default_alias_not_clobbered(self) -> None:
"""DB model with alias='default' is not overwritten by CLI args."""
storage = _MockStorage(
[
{
"alias": "default",
"model": "db-default-model",
"provider": "openai",
"base_url": "http://db/v1",
"api_key": "sk-db",
"context_window": 128000,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://cli/v1", "cli-key", "cli-model", storage=storage)
cfg = reg.get_config("default")
assert cfg.model == "db-default-model"
assert cfg.source == "db"
def test_no_db_writes(self) -> None:
"""Config.toml models are NOT written to storage."""
storage = _MockStorage()
fake_cfg: dict[str, Any] = {
"models": {"local": {"model": "llama"}},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
load_model_registry("http://x/v1", "x", "x", storage=storage)
# Only list_model_definitions should be called, no create
assert storage.calls == ["list_model_definitions"]
def test_storage_failure_graceful(self) -> None:
"""Storage errors don't prevent registry creation."""
storage = MagicMock()
storage.list_model_definitions.side_effect = RuntimeError("db down")
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("default")
# ---------------------------------------------------------------------------
# _resolve_env_vars
# ---------------------------------------------------------------------------
class TestResolveEnvVars:
def test_expand_single(self) -> None:
with patch.dict("os.environ", {"MY_KEY": "secret123"}):
assert _resolve_env_vars("sk-${MY_KEY}") == "sk-secret123"
def test_expand_multiple(self) -> None:
with patch.dict("os.environ", {"A": "1", "B": "2"}):
assert _resolve_env_vars("${A}-${B}") == "1-2"
def test_missing_var_empty(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert _resolve_env_vars("${MISSING}") == ""
def test_no_vars(self) -> None:
assert _resolve_env_vars("plain-key") == "plain-key"
def test_empty_string(self) -> None:
assert _resolve_env_vars("") == ""
# ---------------------------------------------------------------------------
# ModelRegistry.reload
# ---------------------------------------------------------------------------
class TestRegistryReload:
def test_reload_replaces_models(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m1")}
reg = ModelRegistry(models=models_a, default="a")
assert reg.has_alias("a")
models_b = {"b": ModelConfig("b", "y", "y", "m2")}
reg.reload(models_b, "b")
assert not reg.has_alias("a")
assert reg.has_alias("b")
assert reg.default == "b"
def test_reload_clears_clients(self) -> None:
models = {"a": ModelConfig("a", "http://x/v1", "key", "m")}
reg = ModelRegistry(models=models, default="a")
# Force client creation
reg.get_client("a")
assert "a" in reg._clients
# Reload with same models — clients should be cleared
reg.reload(dict(models), "a")
assert "a" not in reg._clients
def test_reload_validates_default(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m")}
reg = ModelRegistry(models=models_a, default="a")
with pytest.raises(ValueError, match="Default model"):
reg.reload(models_a, "nonexistent")
# Registry should be unchanged after failed reload
assert reg.has_alias("a")
assert reg.default == "a"
def test_reload_validates_empty(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m")}
reg = ModelRegistry(models=models_a, default="a")
with pytest.raises(ValueError, match="at least one"):
reg.reload({}, "a")
# ---------------------------------------------------------------------------
# Session integration
# ---------------------------------------------------------------------------
@@ -339,7 +614,7 @@ class _FakeUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
return True, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str:
+1 -1
View File
@@ -29,7 +29,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+297 -7
View File
@@ -94,6 +94,7 @@ def _anthropic_event(
delta.type = kwargs.get("delta_type", "text_delta")
delta.text = kwargs.get("text", "")
delta.thinking = kwargs.get("thinking", "")
delta.signature = kwargs.get("signature", "")
delta.partial_json = kwargs.get("partial_json", "")
event.delta = delta
event.index = kwargs.get("index", 0)
@@ -140,6 +141,34 @@ class TestOpenAIProvider:
def test_provider_name(self) -> None:
assert self.provider.provider_name == "openai"
# -- _sanitize_messages ---------------------------------------------------
def test_sanitize_messages_none_content_no_tool_calls(self) -> None:
msgs = [{"role": "assistant", "content": None}]
assert self.provider._sanitize_messages(msgs) == [{"role": "assistant", "content": ""}]
def test_sanitize_messages_none_content_with_tool_calls(self) -> None:
msgs = [{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]}]
result = self.provider._sanitize_messages(msgs)
assert result[0]["content"] is None
assert result[0]["tool_calls"] == [{"id": "1"}]
def test_sanitize_messages_empty_string_passthrough(self) -> None:
msgs = [{"role": "assistant", "content": ""}]
assert self.provider._sanitize_messages(msgs) == msgs
def test_sanitize_messages_non_assistant_unchanged(self) -> None:
msgs = [{"role": "user", "content": None}]
result = self.provider._sanitize_messages(msgs)
assert result[0]["content"] is None
def test_sanitize_messages_does_not_mutate_original(self) -> None:
original = {"role": "assistant", "content": None}
self.provider._sanitize_messages([original])
assert original["content"] is None
# -- convert_tools --------------------------------------------------------
def test_convert_tools_passthrough(self) -> None:
tools = [
{
@@ -479,10 +508,11 @@ class TestAnthropicProvider:
},
}
],
}
},
{"role": "tool", "tool_call_id": "call_1", "content": "file contents"},
]
_, converted = self.provider._convert_messages(messages)
assert len(converted) == 1
assert len(converted) == 2
blocks = converted[0]["content"]
assert len(blocks) == 2
assert blocks[0] == {"type": "text", "text": "Let me check that."}
@@ -490,6 +520,8 @@ class TestAnthropicProvider:
assert blocks[1]["id"] == "call_1"
assert blocks[1]["name"] == "read_file"
assert blocks[1]["input"] == {"path": "foo.py"}
# Tool result in user message
assert converted[1]["role"] == "user"
def test_message_conversion_tool_results(self) -> None:
messages = [
@@ -598,7 +630,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 5
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -630,7 +666,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 20
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -661,7 +701,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 50
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -1141,6 +1185,146 @@ class TestOpenAIParameterGating:
assert kwargs["reasoning_effort"] == "medium" # fell back from unsupported "low"
class TestAnthropicOrphanedToolUse:
"""Verify _convert_messages synthesizes tool_results for orphaned tool_use."""
def setup_method(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
self.provider = AnthropicProvider()
def test_orphaned_tool_use_gets_synthetic_result(self) -> None:
"""Assistant has tool_calls but next message is user (no tool results)."""
messages = [
{"role": "user", "content": "do something"},
{
"role": "assistant",
"content": "I'll run that.",
"tool_calls": [
{
"id": "call_abc",
"function": {"name": "bash", "arguments": '{"command": "ls"}'},
}
],
},
{"role": "user", "content": "never mind, do something else"},
]
_, converted = self.provider._convert_messages(messages)
# Should have: user, assistant(tool_use), user(synthetic tool_result), user
# After _merge_consecutive, the two user messages may merge.
# Find the synthetic tool_result
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
assert len(tool_results) == 1
assert tool_results[0]["tool_use_id"] == "call_abc"
assert tool_results[0]["is_error"] is True
assert "cancelled" in tool_results[0]["content"].lower()
def test_multiple_orphaned_tool_calls(self) -> None:
"""Assistant has 3 tool_calls, none have results."""
messages = [
{"role": "user", "content": "do three things"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "read_file", "arguments": "{}"}},
{"id": "c3", "function": {"name": "write_file", "arguments": "{}"}},
],
},
{"role": "user", "content": "skip all that"},
]
_, converted = self.provider._convert_messages(messages)
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
assert len(tool_results) == 3
result_ids = {r["tool_use_id"] for r in tool_results}
assert result_ids == {"c1", "c2", "c3"}
def test_partial_results_only_orphans_synthesized(self) -> None:
"""2 tool_calls, only 1 has a result — synthesize for the missing one."""
messages = [
{"role": "user", "content": "do two things"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "file1.txt"},
{"role": "user", "content": "skip the write"},
]
_, converted = self.provider._convert_messages(messages)
# c1 should have a real result, c2 should have a synthetic one
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
result_map = {r["tool_use_id"]: r for r in tool_results}
assert "c1" in result_map
assert result_map["c1"]["content"] == "file1.txt" # real result
assert "c2" in result_map
assert result_map["c2"]["is_error"] is True # synthetic
def test_complete_results_no_synthesis(self) -> None:
"""All tool_calls have results — no synthesis needed."""
messages = [
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "done"},
{"role": "user", "content": "thanks"},
]
_, converted = self.provider._convert_messages(messages)
# No synthetic results — only the real one
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
assert "cancelled" not in block.get("content", "").lower()
def test_trailing_orphan(self) -> None:
"""Orphaned tool_use at end of conversation (no following messages)."""
messages = [
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "Running...",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
],
},
]
_, converted = self.provider._convert_messages(messages)
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
assert len(tool_results) == 1
assert tool_results[0]["tool_use_id"] == "c1"
assert tool_results[0]["is_error"] is True
class TestAnthropicReasoningNone:
"""Verify 'none' effort disables thinking for manual-thinking models."""
@@ -1383,7 +1567,11 @@ class TestAnthropicWebSearch:
response.usage.output_tokens = 50
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
with patch("turnstone.core.providers._anthropic._ensure_anthropic"):
result = self.provider.create_completion(
@@ -1934,6 +2122,104 @@ class TestAnthropicProviderBlocks:
assert blocks[2]["type"] == "web_search_tool_result"
assert blocks[2]["encrypted_content"] == "enc_data"
def test_streaming_thinking_block_captures_signature(self) -> None:
"""Streaming thinking block accumulates signature from signature_delta events."""
thinking_block = MagicMock()
thinking_block.type = "thinking"
thinking_block.model_dump.return_value = {
"type": "thinking",
"thinking": "",
"signature": "",
}
text_block = MagicMock()
text_block.type = "text"
text_block.model_dump.return_value = {"type": "text", "text": ""}
events = [
MagicMock(type="content_block_start", index=0, content_block=thinking_block),
_anthropic_event(
"content_block_delta", delta_type="thinking_delta", thinking="step 1", index=0
),
_anthropic_event(
"content_block_delta", delta_type="thinking_delta", thinking=" step 2", index=0
),
_anthropic_event(
"content_block_delta",
delta_type="signature_delta",
signature="sig_part1",
index=0,
),
_anthropic_event(
"content_block_delta",
delta_type="signature_delta",
signature="sig_part2",
index=0,
),
_anthropic_event("content_block_stop", index=0),
MagicMock(type="content_block_start", index=1, content_block=text_block),
_anthropic_event("content_block_delta", delta_type="text_delta", text="Hello", index=1),
_anthropic_event("content_block_stop", index=1),
_anthropic_event("message_delta", stop_reason="end_turn", usage_output_tokens=50),
]
chunks = list(self.provider._iter_anthropic_stream(iter(events)))
final_chunks = [c for c in chunks if c.provider_blocks]
assert len(final_chunks) == 1
blocks = final_chunks[0].provider_blocks
assert blocks[0]["type"] == "thinking"
assert blocks[0]["thinking"] == "step 1 step 2"
assert blocks[0]["signature"] == "sig_part1sig_part2"
def test_thinking_block_multiturn_roundtrip(self) -> None:
"""Thinking block with signature survives _convert_messages round-trip."""
provider_content = [
{
"type": "thinking",
"thinking": "Let me reason...",
"signature": "ErUBCkYIAxgCIkD_valid_sig",
},
{"type": "text", "text": "Here is my answer."},
]
messages = [
{"role": "user", "content": "Question"},
{
"role": "assistant",
"content": "Here is my answer.",
"_provider_content": provider_content,
},
{"role": "user", "content": "Follow up"},
]
_, converted = self.provider._convert_messages(messages)
assistant_msg = converted[1]
assert assistant_msg["content"] is provider_content
assert assistant_msg["content"][0]["signature"] == "ErUBCkYIAxgCIkD_valid_sig"
assert assistant_msg["content"][0]["type"] == "thinking"
def test_block_to_dict_preserves_thinking_signature(self) -> None:
"""_block_to_dict preserves signature on thinking blocks."""
from turnstone.core.providers._anthropic import _block_to_dict
class FakeThinkingBlock:
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
return {
"type": "thinking",
"thinking": "reasoning...",
"signature": "abc123sig",
}
result = _block_to_dict(FakeThinkingBlock())
assert result["signature"] == "abc123sig"
# Also test fallback path (no model_dump)
class FallbackBlock:
type = "thinking"
thinking = "reasoning..."
signature = "abc123sig"
result2 = _block_to_dict(FallbackBlock())
assert result2["signature"] == "abc123sig"
# ---------------------------------------------------------------------------
# Tool search tests
@@ -2307,7 +2593,11 @@ class TestAnthropicPromptCaching:
response.usage = usage
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
+2 -3
View File
@@ -9,13 +9,12 @@ def _row(
role,
content=None,
tool_name=None,
tool_args=None,
tc_id=None,
pdata=None,
tool_calls=None,
):
"""Build a 7-element conversation row tuple (post-migration 013 format)."""
return (role, content, tool_name, tool_args, tc_id, pdata, tool_calls)
"""Build a 6-element conversation row tuple (post-migration 027 format)."""
return (role, content, tool_name, tc_id, pdata, tool_calls)
class TestAssistantWithToolCalls:
+1 -1
View File
@@ -88,7 +88,7 @@ class RecordingUI:
def approve_tools(self, items):
return True, None # auto-approve everything
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
self.tool_results.append((call_id, name, output))
def on_tool_output_chunk(self, call_id, chunk):
+5 -5
View File
@@ -28,7 +28,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
@@ -214,7 +214,7 @@ class TestPlanExec:
"id": tc_id,
"type": "function",
"function": {
"name": "create_plan",
"name": "plan_agent",
"arguments": json.dumps({"goal": prior_prompt}),
},
}
@@ -250,7 +250,7 @@ class TestPlanExec:
m for m in messages if m["role"] == "assistant" and m.get("tool_calls")
]
assert len(assistant_with_tc) == 1
assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "create_plan"
assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "plan_agent"
# The real tool result is forwarded with its original content
tool_msgs = [m for m in messages if m["role"] == "tool"]
@@ -463,7 +463,7 @@ class TestPlanRefinement:
with patch.object(session, "_refine_plan", side_effect=fake_refine):
items = [
{
"func_name": "create_plan",
"func_name": "plan_agent",
"call_id": "c1",
"prompt": "add auth",
}
@@ -576,7 +576,7 @@ class TestPlanRefinement:
msgs = captured["messages"]
assert msgs[0]["role"] == "system"
assert msgs[1]["role"] == "assistant"
assert msgs[1]["tool_calls"][0]["function"]["name"] == "create_plan"
assert msgs[1]["tool_calls"][0]["function"]["name"] == "plan_agent"
assert msgs[2]["role"] == "tool"
assert msgs[2]["content"] == self.GOOD_PLAN
assert msgs[3]["role"] == "user"
+179
View File
@@ -525,6 +525,37 @@ class TestWorkstreamConfig:
assert session.instructions == "be concise"
assert session.creative_mode is True
def test_resume_restores_model(self, tmp_db):
"""ChatSession.resume() should restore the model from workstream config."""
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
ui = MagicMock()
ui.on_info = MagicMock()
ui.on_error = MagicMock()
ui.on_state_change = MagicMock()
ui.on_rename = MagicMock()
# Create a workstream that was using a specific model
register_workstream("model_ws")
save_message("model_ws", "user", "hello")
save_message("model_ws", "assistant", "hi")
save_workstream_config("model_ws", {"model": "gpt-5", "model_alias": ""})
# Resume into a session that was created with a different model
session = ChatSession(
client=client,
model="gpt-5-nano",
ui=ui,
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
assert session.model == "gpt-5-nano"
result = session.resume("model_ws")
assert result is True
assert session.model == "gpt-5"
# ── Prune workstreams ─────────────────────────────────────────────────
@@ -699,6 +730,7 @@ class TestWebSearchGating:
with (
patch.object(session, "_get_capabilities", return_value=caps),
patch("turnstone.core.session.get_tavily_key", return_value=None),
patch("turnstone.core.web_search._ddg_available", return_value=False),
):
tools = session._get_active_tools()
@@ -754,3 +786,150 @@ class TestWebSearchGating:
names = [t.get("function", {}).get("name") for t in tools]
assert "web_search" in names
class TestMCPToolGating:
"""MCP tools should not be offered when no MCP servers provide them."""
def test_mcp_tools_filtered_without_mcp_client(self, tmp_db, mock_openai_client):
"""read_resource and use_prompt excluded when no MCP client."""
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
assert session._mcp_client is None
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "read_resource" not in names
assert "use_prompt" not in names
def test_read_resource_filtered_when_no_resources(self, tmp_db, mock_openai_client):
"""read_resource excluded when MCP client has no resources."""
mcp_client = MagicMock()
mcp_client.get_tools.return_value = []
mcp_client.resource_count = 0
mcp_client.prompt_count = 2
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
mcp_client=mcp_client,
)
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "read_resource" not in names
assert "use_prompt" in names
def test_use_prompt_filtered_when_no_prompts(self, tmp_db, mock_openai_client):
"""use_prompt excluded when MCP client has no prompts."""
mcp_client = MagicMock()
mcp_client.get_tools.return_value = []
mcp_client.resource_count = 3
mcp_client.prompt_count = 0
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
mcp_client=mcp_client,
)
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "use_prompt" not in names
assert "read_resource" in names
def test_mcp_tools_kept_when_servers_have_both(self, tmp_db, mock_openai_client):
"""Both tools present when MCP client has resources and prompts."""
mcp_client = MagicMock()
mcp_client.get_tools.return_value = []
mcp_client.resource_count = 1
mcp_client.prompt_count = 1
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
mcp_client=mcp_client,
)
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "read_resource" in names
assert "use_prompt" in names
def test_mcp_tools_filtered_with_tool_search_active(self, tmp_db, mock_openai_client):
"""Gating applies even when tool_search is active (client-side path)."""
mcp_client = MagicMock()
mcp_client.get_tools.return_value = []
mcp_client.resource_count = 0
mcp_client.prompt_count = 0
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
mcp_client=mcp_client,
tool_search="on",
)
assert session._tool_search is not None
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "read_resource" not in names
assert "use_prompt" not in names
def test_mcp_tools_filtered_with_native_tool_search(self, tmp_db, mock_openai_client):
"""Gating applies when provider handles tool search natively."""
from unittest.mock import patch
from turnstone.core.providers._protocol import ModelCapabilities
mcp_client = MagicMock()
mcp_client.get_tools.return_value = []
mcp_client.resource_count = 0
mcp_client.prompt_count = 0
session = ChatSession(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
mcp_client=mcp_client,
tool_search="on",
)
caps = ModelCapabilities(supports_tool_search=True)
with patch.object(session, "_get_capabilities", return_value=caps):
tools = session._get_active_tools()
names = [t.get("function", {}).get("name") for t in tools]
assert "read_resource" not in names
assert "use_prompt" not in names
+33 -7
View File
@@ -254,20 +254,46 @@ class TestSecretMasking:
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["judge.api_key"]["value"] == "***"
def test_secret_write_blocked(self, client):
"""Secret settings cannot be modified via API."""
def test_secret_writable_via_api(self, client):
"""Secret settings can be written via API (write-only pattern)."""
r = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-secret-123"},
)
assert r.status_code == 403
assert "config.toml" in r.json()["error"]
assert r.status_code == 200
# Response value is masked even for the write confirmation
assert r.json()["value"] == "***"
def test_secret_shows_managed_label(self, client):
"""Secret settings show a label instead of a value."""
def test_secret_sentinel_preserves_existing(self, client):
"""Submitting '***' for a secret setting is a no-op (preserve existing)."""
# First write a real value
r1 = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-real-key"},
)
assert r1.status_code == 200
# Now submit the sentinel — should return unchanged with full response shape
r2 = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "***"},
)
assert r2.status_code == 200
data = r2.json()
assert data.get("unchanged") is True
assert data["key"] == "judge.api_key"
assert data["value"] == "***"
assert data["type"] == "str"
assert data["is_secret"] is True
def test_secret_still_masked_in_list(self, client):
"""After writing a secret, list still shows '***'."""
client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-written-via-api"},
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert "managed via" in by_key["judge.api_key"]["value"]
assert by_key["judge.api_key"]["value"] == "***"
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -52,7 +52,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+234
View File
@@ -0,0 +1,234 @@
"""Tests for TLS admin API endpoints and CLI commands."""
from __future__ import annotations
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
lacme = pytest.importorskip("lacme")
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
@pytest.fixture
def tls_manager():
"""Create an initialized TLSManager."""
import asyncio
from turnstone.console.tls import TLSManager
mgr = TLSManager(get_storage())
asyncio.run(mgr.init_ca())
# Issue a test cert
asyncio.run(mgr.issue_console_certs(["test.internal", "localhost"]))
return mgr
# ── Admin API endpoints ───────────────────────────────────────────────────────
def _make_app(tls_manager):
"""Create a minimal Starlette app with TLS endpoints."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from turnstone.console.server import (
tls_ca_cert,
tls_ca_status,
tls_delete_cert,
tls_list_certs,
tls_renew_cert,
)
from turnstone.core.auth import AuthResult
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="",
scopes=frozenset({"approve"}),
token_source="config",
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/ca.pem", tls_ca_cert),
Route("/certs", tls_list_certs),
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
)
app.state.tls_manager = tls_manager
return app
def test_list_certs(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.get("/certs")
assert resp.status_code == 200
data = resp.json()
assert len(data["certs"]) >= 1
assert data["certs"][0]["domain"] == "test.internal"
def test_renew_cert(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.post("/certs/test.internal/renew")
assert resp.status_code == 200
data = resp.json()
assert data["domain"] == "test.internal"
def test_renew_cert_not_found(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.post("/certs/nonexistent.internal/renew")
assert resp.status_code == 404
def test_delete_cert(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.delete("/certs/test.internal")
assert resp.status_code == 200
assert resp.json()["deleted"] == "test.internal"
# Verify it's gone
resp = client.get("/certs")
domains = [c["domain"] for c in resp.json()["certs"]]
assert "test.internal" not in domains
def test_delete_cert_not_found(tls_manager):
from starlette.testclient import TestClient
client = TestClient(_make_app(tls_manager))
resp = client.delete("/certs/nonexistent.internal")
assert resp.status_code == 404
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
def test_cli_bootstrap(tmp_path):
"""Test offline CA bootstrap."""
import argparse
from turnstone.admin import _cmd_tls_bootstrap
out = tmp_path / "certs"
args = argparse.Namespace(out=str(out), issue=["redis.internal", "pg.internal"])
_cmd_tls_bootstrap(args)
assert (out / "ca.pem").exists()
assert b"BEGIN CERTIFICATE" in (out / "ca.pem").read_bytes()
# Check certs were issued
assert (out / "certs" / "redis.internal").exists()
assert (out / "certs" / "pg.internal").exists()
def test_cli_bootstrap_no_issue(tmp_path):
"""Bootstrap with no --issue creates CA only."""
import argparse
from turnstone.admin import _cmd_tls_bootstrap
out = tmp_path / "certs"
args = argparse.Namespace(out=str(out), issue=[])
_cmd_tls_bootstrap(args)
assert (out / "ca.pem").exists()
# No certs dir
certs_dir = out / "certs"
if certs_dir.exists():
assert len(list(certs_dir.iterdir())) == 0
# ── Config parsing ────────────────────────────────────────────────────────────
def test_redis_tls_config_map():
"""Redis TLS keys are in the config map."""
from turnstone.core.config import _CONFIG_MAP
redis_map = _CONFIG_MAP["redis"]
assert "tls" in redis_map
assert "tls_ca" in redis_map
assert "tls_cert" in redis_map
assert "tls_key" in redis_map
def test_database_ssl_config_map():
"""Database SSL keys are in the config map."""
from turnstone.core.config import _CONFIG_MAP
db_map = _CONFIG_MAP["database"]
assert "sslmode" in db_map
assert "sslrootcert" in db_map
assert "sslcert" in db_map
assert "sslkey" in db_map
# ── Auth enforcement ──────────────────────────────────────────────────────────
def test_tls_endpoints_require_auth(tls_manager):
"""TLS admin endpoints return 401 without auth."""
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.server import tls_ca_status, tls_list_certs
# No auth middleware — request.state.auth_result will be missing
app = Starlette(
routes=[
Route("/ca", tls_ca_status),
Route("/certs", tls_list_certs),
]
)
app.state.tls_manager = tls_manager
client = TestClient(app)
resp = client.get("/ca")
assert resp.status_code == 401
resp = client.get("/certs")
assert resp.status_code == 401
# ── SDK TLS params ────────────────────────────────────────────────────────────
def test_sdk_client_cert_requires_both():
"""SDK raises ValueError if only one of client_cert/client_key provided."""
from turnstone.sdk._base import _BaseClient
with pytest.raises(ValueError, match="Both client_cert and client_key"):
_BaseClient(
base_url="http://localhost:8080",
client_cert="/path/to/cert.pem",
)
with pytest.raises(ValueError, match="Both client_cert and client_key"):
_BaseClient(
base_url="http://localhost:8080",
client_key="/path/to/key.pem",
)
+98
View File
@@ -0,0 +1,98 @@
"""Tests for TLSClient — service node certificate provisioning."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
lacme = pytest.importorskip("lacme")
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
# ── Console URL discovery ─────────────────────────────────────────────────────
def test_discover_console_url():
"""TLSClient discovers console URL from services table."""
from turnstone.core.tls import TLSClient
storage = get_storage()
storage.register_service("console", "console", "http://console:8080")
client = TLSClient(storage=storage, hostnames=["node-1"])
url = client._discover_console_url()
assert url == "http://console:8080"
def test_discover_console_url_missing():
"""TLSClient raises if no console registered."""
from turnstone.core.tls import TLSClient
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
with pytest.raises(RuntimeError, match="No console service found"):
client._discover_console_url()
def test_explicit_console_url_skips_discovery():
"""When console_url is provided, discovery is skipped."""
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://explicit:9090",
hostnames=["node-1"],
)
assert client._console_url == "http://explicit:9090"
# ── SSL context construction ─────────────────────────────────────────────────
@pytest.mark.anyio
async def test_ssl_contexts_none_before_init():
"""SSL contexts are None before init()."""
from turnstone.core.tls import TLSClient
client = TLSClient(
storage=get_storage(),
console_url="http://localhost:8080",
hostnames=["node-1"],
)
assert client.get_server_ssl_context() is None
assert client.get_client_ssl_context() is None
assert not client.initialized
# ── Backward compatibility ───────────────────────────────────────────────────
def test_bridge_tls_defaults():
"""Bridge with default TLS params works without changes."""
from turnstone.mq.bridge import Bridge
# Default: tls_verify=True, tls_cert=None — no mTLS
bridge = Bridge(server_url="http://localhost:8080")
assert bridge._tls_verify is True
assert bridge._tls_cert is None
def test_collector_tls_defaults():
"""Collector with default TLS params works without changes."""
from turnstone.console.collector import ClusterCollector
broker_mock = MagicMock()
collector = ClusterCollector(broker=broker_mock)
# Should create httpx client without errors
assert collector._http_client is not None
+226
View File
@@ -0,0 +1,226 @@
"""Tests for TLSManager — console CA and ACME server."""
from __future__ import annotations
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
lacme = pytest.importorskip("lacme")
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
@pytest.fixture
def tls_manager():
"""Create a TLSManager backed by test storage."""
from turnstone.console.tls import TLSManager
return TLSManager(get_storage())
# ── CA initialization ─────────────────────────────────────────────────────────
@pytest.mark.anyio
async def test_init_ca(tls_manager):
await tls_manager.init_ca()
assert tls_manager.ca_initialized
root_pem = tls_manager.get_root_cert_pem()
assert b"BEGIN CERTIFICATE" in root_pem
@pytest.mark.anyio
async def test_init_ca_persists(tls_manager):
"""CA root survives re-initialization (loaded from storage)."""
await tls_manager.init_ca()
pem1 = tls_manager.get_root_cert_pem()
# Create a new manager on the same storage
from turnstone.console.tls import TLSManager
mgr2 = TLSManager(get_storage())
await mgr2.init_ca()
pem2 = mgr2.get_root_cert_pem()
assert pem1 == pem2 # Same CA loaded from DB
@pytest.mark.anyio
async def test_get_responder_before_init(tls_manager):
with pytest.raises(RuntimeError, match="CA not initialized"):
tls_manager.get_responder()
@pytest.mark.anyio
async def test_get_responder(tls_manager):
await tls_manager.init_ca()
responder = tls_manager.get_responder()
assert responder is not None
# Should be an ASGI app (callable)
assert callable(responder)
# ── Cert issuance ─────────────────────────────────────────────────────────────
@pytest.mark.anyio
async def test_issue_console_certs_internal(tls_manager):
"""Console certs issued from internal CA when no external directory."""
await tls_manager.init_ca()
await tls_manager.issue_console_certs(["console.internal", "localhost"])
assert tls_manager.internal_bundle is not None
assert tls_manager.frontend_bundle is not None
assert tls_manager.internal_bundle.domain == "console.internal"
assert b"BEGIN CERTIFICATE" in tls_manager.internal_bundle.cert_pem
@pytest.mark.anyio
async def test_issue_console_certs_persists(tls_manager):
"""Certs loaded from storage on re-issue."""
await tls_manager.init_ca()
await tls_manager.issue_console_certs(["console.internal"])
bundle1 = tls_manager.internal_bundle
# New manager, same storage
from turnstone.console.tls import TLSManager
mgr2 = TLSManager(get_storage())
await mgr2.init_ca()
await mgr2.issue_console_certs(["console.internal"])
bundle2 = mgr2.internal_bundle
assert bundle1.cert_pem == bundle2.cert_pem
# ── SSL contexts ──────────────────────────────────────────────────────────────
@pytest.mark.anyio
async def test_ssl_contexts_none_before_certs(tls_manager):
await tls_manager.init_ca()
assert tls_manager.get_server_ssl_context() is None
assert tls_manager.get_client_ssl_context() is None
@pytest.mark.anyio
async def test_ssl_contexts_after_certs(tls_manager):
await tls_manager.init_ca()
await tls_manager.issue_console_certs(["console.internal"])
server_ctx = tls_manager.get_server_ssl_context()
client_ctx = tls_manager.get_client_ssl_context()
assert server_ctx is not None
assert client_ctx is not None
import ssl
assert isinstance(server_ctx, ssl.SSLContext)
assert isinstance(client_ctx, ssl.SSLContext)
# ── Root cert endpoint ────────────────────────────────────────────────────────
@pytest.mark.anyio
async def test_tls_ca_cert_endpoint(tls_manager):
"""Test the CA cert download endpoint via test client."""
await tls_manager.init_ca()
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.server import tls_ca_cert, tls_ca_status
# Middleware that grants full access (config-token style: no user_id)
from turnstone.core.auth import AuthResult
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config"
)
return await call_next(request)
from starlette.middleware.base import BaseHTTPMiddleware
app = Starlette(
routes=[
Route("/ca.pem", tls_ca_cert),
Route("/ca", tls_ca_status),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
)
app.state.tls_manager = tls_manager
client = TestClient(app)
# CA cert download
resp = client.get("/ca.pem")
assert resp.status_code == 200
assert b"BEGIN CERTIFICATE" in resp.content
assert resp.headers["content-type"] == "application/x-pem-file"
# CA status
resp = client.get("/ca")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["ca_cn"] == "Turnstone CA"
@pytest.mark.anyio
async def test_tls_endpoints_disabled():
"""Endpoints return 404/disabled when TLS not enabled."""
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from starlette.testclient import TestClient
from turnstone.console.server import tls_ca_cert, tls_ca_status
from turnstone.core.auth import AuthResult
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
request.state.auth_result = AuthResult(
user_id="", scopes=frozenset({"approve"}), token_source="config"
)
return await call_next(request)
app = Starlette(
routes=[
Route("/ca.pem", tls_ca_cert),
Route("/ca", tls_ca_status),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
)
# No tls_manager on state
client = TestClient(app)
resp = client.get("/ca.pem")
assert resp.status_code == 404
resp = client.get("/ca")
data = resp.json()
assert data["enabled"] is False
# ── Events ────────────────────────────────────────────────────────────────────
@pytest.mark.anyio
async def test_event_dispatcher_wired(tls_manager):
"""Verify the event dispatcher has subscribers."""
assert tls_manager._event_dispatcher is not None
# Should have at least 4 subscriptions (issued, renewed, expiring, failed)
# The exact check depends on lacme's EventDispatcher internals,
# so just verify the dispatcher exists and the manager initializes cleanly
await tls_manager.init_ca()
+226
View File
@@ -0,0 +1,226 @@
"""Tests for TLS storage backend and lacme Store adapter."""
from __future__ import annotations
import json
from datetime import UTC, datetime
import pytest
from turnstone.core.storage import get_storage, init_storage, reset_storage
@pytest.fixture(autouse=True)
def _storage(tmp_path):
"""Initialize ephemeral SQLite storage for each test."""
reset_storage()
db = str(tmp_path / "test.db")
init_storage("sqlite", path=db)
yield
reset_storage()
# ── Account keys ──────────────────────────────────────────────────────────────
def test_save_and_load_account_key():
s = get_storage()
s.save_tls_account_key(
"default",
"-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----",
)
result = s.load_tls_account_key("default")
assert result is not None
assert "EC PRIVATE KEY" in result
def test_load_account_key_missing():
s = get_storage()
assert s.load_tls_account_key("nonexistent") is None
def test_save_account_key_upsert():
s = get_storage()
s.save_tls_account_key("default", "key-v1")
s.save_tls_account_key("default", "key-v2")
assert s.load_tls_account_key("default") == "key-v2"
# ── CA ────────────────────────────────────────────────────────────────────────
def test_save_and_load_ca():
s = get_storage()
s.save_tls_ca("Turnstone CA", "cert-pem-data", "key-pem-data")
result = s.load_tls_ca("Turnstone CA")
assert result is not None
assert result["cert_pem"] == "cert-pem-data"
assert result["key_pem"] == "key-pem-data"
assert result["name"] == "Turnstone CA"
def test_load_ca_missing():
s = get_storage()
assert s.load_tls_ca("nonexistent") is None
def test_save_ca_upsert():
s = get_storage()
s.save_tls_ca("CA", "cert-v1", "key-v1")
s.save_tls_ca("CA", "cert-v2", "key-v2")
result = s.load_tls_ca("CA")
assert result["cert_pem"] == "cert-v2"
assert result["key_pem"] == "key-v2"
# ── Certificates ──────────────────────────────────────────────────────────────
def test_save_and_load_cert():
s = get_storage()
s.save_tls_cert(
domain="node-1.internal",
cert_pem="cert-data",
fullchain_pem="fullchain-data",
key_pem="key-data",
issued_at="2026-03-25T00:00:00",
expires_at="2026-03-27T00:00:00",
meta=json.dumps({"domains": ["node-1.internal", "10.0.1.5"]}),
)
result = s.load_tls_cert("node-1.internal")
assert result is not None
assert result["domain"] == "node-1.internal"
assert result["cert_pem"] == "cert-data"
assert result["fullchain_pem"] == "fullchain-data"
assert result["key_pem"] == "key-data"
assert result["issued_at"] == "2026-03-25T00:00:00"
assert result["expires_at"] == "2026-03-27T00:00:00"
meta = json.loads(result["meta"])
assert meta["domains"] == ["node-1.internal", "10.0.1.5"]
def test_load_cert_missing():
s = get_storage()
assert s.load_tls_cert("nonexistent") is None
def test_save_cert_upsert():
s = get_storage()
s.save_tls_cert("d", "c1", "f1", "k1", "2026-01-01", "2026-01-02")
s.save_tls_cert("d", "c2", "f2", "k2", "2026-02-01", "2026-02-02")
result = s.load_tls_cert("d")
assert result["cert_pem"] == "c2"
assert result["issued_at"] == "2026-02-01"
def test_list_certs_empty():
s = get_storage()
assert s.list_tls_certs() == []
def test_list_certs():
s = get_storage()
s.save_tls_cert("alpha.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
s.save_tls_cert("beta.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
certs = s.list_tls_certs()
assert len(certs) == 2
assert certs[0]["domain"] == "alpha.internal" # sorted by domain
assert certs[1]["domain"] == "beta.internal"
def test_delete_cert():
s = get_storage()
s.save_tls_cert("d", "c", "f", "k", "2026-01-01", "2026-01-02")
assert s.delete_tls_cert("d") is True
assert s.load_tls_cert("d") is None
def test_delete_cert_missing():
s = get_storage()
assert s.delete_tls_cert("nonexistent") is False
# ── StorageStore adapter ──────────────────────────────────────────────────────
@pytest.fixture
def store_adapter():
"""Create a StorageStore backed by the test database."""
from turnstone.core.tls_store import StorageStore
return StorageStore(get_storage())
def test_adapter_save_load_ca(store_adapter):
store_adapter.save_ca("test-ca", b"cert-pem", b"key-pem")
result = store_adapter.load_ca("test-ca")
assert result is not None
cert_pem, key_pem = result
assert cert_pem == b"cert-pem"
assert key_pem == b"key-pem"
def test_adapter_load_ca_missing(store_adapter):
assert store_adapter.load_ca("missing") is None
def test_adapter_save_load_cert(store_adapter):
lacme = pytest.importorskip("lacme")
now = datetime.now(UTC)
bundle = lacme.CertBundle(
domain="test.internal",
domains=("test.internal", "10.0.1.1"),
cert_pem=b"cert",
fullchain_pem=b"fullchain",
key_pem=b"key",
issued_at=now,
expires_at=now,
)
store_adapter.save_cert(bundle)
loaded = store_adapter.load_cert("test.internal")
assert loaded is not None
assert loaded.domain == "test.internal"
assert loaded.domains == ("test.internal", "10.0.1.1")
assert loaded.cert_pem == b"cert"
assert loaded.fullchain_pem == b"fullchain"
assert loaded.key_pem == b"key"
def test_adapter_list_certs(store_adapter):
lacme = pytest.importorskip("lacme")
now = datetime.now(UTC)
for name in ["alpha", "beta"]:
bundle = lacme.CertBundle(
domain=f"{name}.internal",
domains=(f"{name}.internal",),
cert_pem=b"c",
fullchain_pem=b"f",
key_pem=b"k",
issued_at=now,
expires_at=now,
)
store_adapter.save_cert(bundle)
certs = store_adapter.list_certs()
assert len(certs) == 2
assert certs[0].domain == "alpha.internal"
def test_adapter_load_cert_missing(store_adapter):
assert store_adapter.load_cert("missing") is None
def test_adapter_account_key_roundtrip(store_adapter):
"""Test account key save/load with real cryptography objects."""
pytest.importorskip("lacme")
from cryptography.hazmat.primitives.asymmetric import ec
key = ec.generate_private_key(ec.SECP256R1())
store_adapter.save_account_key(key)
loaded = store_adapter.load_account_key()
assert loaded is not None
# Verify it's a usable EC key
assert loaded.key_size == key.key_size
def test_adapter_account_key_missing(store_adapter):
assert store_adapter.load_account_key() is None
+2 -2
View File
@@ -104,8 +104,8 @@ class TestToolsMetadata:
"man": "page",
"web_fetch": "url",
"web_search": "query",
"task": "prompt",
"create_plan": "goal",
"task_agent": "prompt",
"plan_agent": "goal",
"memory": "name",
"recall": "query",
"notify": "message",
+217
View File
@@ -0,0 +1,217 @@
"""Tests for _make_watch_dispatch error/cancel handling and concurrency guards."""
import queue
import threading
import time
from turnstone.core.session import GenerationCancelled
from turnstone.core.workstream import Workstream
from turnstone.server import _make_watch_dispatch
class _StubSession:
"""Minimal ChatSession stand-in with controllable send() behaviour."""
def __init__(self, *, side_effect=None):
self._watch_pending: queue.Queue = queue.Queue(maxsize=20)
self._side_effect = side_effect
def send(self, msg: str) -> None:
if self._side_effect is not None:
raise self._side_effect
class _RecordingUI:
"""Track calls made by the dispatch error handlers."""
def __init__(self):
self.errors: list[str] = []
self.state_changes: list[str] = []
self.stream_end_calls: int = 0
# -- SessionUI protocol stubs used by the dispatch code --
def on_error(self, message: str) -> None:
self.errors.append(message)
def on_state_change(self, state: str) -> None:
self.state_changes.append(state)
def on_stream_end(self) -> None:
self.stream_end_calls += 1
# ── helpers ──────────────────────────────────────────────────────────────────
def _wait_for_worker(ws: Workstream, timeout: float = 2.0) -> None:
"""Block until the worker thread started by dispatch() finishes."""
t = ws.worker_thread
if t is not None:
t.join(timeout)
assert not t.is_alive(), "worker thread did not finish in time"
# ── GenerationCancelled path ────────────────────────────────────────────────
def test_cancelled_emits_stream_end_and_idle():
session = _StubSession(side_effect=GenerationCancelled())
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 1
assert ui.state_changes == ["idle"]
assert ui.errors == []
# ── Generic exception path ──────────────────────────────────────────────────
def test_exception_emits_stream_end_and_error():
session = _StubSession(side_effect=RuntimeError("boom"))
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 1
assert ui.state_changes == ["error"]
assert len(ui.errors) == 1
assert "boom" in ui.errors[0]
# ── Worker-thread identity guard ────────────────────────────────────────────
def test_abandoned_thread_emits_no_events():
"""After force-cancel sets worker_thread=None, the old thread must not
emit stream_end or state changes."""
barrier = threading.Event()
class _BlockingSession(_StubSession):
def send(self, msg: str) -> None:
barrier.wait(timeout=5)
raise RuntimeError("late error")
session = _BlockingSession()
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
# Simulate force-cancel: clear the worker_thread reference.
ws.worker_thread = None
barrier.set()
# Wait for the thread to actually complete (it's still running).
time.sleep(0.3)
assert ui.stream_end_calls == 0
assert ui.state_changes == []
assert ui.errors == []
# ── Path A: busy workstream enqueue ─────────────────────────────────────────
def test_busy_workstream_enqueues_message():
"""When the workstream already has a live worker, dispatch enqueues."""
session = _StubSession()
ws = Workstream()
ui = _RecordingUI()
# Simulate a live worker thread.
blocker = threading.Event()
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
ws.worker_thread.start()
try:
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("queued msg")
item = session._watch_pending.get_nowait()
assert item == {"message": "queued msg"}
finally:
blocker.set()
ws.worker_thread.join(2)
def test_busy_workstream_drops_on_full_queue():
"""When the pending queue is full, dispatch drops the message."""
session = _StubSession()
# Fill the queue to capacity.
for i in range(20):
session._watch_pending.put_nowait({"message": f"msg{i}"})
ws = Workstream()
ui = _RecordingUI()
blocker = threading.Event()
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
ws.worker_thread.start()
try:
dispatch = _make_watch_dispatch(ws, session, ui)
# Should not block or raise — just log a warning and drop.
dispatch("overflow msg")
assert session._watch_pending.full()
finally:
blocker.set()
ws.worker_thread.join(2)
# ── Lock guard ───────────────────────────────────────────────────────────────
def test_dispatch_holds_lock_during_thread_start():
"""Dispatch acquires ws._lock before checking/starting the worker."""
session = _StubSession()
ws = Workstream()
ui = _RecordingUI()
acquire_count = 0
inner = ws._lock
class _CountingLock:
def __enter__(self):
nonlocal acquire_count
acquire_count += 1
return inner.__enter__()
def __exit__(self, *args):
return inner.__exit__(*args)
ws._lock = _CountingLock() # type: ignore[assignment]
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert acquire_count >= 1
# ── Happy path ───────────────────────────────────────────────────────────────
def test_successful_send_no_error_events():
"""Normal send() completion should not trigger error/cancel events."""
session = _StubSession() # send() does nothing (success)
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 0
assert ui.state_changes == []
assert ui.errors == []
+2 -2
View File
@@ -84,7 +84,7 @@ class TestTavilyClient:
class TestDuckDuckGoClient:
def test_integration_via_mock_ddgs(self):
"""Patch the duckduckgo_search import inside DuckDuckGoClient.search."""
"""Patch the ddgs import inside DuckDuckGoClient.search."""
mock_ddgs = MagicMock()
mock_ddgs.__enter__ = MagicMock(return_value=mock_ddgs)
mock_ddgs.__exit__ = MagicMock(return_value=False)
@@ -93,7 +93,7 @@ class TestDuckDuckGoClient:
]
mock_module = MagicMock()
mock_module.DDGS.return_value = mock_ddgs
with patch.dict("sys.modules", {"duckduckgo_search": mock_module}):
with patch.dict("sys.modules", {"ddgs": mock_module}):
client = DuckDuckGoClient(timeout=10)
result = client.search("test query", max_results=3)
mock_ddgs.text.assert_called_once_with("test query", max_results=3)
+1 -1
View File
@@ -54,7 +54,7 @@ class FakeUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+124
View File
@@ -0,0 +1,124 @@
# turnstone.toml — shared bootstrap configuration
#
# This file is read once at startup. Values here are overridden by
# environment variables, which are in turn overridden by CLI flags.
#
# All sections are optional. Missing sections use binary defaults.
# Config file location precedence:
# 1. --config flag
# 2. $TURNSTONE_CONFIG env var
# 3. ~/.config/turnstone/config.toml
# --- LLM API (turnstone, node, eval) ---
[api]
# base_url = "" # API endpoint; empty = binary default
# api_key = "" # env: OPENAI_API_KEY or ANTHROPIC_API_KEY
# --- Default Model (turnstone, node, eval) ---
[model]
# name = "" # Model ID; empty = provider default (gpt-5 / claude-sonnet-4)
# temperature = 0.0 # 0 = provider default
# reasoning_effort = "" # "low", "medium", "high", "max"
# context_window = 0 # 0 = auto-detect from provider capabilities
# max_tokens = 0 # 0 = provider default
# --- Named Models (turnstone, node, eval) ---
# Define model aliases with per-model overrides. Useful for local model
# servers or mixing providers. Reference by name with --model flag.
#
# [models.local]
# name = "llama-3-70b"
# provider = "openai"
# base_url = "http://localhost:8000/v1"
# context_window = 8192
#
# [models.local.capabilities]
# supports_vision = false
# supports_web_search = false
#
# [models.claude]
# name = "claude-opus-4-6"
# provider = "anthropic"
# --- Database (turnstone, node, console) ---
[database]
# url = "" # postgres://user:pass@host/db or /path/to.db
# env: TURNSTONE_DB_URL
# SSL params (passed through to SQLAlchemy connection):
# sslmode = "prefer" # disable, allow, prefer, require, verify-ca, verify-full
# sslrootcert = "" # path to CA cert for verify-ca/verify-full
# sslcert = "" # path to client cert (mTLS)
# sslkey = "" # path to client key (mTLS)
# --- Redis (bridge, console, channel) ---
[redis]
# url = "" # redis://host:6379/0 or rediss://host:6380/0
# env: TURNSTONE_REDIS_URL
# TLS params (passed through to Redis connection):
# tls = false # enable TLS (also auto-enabled by rediss:// scheme)
# tls_ca = "" # path to CA cert
# tls_cert = "" # path to client cert (mTLS)
# tls_key = "" # path to client key (mTLS)
# --- Auth (node, console) ---
[auth]
# enabled = true # env: TURNSTONE_AUTH_ENABLED
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
# env: TURNSTONE_JWT_SECRET
# token = "" # Static config token for full access
# env: TURNSTONE_AUTH_TOKEN
# --- Logging (turnstone, node, console) ---
[log]
# level = "" # "debug", "info", "warn", "error"
# empty = binary default (warn for CLI, info for servers)
# env: TURNSTONE_LOG_LEVEL
# json = false # JSON output; auto-enabled when stderr is not a TTY
# --- Session (turnstone, node) ---
[session]
# instructions = "" # Default system message
# compact_max_tokens = 32768 # Max tokens for context compaction summary
# auto_compact_pct = 0.8 # Trigger compaction at this % of context window
# --- Tools (turnstone, node) ---
[tools]
# timeout = 120 # Tool execution timeout in seconds
# skip_permissions = false # Auto-approve all tool calls
# --- Judge (turnstone, node) ---
[judge]
# enabled = true # Enable intent validation
# confidence_threshold = 0.7 # Minimum confidence for heuristic verdicts
# output_guard = true # Scan tool output for security signals
# redact_secrets = true # Redact detected credentials in output
# --- Memory (turnstone, node) ---
[memory]
# relevance_k = 5 # Top-K memories for context injection
# fetch_limit = 50 # Max memories to fetch for ranking
# max_content = 32768 # Max memory content size in chars
# nudge_cooldown = 300 # Min seconds between metacognitive nudges
# nudges = true # Enable memory nudges
# --- MCP (turnstone, node) ---
[mcp]
# config_path = "" # Path to MCP servers config file (JSON)
# refresh_interval = 14400 # Refresh interval in seconds (default: 4h)
# --- Server (node, console) ---
[server]
# max_workstreams = 50 # Maximum concurrent workstreams per node
# env: TURNSTONE_MAX_WORKSTREAMS
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.8.5"
__version__ = "0.9.2"
+216
View File
@@ -142,6 +142,189 @@ def _cmd_revoke_token(args: argparse.Namespace) -> None:
sys.exit(1)
# ---------------------------------------------------------------------------
# TLS commands
# ---------------------------------------------------------------------------
def _cmd_tls_bootstrap(args: argparse.Namespace) -> None:
"""Initialize CA and issue certs offline."""
try:
from lacme import CertificateAuthority, FileStore
except ImportError:
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
sys.exit(1)
import contextlib
import os
from pathlib import Path
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(PermissionError):
os.chmod(out_dir, 0o700) # Restrict access — contains CA private key
store = FileStore(str(out_dir))
ca = CertificateAuthority(store, name="turnstone")
ca.init(cn="Turnstone CA", validity_days=3650)
print(f"CA initialized in {out_dir} (permissions: 0700)")
# Write CA cert to a well-known location
ca_cert_path = out_dir / "ca.pem"
ca_cert_path.write_bytes(ca.root_cert_pem)
with contextlib.suppress(PermissionError):
os.chmod(ca_cert_path, 0o644)
print(f"CA cert: {ca_cert_path}")
# Issue certs for requested domains
for domain in args.issue:
bundle = ca.issue([domain], validity_hours=48)
store.save_cert(bundle)
cert_dir = out_dir / "certs" / domain
print(f"Issued: {domain} -> {cert_dir}")
print(f"\nBootstrap complete. {len(args.issue)} cert(s) issued.")
print(f"CA and certs written to: {out_dir}")
def _cmd_tls_issue(args: argparse.Namespace) -> None:
"""Request a cert from the console's ACME endpoint."""
try:
from lacme import SyncClient
except ImportError:
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
sys.exit(1)
import os
from pathlib import Path
console_url = args.console_url
if not console_url:
console_url = _discover_console_url()
domains = [args.domain] + args.san
directory_url = f"{console_url}/acme/directory"
print(f"Requesting cert for {domains} from {directory_url}")
client = SyncClient(
directory_url=directory_url,
allow_insecure=True,
)
bundle = client.issue(domains)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "cert.pem").write_bytes(bundle.cert_pem)
(out_dir / "fullchain.pem").write_bytes(bundle.fullchain_pem)
(out_dir / "key.pem").write_bytes(bundle.key_pem)
os.chmod(out_dir / "key.pem", 0o600)
print(f"Certificate written to {out_dir}/")
print(" cert.pem (leaf certificate)")
print(" fullchain.pem (cert + chain)")
print(" key.pem (private key, 0600)")
def _cmd_tls_ca_cert(args: argparse.Namespace) -> None:
"""Download the CA root certificate from the console."""
import httpx
console_url = args.console_url
if not console_url:
console_url = _discover_console_url()
# Use plain HTTP for bootstrap (node may not have CA cert yet)
# WARNING: This is trust-on-first-use (TOFU) — verify the fingerprint
base = console_url.replace("https://", "http://")
url = f"{base}/acme/ca.pem"
print(f"Fetching CA cert from {url}")
print("WARNING: Fetching over plain HTTP — verify the fingerprint below")
resp = httpx.get(url)
resp.raise_for_status()
# Show fingerprint for out-of-band verification
import hashlib
fingerprint = hashlib.sha256(resp.content).hexdigest()
print(f"CA cert SHA-256: {fingerprint}")
from pathlib import Path
Path(args.out).write_bytes(resp.content)
print(f"CA cert written to {args.out}")
def _cmd_tls_list(args: argparse.Namespace) -> None:
"""List certificates from the console."""
import httpx
console_url = args.console_url
if not console_url:
console_url = _discover_console_url()
url = f"{console_url}/v1/api/admin/tls/certs"
headers = {}
token = getattr(args, "auth_token", "") or _get_config_token()
if token:
headers["Authorization"] = f"Bearer {token}"
resp = httpx.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
certs = data.get("certs", [])
if not certs:
print("No certificates issued.")
return
print(f"{'DOMAIN':<30s} {'ISSUED':<22s} {'EXPIRES':<22s}")
print("-" * 74)
for c in certs:
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
def _get_config_token() -> str:
"""Try to load auth token from config.toml or environment."""
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
if token:
return token
try:
from turnstone.core.config import load_config
cfg = load_config("auth")
return str(cfg.get("token", ""))
except Exception:
return ""
def _discover_console_url() -> str:
"""Discover console URL from the services table."""
from turnstone.core.storage import get_storage
try:
storage = get_storage()
except Exception:
print(
"No storage configured. Use --console-url or run from a "
"directory with a turnstone database.",
file=sys.stderr,
)
sys.exit(1)
consoles = storage.list_services("console", max_age_seconds=3600)
if not consoles:
print(
"No console found in services table. Use --console-url explicitly.",
file=sys.stderr,
)
sys.exit(1)
return consoles[0]["url"]
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for turnstone-admin CLI."""
parser = argparse.ArgumentParser(
@@ -171,6 +354,35 @@ def main() -> None:
p_rt = sub.add_parser("revoke-token", help="Revoke an API token")
p_rt.add_argument("--token-id", required=True, help="Token ID to revoke")
# TLS subcommands
p_bootstrap = sub.add_parser(
"tls-bootstrap",
help="Initialize CA and issue certs offline (no running console needed)",
)
p_bootstrap.add_argument("--out", required=True, help="Output directory for PEM files")
p_bootstrap.add_argument(
"--issue",
action="append",
default=[],
help="Domain to issue cert for (repeatable)",
)
p_issue = sub.add_parser("tls-issue", help="Request cert from console ACME")
p_issue.add_argument("domain", help="Primary domain for the certificate")
p_issue.add_argument("--san", action="append", default=[], help="Additional SAN (repeatable)")
p_issue.add_argument("--out", default=".", help="Output directory for PEM files")
p_issue.add_argument(
"--console-url", default="", help="Console URL (discovered from DB if empty)"
)
p_cacert = sub.add_parser("tls-ca-cert", help="Download CA root certificate")
p_cacert.add_argument("--out", default="ca.pem", help="Output file path")
p_cacert.add_argument("--console-url", default="", help="Console URL")
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
p_tlslist.add_argument("--console-url", default="", help="Console URL")
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -182,5 +394,9 @@ def main() -> None:
"list-users": _cmd_list_users,
"list-tokens": _cmd_list_tokens,
"revoke-token": _cmd_revoke_token,
"tls-bootstrap": _cmd_tls_bootstrap,
"tls-issue": _cmd_tls_issue,
"tls-ca-cert": _cmd_tls_ca_cert,
"tls-list": _cmd_tls_list,
}
dispatch[args.command](args)
+91
View File
@@ -785,3 +785,94 @@ class RegistryInstallRequest(BaseModel):
variables: dict[str, str] = Field(default_factory=dict)
env: dict[str, str] = Field(default_factory=dict)
headers: dict[str, str] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Admin: Model Definitions
# ---------------------------------------------------------------------------
class ModelDefinitionInfo(BaseModel):
definition_id: str
alias: str
model: str
provider: str = "openai"
base_url: str = ""
api_key: str = ""
context_window: int = 32768
capabilities: str = "{}"
enabled: bool = True
source: str = ""
created_by: str = ""
created: str = ""
updated: str = ""
class CreateModelDefinitionRequest(BaseModel):
alias: str
model: str
provider: str = "openai"
base_url: str = ""
api_key: str = ""
context_window: int = 32768
capabilities: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class UpdateModelDefinitionRequest(BaseModel):
alias: str | None = None
model: str | None = None
provider: str | None = None
base_url: str | None = None
api_key: str | None = None
context_window: int | None = None
capabilities: dict[str, Any] | None = None
enabled: bool | None = None
class ListModelDefinitionsResponse(BaseModel):
models: list[ModelDefinitionInfo]
class ModelReloadResponse(BaseModel):
status: str = "ok"
results: dict[str, Any] = Field(default_factory=dict)
class DetectModelRequest(BaseModel):
provider: str = "openai"
base_url: str = ""
api_key: str = ""
model: str = ""
definition_id: str = ""
class DetectModelResponse(BaseModel):
reachable: bool = False
model_found: bool | None = None
available_models: list[str] = Field(default_factory=list)
context_window: int | None = None
server_type: str | None = None
error: str | None = None
class ModelCapabilitiesResponse(BaseModel):
model: str
provider: str
known: bool = False
capabilities: dict[str, Any] = Field(default_factory=dict)
class KnownModelsResponse(BaseModel):
provider: str
models: list[str] = Field(default_factory=list)
class AvailableModelInfo(BaseModel):
alias: str
model: str
provider: str
class ListAvailableModelsResponse(BaseModel):
models: list[AvailableModelInfo] = Field(default_factory=list)
+141
View File
@@ -11,6 +11,7 @@ from turnstone.api.console_schemas import (
AdminMemoryInfo,
AssignRoleRequest,
AuditEventInfo,
AvailableModelInfo,
ChannelUserInfo,
ClusterNodesResponse,
ClusterOverviewResponse,
@@ -21,16 +22,22 @@ from turnstone.api.console_schemas import (
ConsoleHealthResponse,
CreateChannelUserRequest,
CreateMcpServerRequest,
CreateModelDefinitionRequest,
CreateRoleRequest,
CreateSkillRequest,
CreateSkillResourceRequest,
CreateToolPolicyRequest,
DetectModelRequest,
DetectModelResponse,
ImportMcpConfigRequest,
ImportMcpConfigResponse,
KnownModelsResponse,
ListAdminMemoriesResponse,
ListAuditEventsResponse,
ListAvailableModelsResponse,
ListChannelUsersResponse,
ListMcpServersResponse,
ListModelDefinitionsResponse,
ListOrgsResponse,
ListOutputAssessmentsResponse,
ListRolesResponse,
@@ -44,6 +51,9 @@ from turnstone.api.console_schemas import (
ListVerdictsResponse,
McpReloadResponse,
McpServerDetail,
ModelCapabilitiesResponse,
ModelDefinitionInfo,
ModelReloadResponse,
NodeDetailResponse,
OrgInfo,
OutputAssessmentInfo,
@@ -60,6 +70,7 @@ from turnstone.api.console_schemas import (
SkillVersionInfo,
ToolPolicyInfo,
UpdateMcpServerRequest,
UpdateModelDefinitionRequest,
UpdateOrgRequest,
UpdateRoleRequest,
UpdateSettingRequest,
@@ -560,6 +571,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
response_model=ListSkillVersionsResponse,
tags=["Admin"],
),
# --- Models ---
EndpointSpec(
"/v1/api/models",
"GET",
"List enabled model aliases for workstream creation",
response_model=ListAvailableModelsResponse,
tags=["Models"],
),
# --- Skills ---
EndpointSpec(
"/v1/api/skills",
@@ -843,6 +862,117 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400],
tags=["Admin"],
),
# --- Admin: Model Definitions ---
EndpointSpec(
"/v1/api/admin/model-definitions",
"GET",
"List model definitions with live status from cluster nodes",
response_model=ListModelDefinitionsResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions",
"POST",
"Create a model definition",
request_model=CreateModelDefinitionRequest,
response_model=ModelDefinitionInfo,
error_codes=[400, 409],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/reload",
"POST",
"Tell all nodes to re-read model definitions from DB and rebuild registry",
response_model=ModelReloadResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"GET",
"Get a single model definition",
response_model=ModelDefinitionInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"PUT",
"Update a model definition",
request_model=UpdateModelDefinitionRequest,
response_model=ModelDefinitionInfo,
error_codes=[400, 404, 409],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"DELETE",
"Delete a model definition",
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/detect",
"POST",
"Probe a model endpoint: verify reachability, list models, detect context window and server type",
request_model=DetectModelRequest,
response_model=DetectModelResponse,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-capabilities",
"GET",
"Look up static capabilities for a known model",
response_model=ModelCapabilitiesResponse,
query_params=[
QueryParam(name="provider", description="Provider name", required=True),
QueryParam(name="model", description="Model ID to look up", required=True),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-capabilities/known",
"GET",
"List known model name prefixes for a provider",
response_model=KnownModelsResponse,
query_params=[
QueryParam(name="provider", description="Provider name", required=True),
],
tags=["Admin"],
),
# --- Admin: TLS / ACME ---
EndpointSpec(
"/v1/api/admin/tls/ca",
"GET",
"CA status: initialization state, CN, cert count, cert inventory",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/tls/ca.pem",
"GET",
"Download CA root certificate (PEM format)",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/tls/certs",
"GET",
"List all issued TLS certificates",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/tls/certs/{domain}/renew",
"POST",
"Force-renew a certificate by domain",
error_codes=[404, 500],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/tls/certs/{domain}",
"DELETE",
"Delete a certificate by domain",
error_codes=[404],
tags=["Admin"],
),
# --- Observability ---
EndpointSpec(
"/health",
@@ -920,6 +1050,17 @@ _ALL_MODELS: list[type[BaseModel]] = [
ImportMcpConfigRequest,
ImportMcpConfigResponse,
McpReloadResponse,
ModelDefinitionInfo,
CreateModelDefinitionRequest,
UpdateModelDefinitionRequest,
ListModelDefinitionsResponse,
ModelReloadResponse,
DetectModelRequest,
DetectModelResponse,
ModelCapabilitiesResponse,
KnownModelsResponse,
AvailableModelInfo,
ListAvailableModelsResponse,
RegistrySearchResponse,
RegistryInstallRequest,
SkillDiscoverResponse,
+15
View File
@@ -41,6 +41,11 @@ class CommandRequest(BaseModel):
class CancelRequest(BaseModel):
ws_id: str = Field(description="Target workstream ID")
force: bool = Field(
default=False,
description="Force cancel: abandon the stuck worker thread immediately. "
"Use when cooperative cancel has not resolved within a few seconds.",
)
class CreateWorkstreamRequest(BaseModel):
@@ -252,3 +257,13 @@ class SkillSummary(BaseModel):
class ListSkillSummaryResponse(BaseModel):
skills: list[SkillSummary]
class AvailableModelInfo(BaseModel):
alias: str
model: str
provider: str
class ListAvailableModelsResponse(BaseModel):
models: list[AvailableModelInfo] = Field(default_factory=list)
+12
View File
@@ -20,6 +20,7 @@ from turnstone.api.schemas import (
)
from turnstone.api.server_schemas import (
ApproveRequest,
AvailableModelInfo,
CancelRequest,
CloseWorkstreamRequest,
CommandRequest,
@@ -27,6 +28,7 @@ from turnstone.api.server_schemas import (
CreateWorkstreamResponse,
DashboardResponse,
HealthResponse,
ListAvailableModelsResponse,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListSkillSummaryResponse,
@@ -155,6 +157,14 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
response_model=ListSkillSummaryResponse,
tags=["Skills"],
),
# --- Models ---
EndpointSpec(
"/v1/api/models",
"GET",
"List available model aliases",
response_model=ListAvailableModelsResponse,
tags=["Models"],
),
# --- Auth ---
EndpointSpec(
"/v1/api/auth/login",
@@ -293,6 +303,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
SearchMemoriesRequest,
SkillSummary,
ListSkillSummaryResponse,
AvailableModelInfo,
ListAvailableModelsResponse,
]
+21 -1
View File
@@ -58,6 +58,11 @@ def main() -> None:
help="HTTP server port (default: $TURNSTONE_CHANNEL_PORT or 8091)",
)
# -- TLS -----------------------------------------------------------------
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file for HTTPS")
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
# -- Auth ----------------------------------------------------------------
parser.add_argument(
"--auth-token",
@@ -189,7 +194,8 @@ def main() -> None:
advertise_host = socket.gethostname()
else:
advertise_host = args.http_host
advertise_url = f"http://{advertise_host}:{args.http_port}"
scheme = "https" if args.ssl_certfile else "http"
advertise_url = f"{scheme}://{advertise_host}:{args.http_port}"
service_url = advertise_url
# Register in service registry
@@ -209,11 +215,25 @@ def main() -> None:
except Exception:
log.exception("channel.heartbeat_failed")
# TLS: use cert files if available (from bootstrap or TLSClient)
ssl_certfile = getattr(args, "ssl_certfile", None)
ssl_keyfile = getattr(args, "ssl_keyfile", None)
ssl_ca_certs = getattr(args, "ssl_ca_certs", None)
if bool(ssl_certfile) != bool(ssl_keyfile):
print(
"Both --ssl-certfile and --ssl-keyfile are required for TLS",
file=sys.stderr,
)
sys.exit(1)
uv_config = uvicorn.Config(
channel_app,
host=args.http_host,
port=args.http_port,
log_level="warning",
ssl_certfile=ssl_certfile,
ssl_keyfile=ssl_keyfile,
ssl_ca_certs=ssl_ca_certs,
)
server = uvicorn.Server(uv_config)
+21 -4
View File
@@ -251,8 +251,18 @@ class TerminalUI(SessionUI):
item["denial_msg"] = denial_msg
return False, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
pass # Optional: display summary
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if is_error:
with self._print_lock:
sys.stderr.write(f"{RED}\u2717 {name}: {output}{RESET}\n")
sys.stderr.flush()
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
pass # Terminal shows spinner during tool execution
@@ -424,9 +434,16 @@ class WorkstreamTerminalUI(TerminalUI):
else:
self._buffer("error", message)
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if self.is_foreground:
super().on_tool_result(call_id, name, output)
super().on_tool_result(call_id, name, output, is_error=is_error)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
if self.is_foreground:
+23
View File
@@ -60,6 +60,8 @@ class ClusterCollector:
http_timeout: float = 30.0,
auth_token: str = "",
token_manager: ServiceTokenManager | None = None,
tls_verify: Any = True,
tls_cert: tuple[str, str] | None = None,
):
self._broker = broker
self._prefix = prefix
@@ -86,12 +88,33 @@ class ClusterCollector:
max_connections=max_poll_workers + 10,
max_keepalive_connections=min(max_poll_workers, 200),
),
verify=tls_verify,
cert=tls_cert,
)
# SSE fan-out to browser clients
self._listeners: list[queue.Queue[dict[str, Any]]] = []
self._listeners_lock = threading.Lock()
def upgrade_tls(self, tls_verify: Any = True, tls_cert: tuple[str, str] | None = None) -> None:
"""Replace the httpx client with one using mTLS context."""
old = self._http_client
self._http_client = httpx.Client(
timeout=httpx.Timeout(
connect=10, read=self._http_timeout, write=5, pool=self._http_timeout
),
limits=httpx.Limits(
max_connections=self._max_poll_workers + 10,
max_keepalive_connections=min(self._max_poll_workers, 200),
),
verify=tls_verify,
cert=tls_cert,
)
# Don't close old client — concurrent _fetch_node() threads may still
# be using it. It will be GC'd once all references are released, and
# the current client is closed in stop().
del old
# -- lifecycle -----------------------------------------------------------
def start(self) -> None:
File diff suppressed because it is too large Load Diff
+742
View File
@@ -64,7 +64,9 @@ function showAdmin() {
audit: "admin.audit",
memories: "admin.memories",
settings: "admin.settings",
tls: "admin.settings",
mcp: "admin.mcp",
models: "admin.models",
};
if (perms) {
var permSet = perms.split(",");
@@ -192,7 +194,9 @@ function switchAdminTab(tab) {
"usage",
"audit",
"memories",
"models",
"settings",
"tls",
"mcp",
];
for (var p = 0; p < panels.length; p++) {
@@ -214,7 +218,9 @@ function switchAdminTab(tab) {
loadGovAudit();
}
if (tab === "memories") loadAdminMemories();
if (tab === "models") loadAdminModels();
if (tab === "settings") loadSettings();
if (tab === "tls") loadTlsCerts();
if (tab === "mcp") loadAdminMcp();
// Update breadcrumb with active tab label
@@ -1841,6 +1847,7 @@ function _installTrap(overlayId, boxId, trapRef) {
else if (overlayId === "mcp-detail-overlay") hideMcpDetailModal();
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
else if (overlayId === "model-create-overlay") hideCreateModelModal();
}
};
}
@@ -1929,6 +1936,7 @@ document.addEventListener("keydown", function (e) {
["mcp-import-overlay", hideImportMcpModal],
["mcp-create-overlay", hideCreateMcpModal],
["github-import-overlay", hideGitHubImportModal],
["model-create-overlay", hideCreateModelModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
@@ -2083,6 +2091,180 @@ function _settingsSectionLabel(section) {
return labels[section] || section;
}
// ---------------------------------------------------------------------------
// TLS tab
// ---------------------------------------------------------------------------
function loadTlsCerts() {
var statusEl = document.getElementById("tls-ca-status");
var listEl = document.getElementById("tls-cert-list");
if (!statusEl || !listEl) return;
// Fetch CA status and cert list in parallel
Promise.all([
authFetch("/v1/api/admin/tls/ca").then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
}),
authFetch("/v1/api/admin/tls/certs").then(function (r) {
if (!r.ok) return { certs: [] };
return r.json();
}),
])
.then(function (results) {
var data = results[0];
var certData = results[1];
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
if (!data.enabled) {
var msg = document.createElement("div");
msg.className = "dashboard-empty";
msg.textContent =
"TLS is not enabled. Set tls.enabled = true in Settings.";
statusEl.appendChild(msg);
return;
}
// CA status bar
var bar = document.createElement("div");
bar.className = "tls-ca-bar";
var caLabel = document.createElement("span");
caLabel.textContent = "CA: " + data.ca_cn;
var countLabel = document.createElement("span");
countLabel.textContent = "Certificates: " + data.cert_count;
bar.appendChild(caLabel);
bar.appendChild(countLabel);
statusEl.appendChild(bar);
var certs = certData.certs || [];
if (certs.length === 0) {
var empty = document.createElement("div");
empty.className = "dashboard-empty";
empty.textContent = "No certificates issued yet.";
listEl.appendChild(empty);
return;
}
// Cert rows
certs.forEach(function (c) {
var row = document.createElement("div");
row.className = "admin-row";
row.setAttribute("role", "listitem");
var colDomain = document.createElement("span");
colDomain.className = "admin-col";
colDomain.textContent = c.domain;
var colSans = document.createElement("span");
colSans.className = "admin-col";
colSans.textContent = (c.domains || [c.domain]).join(", ");
var colIssued = document.createElement("span");
colIssued.className = "admin-col";
colIssued.textContent = (c.issued_at || "")
.slice(0, 16)
.replace("T", " ");
var colExpires = document.createElement("span");
colExpires.className = "admin-col";
var expires = new Date(c.expires_at);
var isExpired = expires < new Date();
colExpires.textContent =
(isExpired ? "EXPIRED " : "") +
(c.expires_at || "").slice(0, 16).replace("T", " ");
if (isExpired) colExpires.style.color = "var(--red)";
var colActions = document.createElement("span");
colActions.className = "admin-col admin-col-actions";
var renewBtn = document.createElement("button");
renewBtn.className = "admin-btn-action";
renewBtn.textContent = "Renew";
renewBtn.setAttribute(
"aria-label",
"Renew certificate for " + c.domain,
);
renewBtn.onclick = function () {
tlsRenewCert(c.domain);
};
var deleteBtn = document.createElement("button");
deleteBtn.className = "admin-btn-danger";
deleteBtn.textContent = "Delete";
deleteBtn.setAttribute(
"aria-label",
"Delete certificate for " + c.domain,
);
deleteBtn.onclick = function () {
tlsDeleteCert(c.domain);
};
colActions.appendChild(renewBtn);
colActions.appendChild(deleteBtn);
row.appendChild(colDomain);
row.appendChild(colSans);
row.appendChild(colIssued);
row.appendChild(colExpires);
row.appendChild(colActions);
listEl.appendChild(row);
});
})
.catch(function () {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
var errMsg = document.createElement("div");
errMsg.className = "dashboard-empty";
errMsg.textContent = "Failed to load TLS status";
statusEl.appendChild(errMsg);
});
}
function tlsRenewCert(domain) {
showConfirmModal(
"Renew Certificate",
"Force renew certificate for \u2018" + domain + "\u2019?",
"Renew",
function () {
authFetch(
"/v1/api/admin/tls/certs/" + encodeURIComponent(domain) + "/renew",
{ method: "POST" },
)
.then(function (r) {
if (!r.ok) throw new Error("Renew failed");
showToast("Certificate renewed for " + domain);
loadTlsCerts();
})
.catch(function () {
showToast("Failed to renew certificate", "error");
});
},
);
}
function tlsDeleteCert(domain) {
showConfirmModal(
"Delete Certificate",
"Delete certificate for \u2018" + domain + "\u2019? This cannot be undone.",
"Delete",
function () {
authFetch("/v1/api/admin/tls/certs/" + encodeURIComponent(domain), {
method: "DELETE",
})
.then(function (r) {
if (!r.ok) throw new Error("Delete failed");
showToast("Certificate deleted for " + domain);
loadTlsCerts();
})
.catch(function () {
showToast("Failed to delete certificate", "error");
});
},
);
}
// ---------------------------------------------------------------------------
// Settings tab
// ---------------------------------------------------------------------------
function loadSettings() {
var el = document.getElementById("admin-settings-content");
if (!el) return;
@@ -3848,3 +4030,563 @@ function _pollInstallStatus(serverId, serverName, attempt) {
.catch(function () {});
}, 3000);
}
// ---------------------------------------------------------------------------
// Models tab
// ---------------------------------------------------------------------------
var _modelDefs = [];
var _modelCreateTrap = null;
var _modelCreateTrigger = null;
function loadAdminModels() {
authFetch("/v1/api/admin/model-definitions")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
_modelDefs = data.models || [];
_renderModels(_modelDefs);
})
.catch(function () {
var el = document.getElementById("admin-models-table");
el.textContent = "";
var d = document.createElement("div");
d.className = "dashboard-empty";
d.textContent = "Failed to load models";
el.appendChild(d);
});
}
function _renderModels(items) {
var el = document.getElementById("admin-models-table");
// Clear previous content
el.textContent = "";
if (!items.length) {
var empty = document.createElement("div");
empty.className = "dashboard-empty";
empty.textContent = "No model definitions configured";
el.appendChild(empty);
return;
}
for (var i = 0; i < items.length; i++) {
var m = items[i];
var isConfig = m.source === "config";
// Status
var dotClass = m.enabled
? "model-status-dot enabled"
: "model-status-dot disabled";
var rowClass = m.enabled ? "model-row-enabled" : "model-row-disabled";
var statusText = m.enabled ? "enabled" : "disabled";
// Context window formatting (0 = auto-detect)
var ctxText = m.context_window
? m.context_window >= 1000
? Math.round(m.context_window / 1000) + "k"
: String(m.context_window)
: "auto";
// Provider badge class
var providerCls =
m.provider === "anthropic"
? "model-provider-anthropic"
: "model-provider-openai";
// Build row via DOM
var row = document.createElement("div");
row.className = "admin-row models-grid " + rowClass;
row.setAttribute("role", "listitem");
// Alias + source badge
var colAlias = document.createElement("span");
colAlias.className = "admin-col";
colAlias.textContent = m.alias;
var badge = document.createElement("span");
badge.className = isConfig
? "scope-badge scope-config"
: "scope-badge scope-db";
badge.textContent = isConfig ? "config" : "db";
colAlias.appendChild(document.createTextNode(" "));
colAlias.appendChild(badge);
row.appendChild(colAlias);
// Model ID
var colModel = document.createElement("span");
colModel.className = "admin-col";
var code = document.createElement("code");
code.textContent = m.model;
colModel.appendChild(code);
row.appendChild(colModel);
// Provider
var colProvider = document.createElement("span");
colProvider.className = "admin-col";
var provBadge = document.createElement("span");
provBadge.className = "model-provider-badge " + providerCls;
provBadge.textContent = m.provider;
colProvider.appendChild(provBadge);
row.appendChild(colProvider);
// Context window
var colCtx = document.createElement("span");
colCtx.className = "admin-col";
colCtx.textContent = ctxText;
row.appendChild(colCtx);
// Status
var colStatus = document.createElement("span");
colStatus.className = "admin-col";
var dot = document.createElement("span");
dot.className = dotClass;
dot.setAttribute("aria-hidden", "true");
colStatus.appendChild(dot);
colStatus.appendChild(document.createTextNode(statusText));
row.appendChild(colStatus);
// Actions
var colActions = document.createElement("span");
colActions.className = "admin-col";
if (!isConfig) {
var editBtn = document.createElement("button");
editBtn.className = "admin-btn-action";
editBtn.textContent = "edit";
editBtn.setAttribute("data-model-edit", m.definition_id);
colActions.appendChild(editBtn);
var delBtn = document.createElement("button");
delBtn.className = "admin-btn-danger";
delBtn.textContent = "del";
delBtn.setAttribute("data-model-delete", m.definition_id);
delBtn.setAttribute("data-model-alias", m.alias);
colActions.appendChild(delBtn);
}
row.appendChild(colActions);
el.appendChild(row);
}
// Bind event handlers
el.querySelectorAll("[data-model-edit]").forEach(function (btn) {
btn.addEventListener("click", function () {
showEditModelModal(this.getAttribute("data-model-edit"));
});
});
el.querySelectorAll("[data-model-delete]").forEach(function (btn) {
btn.addEventListener("click", function () {
var did = this.getAttribute("data-model-delete");
var dalias = this.getAttribute("data-model-alias");
showConfirmModal(
"Delete Model",
'Delete model "' + dalias + '"?',
"Delete",
function () {
authFetch(
"/v1/api/admin/model-definitions/" + encodeURIComponent(did),
{
method: "DELETE",
},
)
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Model deleted");
_flagModelSyncPending();
loadAdminModels();
})
.catch(function () {
showToast("Failed to delete model");
});
},
);
});
});
}
function showCreateModelModal() {
_modelCreateTrigger = document.activeElement;
var ov = document.getElementById("model-create-overlay");
ov.style.display = "flex";
document.getElementById("model-edit-id").value = "";
document.getElementById("model-create-title").textContent = "Add Model";
document.getElementById("model-create-submit").textContent = "Create";
document.getElementById("model-create-error").classList.remove("is-visible");
document.getElementById("model-alias").value = "";
document.getElementById("model-name").value = "";
document.getElementById("model-provider").value = "openai";
document.getElementById("model-base-url").value = "";
document.getElementById("model-api-key").value = "";
document.getElementById("model-api-key").placeholder = "sk-...";
document.getElementById("model-ctx-window").value = "0";
document.getElementById("model-capabilities").value = "";
document.getElementById("model-enabled").checked = true;
document.getElementById("model-detect-result").style.display = "none";
document.getElementById("model-detect-btn").disabled = false;
document.getElementById("model-detect-btn").textContent = "Detect";
_refreshModelSuggestions();
document.getElementById("model-alias").focus();
_modelCreateTrap = _installTrap("model-create-overlay", "model-create-box");
}
function showEditModelModal(definitionId) {
authFetch(
"/v1/api/admin/model-definitions/" + encodeURIComponent(definitionId),
)
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (m) {
showCreateModelModal();
document.getElementById("model-edit-id").value = definitionId;
document.getElementById("model-create-title").textContent = "Edit Model";
document.getElementById("model-create-submit").textContent = "Save";
document.getElementById("model-alias").value = m.alias || "";
document.getElementById("model-name").value = m.model || "";
document.getElementById("model-provider").value = m.provider || "openai";
document.getElementById("model-base-url").value = m.base_url || "";
document.getElementById("model-api-key").value = "";
document.getElementById("model-api-key").placeholder =
"\u2022\u2022\u2022 (leave blank to keep existing)";
document.getElementById("model-ctx-window").value =
m.context_window != null ? m.context_window : 0;
// Parse capabilities JSON for display
var caps = m.capabilities || "{}";
try {
caps = JSON.stringify(JSON.parse(caps), null, 2);
} catch (e) {
/* keep raw */
}
if (caps === "{}") caps = "";
document.getElementById("model-capabilities").value = caps;
document.getElementById("model-enabled").checked = m.enabled !== false;
})
.catch(function () {
showToast("Failed to load model details");
});
}
function hideCreateModelModal() {
document.getElementById("model-create-overlay").style.display = "none";
_modelCreateTrap = _removeTrap(_modelCreateTrap);
if (_modelCreateTrigger && _modelCreateTrigger.focus)
_modelCreateTrigger.focus();
_modelCreateTrigger = null;
}
function submitCreateModel() {
var alias = document.getElementById("model-alias").value.trim();
var modelName = document.getElementById("model-name").value.trim();
if (!alias) {
_showModelError("Alias is required");
return;
}
if (!modelName) {
_showModelError("Model ID is required");
return;
}
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
_showModelError("Alias must be alphanumeric (with . _ -)");
return;
}
var capsText = document.getElementById("model-capabilities").value.trim();
var caps = {};
if (capsText) {
try {
caps = JSON.parse(capsText);
} catch (e) {
_showModelError("Invalid JSON in capabilities");
return;
}
}
var form = {
alias: alias,
model: modelName,
provider: document.getElementById("model-provider").value,
base_url: document.getElementById("model-base-url").value.trim(),
context_window:
parseInt(document.getElementById("model-ctx-window").value, 10) || 0,
capabilities: caps,
enabled: document.getElementById("model-enabled").checked,
};
var apiKey = document.getElementById("model-api-key").value;
if (apiKey) form.api_key = apiKey;
var editId = document.getElementById("model-edit-id").value;
var method = editId ? "PUT" : "POST";
var url = editId
? "/v1/api/admin/model-definitions/" + encodeURIComponent(editId)
: "/v1/api/admin/model-definitions";
document.getElementById("model-create-submit").disabled = true;
authFetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
hideCreateModelModal();
showToast(editId ? "Model updated" : "Model created");
_flagModelSyncPending();
loadAdminModels();
})
.catch(function (e) {
_showModelError(e.message);
})
.finally(function () {
document.getElementById("model-create-submit").disabled = false;
});
}
function _showModelError(msg) {
var e = document.getElementById("model-create-error");
e.textContent = msg;
e.classList.add("is-visible");
}
function _detectResultLine(text, color) {
var div = document.createElement("div");
div.style.marginTop = "3px";
if (color) div.style.color = "var(--" + color + ")";
div.textContent = text;
return div;
}
function _clearDetectResult() {
var rd = document.getElementById("model-detect-result");
if (rd) {
rd.style.display = "none";
rd.textContent = "";
rd.style.borderColor = "";
}
}
function detectModel() {
var btn = document.getElementById("model-detect-btn");
var resultDiv = document.getElementById("model-detect-result");
btn.disabled = true;
btn.setAttribute("aria-busy", "true");
btn.textContent = "Detecting\u2026";
resultDiv.style.display = "none";
resultDiv.textContent = "";
var form = {
provider: document.getElementById("model-provider").value,
base_url: document.getElementById("model-base-url").value.trim(),
model: document.getElementById("model-name").value.trim(),
};
var apiKey = document.getElementById("model-api-key").value;
if (apiKey) form.api_key = apiKey;
var editId = document.getElementById("model-edit-id").value;
if (editId) form.definition_id = editId;
authFetch("/v1/api/admin/model-definitions/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Detect failed");
});
return r.json();
})
.then(function (d) {
resultDiv.style.display = "block";
resultDiv.textContent = "";
if (d.error && !d.reachable) {
resultDiv.appendChild(
_detectResultLine("\u2717 Failed: " + d.error, "red"),
);
resultDiv.style.borderColor = "var(--red)";
return;
}
var line1 = "\u2713 Connected";
if (d.available_models && d.available_models.length) {
line1 += " \u2014 " + d.available_models.length + " model(s) available";
}
resultDiv.appendChild(_detectResultLine(line1, "green"));
if (d.model_found === false) {
var models = d.available_models || [];
var msg =
'\u26A0 Model "' +
form.model +
'" not found in ' +
models.length +
" available model(s)";
if (models.length > 0) {
var shown = models.slice(0, 8);
msg += ": " + shown.join(", ");
if (models.length > 8)
msg += ", \u2026 +" + (models.length - 8) + " more";
}
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
}
if (d.context_window) {
resultDiv.appendChild(
_detectResultLine(
"Context window: " + d.context_window.toLocaleString() + " tokens",
),
);
var ctxInput = document.getElementById("model-ctx-window");
if (parseInt(ctxInput.value, 10) === 0) {
ctxInput.value = d.context_window;
}
}
if (d.server_type) {
resultDiv.appendChild(
_detectResultLine("Server type: " + d.server_type),
);
}
resultDiv.style.borderColor = "var(--green)";
})
.catch(function (e) {
if (e.message === "auth") return;
resultDiv.style.display = "block";
resultDiv.textContent = "";
resultDiv.appendChild(_detectResultLine("\u2717 " + e.message, "red"));
resultDiv.style.borderColor = "var(--red)";
})
.finally(function () {
btn.disabled = false;
btn.removeAttribute("aria-busy");
btn.textContent = "Detect";
});
}
/* Capability auto-fill: when the user types a known model name or
changes the provider, look up static capabilities and pre-fill
context_window and the capabilities textarea. */
var _capsTimer = null;
function _onModelFieldChange() {
clearTimeout(_capsTimer);
_capsTimer = setTimeout(function () {
var overlay = document.getElementById("model-create-overlay");
if (!overlay || overlay.style.display === "none") return;
var provider = document.getElementById("model-provider").value;
var modelName = document.getElementById("model-name").value.trim();
if (!modelName) return;
authFetch(
"/v1/api/admin/model-capabilities?provider=" +
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
)
.then(function (r) {
return r.json();
})
.then(function (d) {
if (!d.known || !d.capabilities) return;
var ctxInput = document.getElementById("model-ctx-window");
if (
parseInt(ctxInput.value, 10) === 0 &&
d.capabilities.context_window
) {
ctxInput.value = d.capabilities.context_window;
}
var capsInput = document.getElementById("model-capabilities");
if (!capsInput.value.trim()) {
var caps = Object.assign({}, d.capabilities);
delete caps.context_window;
delete caps.max_output_tokens;
delete caps.token_param;
delete caps.supports_streaming;
delete caps.supports_tools;
var text = JSON.stringify(caps, null, 2);
if (text !== "{}") capsInput.value = text;
}
})
.catch(function () {
/* silent */
});
}, 500);
}
/* Populate the model name datalist with known model prefixes for the
selected provider. Called on page load and provider change. */
function _refreshModelSuggestions() {
var dl = document.getElementById("model-name-suggestions");
if (!dl) return;
var provider = document.getElementById("model-provider").value;
authFetch(
"/v1/api/admin/model-capabilities/known?provider=" +
encodeURIComponent(provider),
)
.then(function (r) {
return r.json();
})
.then(function (d) {
dl.textContent = "";
(d.models || []).forEach(function (m) {
var opt = document.createElement("option");
opt.value = m;
dl.appendChild(opt);
});
})
.catch(function () {
dl.textContent = "";
});
}
/* Register listeners once at page load */
(function () {
var nameEl = document.getElementById("model-name");
var provEl = document.getElementById("model-provider");
if (nameEl) nameEl.addEventListener("input", _onModelFieldChange);
if (provEl) {
provEl.addEventListener("change", _onModelFieldChange);
provEl.addEventListener("change", _refreshModelSuggestions);
provEl.addEventListener("change", _clearDetectResult);
}
/* Clear stale detect results when probe-relevant inputs change */
["model-base-url", "model-api-key"].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener("input", _clearDetectResult);
});
})();
function _flagModelSyncPending() {
var btn = document.getElementById("model-sync-btn");
if (btn) btn.classList.add("model-sync-pending");
}
function _clearModelSyncPending() {
var btn = document.getElementById("model-sync-btn");
if (btn) btn.classList.remove("model-sync-pending");
}
function reloadModelNodes() {
var btn = document.getElementById("model-sync-btn");
btn.disabled = true;
btn.textContent = "Syncing...";
authFetch("/v1/api/admin/model-definitions/reload", { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Model reload dispatched");
_clearModelSyncPending();
loadAdminModels();
})
.catch(function () {
showToast("Failed to sync models");
})
.finally(function () {
btn.disabled = false;
btn.textContent = "Sync to Nodes";
});
}
+24 -1
View File
@@ -1281,8 +1281,31 @@ function showNewWsModal() {
.catch(function () {
/* ignore — defaults still work */
});
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
modelSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default model";
modelSelect.appendChild(defaultOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
})
.then(function (data) {
(data.models || []).forEach(function (m) {
var opt = document.createElement("option");
opt.value = m.alias;
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
});
})
.catch(function () {
/* ignore — default model still works */
});
document.getElementById("new-ws-name").value = "";
document.getElementById("new-ws-model").value = "";
modelSelect.value = "";
var taskEl = document.getElementById("new-ws-task");
taskEl.value = "";
var mod =
+3 -1
View File
@@ -1838,7 +1838,9 @@ function _renderGovAudit(events, total) {
_relativeTime(ev.timestamp) +
"</span>" +
'<span class="admin-col admin-col-auser">' +
escapeHtml(ev.user_id ? ev.user_id.slice(0, 8) : "\u2014") +
escapeHtml(
ev.username || (ev.user_id ? ev.user_id.slice(0, 8) : "\u2014"),
) +
"</span>" +
'<span class="admin-col admin-col-aaction"><span class="' +
actionCls +
+82 -1
View File
@@ -109,7 +109,9 @@
</div>
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
<button id="tab-models" class="admin-nav" data-tab="models" role="tab" aria-selected="false" aria-controls="admin-models" tabindex="-1" onclick="switchAdminTab('models')">Models</button>
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
</div>
</nav>
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
@@ -393,6 +395,26 @@
</div>
</div>
<!-- Models Tab -->
<div id="admin-models" class="admin-panel" role="tabpanel" aria-labelledby="tab-models" style="display:none">
<div class="admin-toolbar">
<span class="section-header">Models</span>
<button id="model-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadModelNodes()" title="Push model config to all cluster nodes">Sync to Nodes</button>
<button class="admin-action-btn" onclick="showCreateModelModal()">+ Add Model</button>
</div>
<div class="admin-colheaders models-grid" aria-hidden="true">
<span class="admin-col">ALIAS</span>
<span class="admin-col">MODEL</span>
<span class="admin-col">PROVIDER</span>
<span class="admin-col">CTX WINDOW</span>
<span class="admin-col">STATUS</span>
<span class="admin-col">ACTIONS</span>
</div>
<div id="admin-models-table" role="list" aria-label="Model definitions" aria-live="polite">
<div class="dashboard-empty">Loading...</div>
</div>
</div>
<!-- Settings Tab -->
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
<div class="admin-toolbar">
@@ -404,6 +426,24 @@
</div>
</div>
<!-- TLS Tab -->
<div id="admin-tls" class="admin-panel" role="tabpanel" aria-labelledby="tab-tls" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">TLS / CERTIFICATES</span>
</div>
<div id="tls-ca-status"></div>
<div class="admin-colheaders admin-colheaders-tls" aria-hidden="true">
<span class="admin-col">DOMAIN</span>
<span class="admin-col">SANS</span>
<span class="admin-col">ISSUED</span>
<span class="admin-col">EXPIRES</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="tls-cert-list" role="list" aria-label="TLS certificates" aria-live="polite">
<div class="dashboard-empty">Loading&hellip;</div>
</div>
</div>
<div id="admin-mcp" class="admin-panel" role="tabpanel" aria-labelledby="tab-mcp" style="display:none">
<div class="admin-toolbar">
<span class="section-header">MCP</span>
@@ -504,7 +544,9 @@ window.TURNSTONE_KB_SHORTCUTS = [
<label for="new-ws-name">Name <span class="label-hint">optional</span></label>
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
<label for="new-ws-model">Model <span class="label-hint">optional</span></label>
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
<select id="new-ws-model">
<option value="">Default model</option>
</select>
<label for="new-ws-skill">Skill <span class="label-hint">optional</span></label>
<select id="new-ws-skill">
<option value="">Use defaults</option>
@@ -1170,6 +1212,45 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Model create/edit modal -->
<div id="model-create-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="model-create-title">
<div id="model-create-box" class="admin-modal">
<h2 id="model-create-title">Add Model</h2>
<div id="model-create-error" role="alert" aria-live="assertive"></div>
<input type="hidden" id="model-edit-id" value="">
<label for="model-alias">Alias</label>
<input type="text" id="model-alias" placeholder="e.g. gpt5-prod" maxlength="64" pattern="[a-zA-Z0-9._-]+">
<label for="model-name">Model ID <span style="font-weight:400;text-transform:none">(type to autocomplete)</span></label>
<input type="text" id="model-name" placeholder="e.g. gpt-5" list="model-name-suggestions">
<datalist id="model-name-suggestions"></datalist>
<label for="model-provider">Provider</label>
<select id="model-provider">
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
<option value="openai-compatible">openai-compatible</option>
</select>
<label for="model-base-url">Base URL <span style="font-weight:400;text-transform:none">(empty = provider default)</span></label>
<input type="text" id="model-base-url" placeholder="https://api.openai.com/v1">
<label for="model-api-key">API Key <span style="font-weight:400;text-transform:none">(write-only, never displayed)</span></label>
<input type="password" id="model-api-key" placeholder="sk-..." autocomplete="off">
<label for="model-ctx-window">Context Window <span style="font-weight:400;text-transform:none">(0 = auto-detect from model)</span></label>
<input type="number" id="model-ctx-window" value="0" min="0">
<label for="model-capabilities">Capabilities <span style="font-weight:400;text-transform:none">(JSON)</span></label>
<textarea id="model-capabilities" rows="3" placeholder='{"supports_vision": true}' style="font-family:var(--font-mono);font-size:11px"></textarea>
<div style="display:flex;gap:20px;margin-top:14px">
<label style="margin:0;font-size:12px;color:var(--fg-dim)"><input type="checkbox" id="model-enabled" checked style="margin-right:5px">Enabled</label>
</div>
<div id="model-detect-area" style="margin-top:14px">
<button type="button" id="model-detect-btn" class="modal-cancel" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<div id="model-detect-result" role="status" aria-live="polite" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);word-break:break-word"></div>
</div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateModelModal()">Cancel</button>
<button id="model-create-submit" class="modal-submit" onclick="submitCreateModel()">Create</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
+47 -5
View File
@@ -948,6 +948,19 @@
grid-template-columns: 100px 1fr 100px 80px;
}
/* TLS grid: DOMAIN | SANS | ISSUED | EXPIRES | ACTIONS */
#admin-tls .admin-colheaders,
#admin-tls .admin-row {
grid-template-columns: 160px 1fr 130px 150px 120px;
}
.tls-ca-bar {
display: flex;
gap: 2rem;
margin-bottom: 0.75rem;
font-size: 12px;
color: var(--fg-dim);
}
/* Scope badges */
.scope-badge {
display: inline-block;
@@ -1176,7 +1189,8 @@
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
.admin-modal textarea { resize: vertical; min-height: 40px; }
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
.admin-modal [role="alert"] { display: none; color: var(--red); font-size: 12px; margin-bottom: 8px; }
.admin-modal [role="alert"].is-visible { display: block; }
.admin-details { margin-top: 12px; border: 1px solid var(--border); border-radius: 6px; padding: 0 12px; }
.admin-details[open] { padding-bottom: 12px; }
@@ -1356,6 +1370,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
}
.modal-cancel:hover { background: var(--bg-elevated); }
.modal-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.modal-submit {
flex: 1;
padding: 9px;
@@ -1381,7 +1396,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
#create-template-overlay, #edit-template-overlay,
#memory-detail-overlay,
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
#github-import-overlay {
#github-import-overlay,
#model-create-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -2103,7 +2119,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
.mcp-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
.mcp-sync-pending,.model-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
@keyframes mcp-sync-pulse{0%,100%{border-color:var(--yellow)}50%{border-color:rgba(251,191,36,.3)}}
/* -- MCP sub-view toggle -------------------------------------------------- */
@@ -2170,7 +2186,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.mcp-reg-card{grid-template-columns:1fr;gap:8px}
.mcp-reg-card-actions{flex-direction:row;align-items:center}
.mcp-registry-search{flex-direction:column}
#admin-mcp .admin-toolbar{flex-wrap:wrap;gap:8px}
#admin-mcp .admin-toolbar,
#admin-models .admin-toolbar{flex-wrap:wrap;gap:8px}
#mcp-servers-toolbar{display:flex;gap:6px;width:100%}
#admin-skills .admin-toolbar{flex-wrap:wrap;gap:8px}
#skill-installed-toolbar{display:flex;gap:6px;width:100%}
@@ -2274,6 +2291,31 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.oidc-detail-panel { margin-left: 8px; }
}
/* -- Models grid --------------------------------------------------------- */
.models-grid{grid-template-columns:1.2fr 1.2fr 80px 90px 80px 120px;gap:0 6px}
@media(max-width:700px){
.models-grid{grid-template-columns:1fr 80px 120px}
.models-grid .admin-col:nth-child(2),
.models-grid .admin-col:nth-child(3),
.models-grid .admin-col:nth-child(4){display:none}
}
/* Model status indicators */
.model-status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin-right:6px}
.model-status-dot.enabled{background:var(--blue);box-shadow:0 0 6px var(--blue-glow)}
.model-status-dot.disabled{background:var(--fg-dim);opacity:.35}
.model-row-enabled{border-left:3px solid var(--blue)}
.model-row-disabled{border-left:3px solid transparent}
/* Provider badges */
.model-provider-badge{display:inline-block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:2px;background:var(--bg-highlight);border:1px solid var(--border)}
.model-provider-openai{color:var(--blue);border-color:rgba(56,189,248,.2)}
.model-provider-anthropic{color:var(--magenta);border-color:rgba(192,132,252,.25)}
/* Model source badge */
.scope-db{color:var(--blue);border-color:rgba(56,189,248,.2)}
/* ==========================================================================
Reduced motion console-specific
========================================================================== */
@@ -2297,5 +2339,5 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.mcp-view-btn, .mcp-reg-card, .mcp-install-btn, .mcp-install-source-label { transition: none; }
.mcp-registry-search input[type="search"] { transition: none; }
.mcp-reg-card-repo { transition: none; }
.mcp-sync-pending { animation: none; }
.mcp-sync-pending, .model-sync-pending { animation: none; }
}
+391
View File
@@ -0,0 +1,391 @@
"""TLS Manager — Certificate Authority and ACME server for the console.
Owns the lacme CertificateAuthority, ACMEResponder, and RenewalManager
lifecycle. When TLS is enabled, the console acts as the cluster's internal
CA and ACME server, issuing short-lived mTLS certificates to all services.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import structlog
if TYPE_CHECKING:
import ssl
from starlette.types import ASGIApp
from turnstone.core.config_store import ConfigStore
from turnstone.core.storage._protocol import StorageBackend
log = structlog.get_logger(__name__)
# Hardcoded defaults — no operator config needed
_CA_CN = "Turnstone CA"
_CA_NAME = "turnstone" # Store key for save_ca/load_ca
_CA_VALIDITY_DAYS = 3650 # 10 years
_CERT_VALIDITY_HOURS = 48
_RENEW_INTERVAL_HOURS = 24
_RENEW_BEFORE_EXPIRY_DAYS = 1
def _require_lacme() -> Any:
try:
import lacme
except ImportError:
raise ImportError(
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
) from None
return lacme
class TLSManager:
"""Manages the internal CA, ACME responder, and certificate lifecycle.
Typical usage::
mgr = TLSManager(storage, config_store)
await mgr.init_ca()
responder = mgr.get_responder() # Mount at /acme
await mgr.issue_console_certs() # Self-issue for this node
mgr.start_renewal() # Background auto-renewal
"""
def __init__(
self,
storage: StorageBackend,
config_store: ConfigStore | None = None,
) -> None:
lacme = _require_lacme()
from turnstone.core.tls_store import StorageStore
self._store = StorageStore(storage)
self._config_store = config_store
self._event_dispatcher = lacme.EventDispatcher()
self._ca: Any | None = None
self._responder: Any | None = None
self._renewal_task: Any | None = None
self._renewal_manager: Any | None = None
self._internal_bundle: Any | None = None
self._frontend_bundle: Any | None = None
# Wire structlog to lacme events
self._subscribe_events()
# Wire Prometheus metrics (if prometheus_client available)
try:
from lacme.metrics import setup_metrics
setup_metrics(self._event_dispatcher)
except ImportError:
pass # prometheus_client or lacme.metrics missing
except ValueError as exc:
if "Duplicated timeseries" in str(exc):
log.debug("tls_metrics_already_registered")
else:
raise
def _subscribe_events(self) -> None:
"""Subscribe structlog handlers to lacme lifecycle events."""
_require_lacme()
from lacme.events import (
CertificateExpiring,
CertificateIssued,
CertificateRenewed,
ChallengeFailed,
)
def _on_issued(event: Any) -> None:
if isinstance(event, CertificateIssued):
log.info("tls.cert.issued", domain=event.domain)
def _on_renewed(event: Any) -> None:
if isinstance(event, CertificateRenewed):
log.info("tls.cert.renewed", domain=event.domain)
def _on_expiring(event: Any) -> None:
if isinstance(event, CertificateExpiring):
log.warning("tls.cert.expiring", domain=event.domain)
def _on_failed(event: Any) -> None:
if isinstance(event, ChallengeFailed):
log.error("tls.challenge.failed", domain=getattr(event, "domain", "unknown"))
self._event_dispatcher.subscribe(_on_issued, event_type=CertificateIssued)
self._event_dispatcher.subscribe(_on_renewed, event_type=CertificateRenewed)
self._event_dispatcher.subscribe(_on_expiring, event_type=CertificateExpiring)
self._event_dispatcher.subscribe(_on_failed, event_type=ChallengeFailed)
# -- CA lifecycle ----------------------------------------------------------
async def init_ca(self) -> None:
"""Initialize the internal Certificate Authority.
If a bootstrap CA exists on disk (from tls-bootstrap), imports it
into the database store first so the console uses the same CA that
signed the infrastructure certs.
"""
lacme = _require_lacme()
# Import bootstrap CA from well-known volume path if not already in DB
self._import_bootstrap_ca()
self._ca = lacme.CertificateAuthority(
self._store,
name=_CA_NAME,
event_dispatcher=self._event_dispatcher,
)
self._ca.init(cn=_CA_CN, validity_days=_CA_VALIDITY_DAYS)
log.info("tls.ca.initialized", cn=_CA_CN)
def _import_bootstrap_ca(self) -> None:
"""Import a bootstrap CA from /certs into the database store.
The tls-bootstrap CLI writes the CA to a FileStore at /certs.
On first boot, the console imports it so all services share
the same trust root.
"""
import os
from pathlib import Path
# Check if bootstrap CA exists and DB CA doesn't
bootstrap_dir = Path(os.environ.get("TURNSTONE_TLS_BOOTSTRAP_DIR", "/certs"))
ca_dir = bootstrap_dir / "ca" / _CA_NAME
ca_cert_file = ca_dir / "cert.pem"
ca_key_file = ca_dir / "key.pem"
if not ca_cert_file.exists() or not ca_key_file.exists():
return # No bootstrap CA found
existing = self._store.load_ca(_CA_NAME)
if existing is not None:
return # Already imported
cert_pem = ca_cert_file.read_bytes()
key_pem = ca_key_file.read_bytes()
self._store.save_ca(_CA_NAME, cert_pem, key_pem)
log.info("tls.ca.imported_from_bootstrap", path=str(ca_dir))
def get_responder(self) -> ASGIApp:
"""Return the ACME responder ASGI app for mounting."""
if self._ca is None:
raise RuntimeError("CA not initialized — call init_ca() first")
lacme = _require_lacme()
if self._responder is None:
self._responder = lacme.ACMEResponder(
ca=self._ca,
auto_approve=True,
)
return self._responder # type: ignore[no-any-return]
def get_root_cert_pem(self) -> bytes:
"""Return the CA root certificate in PEM format."""
if self._ca is None:
raise RuntimeError("CA not initialized — call init_ca() first")
return self._ca.root_cert_pem # type: ignore[no-any-return]
# -- Cert issuance ---------------------------------------------------------
async def issue_console_certs(self, hostnames: list[str]) -> None:
"""Issue certificates for the console node.
Raises ValueError if hostnames is empty.
Issues two certificates:
- Internal cert: always from the internal CA (for mTLS with cluster)
- Frontend cert: from external ACME CA if configured, else internal CA
"""
if not hostnames:
raise ValueError("issue_console_certs requires at least one hostname")
# Internal cert — always from our own CA
await self._issue_internal_cert(hostnames)
# Frontend cert — external CA if configured
acme_directory = ""
if self._config_store:
acme_directory = self._config_store.get("tls.acme_directory") or ""
if acme_directory:
await self._issue_frontend_cert(hostnames, acme_directory)
else:
# Self-issue from internal CA (behind reverse proxy or internal only)
self._frontend_bundle = self._internal_bundle
log.info("tls.frontend.self_issued", hostnames=hostnames)
async def _issue_internal_cert(self, hostnames: list[str]) -> None:
"""Issue an internal mTLS cert from the internal CA."""
if self._ca is None:
raise RuntimeError("CA not initialized")
# Check for existing cert in store (skip if expired)
existing = self._store.load_cert(hostnames[0])
if existing is not None:
from datetime import UTC, datetime
if existing.expires_at > datetime.now(UTC):
self._internal_bundle = existing
log.info("tls.internal.loaded", domain=hostnames[0])
return
log.info("tls.internal.expired", domain=hostnames[0])
self._store.delete_cert(hostnames[0])
# Issue new cert
bundle = self._ca.issue(
hostnames,
validity_hours=_CERT_VALIDITY_HOURS,
)
self._store.save_cert(bundle)
self._internal_bundle = bundle
log.info("tls.internal.issued", domain=hostnames[0])
async def _issue_frontend_cert(
self,
hostnames: list[str],
acme_directory: str,
) -> None:
"""Issue a frontend cert from an external ACME CA."""
lacme = _require_lacme()
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
async with lacme.Client(
directory_url=acme_directory,
store=self._store,
challenge_handler=handler,
event_dispatcher=self._event_dispatcher,
) as client:
self._frontend_bundle = await client.issue(hostnames)
self._store.save_cert(self._frontend_bundle)
log.info(
"tls.frontend.issued",
domain=hostnames[0],
ca=acme_directory,
)
# -- Auto-renewal ----------------------------------------------------------
async def start_renewal(self) -> None:
"""Start background auto-renewal for all stored certificates.
Uses CA-direct mode (lacme 1.0.2+) signs directly via the CA
without going through ACME. No loopback client, no network,
no startup ordering dependency.
"""
if self._ca is None:
raise RuntimeError("CA not initialized")
lacme = _require_lacme()
def _on_renewed(bundle: Any) -> None:
# Update our cached bundles if the renewed domain matches
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
self._internal_bundle = bundle
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
self._frontend_bundle = bundle
self._renewal_manager = lacme.RenewalManager(
ca=self._ca,
store=self._store,
interval_hours=_RENEW_INTERVAL_HOURS,
days_before_expiry=_RENEW_BEFORE_EXPIRY_DAYS,
on_renewed=_on_renewed,
event_dispatcher=self._event_dispatcher,
)
self._renewal_task = self._renewal_manager.start()
log.info(
"tls.renewal.started",
interval_hours=_RENEW_INTERVAL_HOURS,
)
async def stop_renewal(self) -> None:
"""Stop the background renewal task."""
if self._renewal_task is not None:
import asyncio
import contextlib
self._renewal_task.cancel()
try:
with contextlib.suppress(asyncio.CancelledError):
await self._renewal_task
except Exception:
log.exception("tls.renewal.stop_error")
self._renewal_task = None
# -- SSL contexts ----------------------------------------------------------
def get_server_ssl_context(self) -> ssl.SSLContext | None:
"""Build an SSL context for the uvicorn HTTPS listener.
Uses the frontend cert (external CA or self-issued).
Returns None if no certs are available.
"""
if self._frontend_bundle is None:
return None
_require_lacme()
from lacme.mtls import server_ssl_context
return server_ssl_context( # type: ignore[no-any-return,unused-ignore]
cert_pem=self._frontend_bundle.fullchain_pem,
key_pem=self._frontend_bundle.key_pem,
ca_cert_pem=self.get_root_cert_pem(),
)
def get_client_ssl_context(self) -> ssl.SSLContext | None:
"""Build an mTLS client context for connecting to cluster services.
Uses the internal cert for mutual authentication.
Returns None if no certs are available.
"""
if self._internal_bundle is None:
return None
_require_lacme()
from lacme.mtls import client_ssl_context
return client_ssl_context( # type: ignore[no-any-return,unused-ignore]
cert_pem=self._internal_bundle.cert_pem,
key_pem=self._internal_bundle.key_pem,
ca_cert_pem=self.get_root_cert_pem(),
)
# -- Properties ------------------------------------------------------------
def list_certs(self) -> list[Any]:
"""List all stored certificate bundles."""
return self._store.list_certs()
def renew_cert(self, domain: str) -> Any:
"""Force-renew a certificate by domain. Returns the new bundle."""
if self._ca is None:
raise RuntimeError("CA not initialized")
existing = self._store.load_cert(domain)
if existing is None:
raise ValueError(f"No certificate for {domain}")
# Issue new cert first, then delete old (safe if issuance fails)
bundle = self._ca.issue(list(existing.domains))
self._store.delete_cert(domain)
self._store.save_cert(bundle)
# Update in-memory bundles if this is the console's own cert
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
self._internal_bundle = bundle
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
self._frontend_bundle = bundle
return bundle
def delete_cert(self, domain: str) -> bool:
"""Delete a certificate by domain."""
return self._store.delete_cert(domain)
@property
def ca_initialized(self) -> bool:
return self._ca is not None
@property
def internal_bundle(self) -> Any | None:
return self._internal_bundle
@property
def frontend_bundle(self) -> Any | None:
return self._frontend_bundle
+12 -3
View File
@@ -157,7 +157,7 @@ PUBLIC_PATHS: frozenset[str] = frozenset(
"/api/auth/oidc/callback",
}
)
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/", "/acme/")
WRITE_PATHS: frozenset[str] = frozenset(
{
@@ -173,7 +173,12 @@ WRITE_PATHS: frozenset[str] = frozenset(
)
APPROVE_PATHS: frozenset[str] = frozenset(
{"/api/approve", "/api/_internal/config-reload", "/api/_internal/mcp-reload"}
{
"/api/approve",
"/api/_internal/config-reload",
"/api/_internal/mcp-reload",
"/api/_internal/model-reload",
}
)
ADMIN_PREFIX = "/api/admin/"
@@ -329,18 +334,22 @@ def create_jwt(
expiry_hours: int = 24,
audience: str = "",
permissions: frozenset[str] = frozenset(),
expiry_seconds: int | None = None,
) -> str:
"""Create a signed JWT with user identity, scopes, and permissions."""
import jwt
if expiry_seconds is not None and expiry_seconds <= 0:
raise ValueError("expiry_seconds must be positive")
now = int(time.time())
ttl = expiry_seconds if expiry_seconds is not None else expiry_hours * 3600
payload: dict[str, Any] = {
"sub": user_id,
"scopes": ",".join(sorted(scopes)),
"src": source,
"iss": JWT_ISSUER,
"iat": now,
"exp": now + expiry_hours * 3600,
"exp": now + ttl,
}
if audience:
payload["aud"] = audience
+8
View File
@@ -125,6 +125,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
"port": "redis_port",
"password": "redis_password",
"db": "redis_db",
"tls": "redis_tls",
"tls_ca": "redis_tls_ca",
"tls_cert": "redis_tls_cert",
"tls_key": "redis_tls_key",
},
"console": {
"host": "host",
@@ -157,6 +161,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
"url": "db_url",
"path": "db_path",
"pool_size": "db_pool_size",
"sslmode": "db_sslmode",
"sslrootcert": "db_sslrootcert",
"sslcert": "db_sslcert",
"sslkey": "db_sslkey",
},
"judge": {
"enabled": "judge_enabled",
+61 -30
View File
@@ -853,6 +853,10 @@ If you used read_file to check a target, cite what you found."""
# ---------------------------------------------------------------------------
class _ExecutorPoisonedError(Exception):
"""Raised when a timeout leaves the executor's worker thread stuck."""
class IntentJudge:
"""Session-scoped LLM judge for intent validation.
@@ -912,18 +916,12 @@ class IntentJudge:
self._model = session_model
self._judge_context_window = context_window
# Executor for timeout-guarded API calls (1 thread — judge is serial)
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
def shutdown(self) -> None:
"""Release the executor thread pool."""
self._executor.shutdown(wait=False, cancel_futures=True)
def evaluate(
self,
items: list[dict[str, Any]],
messages: list[dict[str, Any]],
callback: Callable[[IntentVerdict], None],
cancel_event: threading.Event | None = None,
) -> list[IntentVerdict]:
"""Evaluate tool calls. Returns heuristic verdicts immediately.
@@ -936,6 +934,10 @@ class IntentJudge:
``func_args``, ``approval_label``, ``call_id``).
messages: Conversation history (OpenAI message format).
callback: Called with each LLM verdict (or timeout/error fallback).
cancel_event: When set, the daemon judge thread abandons
remaining work. Callers should set this after the user
has already made an approval decision so the judge does
not keep consuming inference resources.
Returns:
List of heuristic verdicts (one per item), available immediately.
@@ -958,7 +960,7 @@ class IntentJudge:
# Spawn daemon thread for LLM judge
thread = threading.Thread(
target=self._run_judge,
args=(items, messages, heuristic_verdicts, callback),
args=(items, messages, heuristic_verdicts, callback, cancel_event),
daemon=True,
name="intent-judge",
)
@@ -972,25 +974,44 @@ class IntentJudge:
messages: list[dict[str, Any]],
heuristic_verdicts: list[IntentVerdict],
callback: Callable[[IntentVerdict], None],
cancel_event: threading.Event | None = None,
) -> None:
"""Daemon thread: run LLM judge for each item and invoke callback."""
for item, h_verdict in zip(items, heuristic_verdicts, strict=True):
try:
llm_verdict = self._evaluate_single(item, messages)
# Arbitrate: only callback when LLM upgrades the heuristic
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
callback(llm_verdict)
# else: heuristic already delivered, no duplicate callback
except Exception:
log.exception(
"Judge evaluation failed for %s",
item.get("func_name", "?"),
)
# Evaluation-scoped executor — avoids sharing mutable state with
# other daemon threads from concurrent evaluate() calls.
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
try:
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
if cancel_event and cancel_event.is_set():
log.debug("judge.cancelled", remaining=len(items) - idx)
return
try:
llm_verdict = self._evaluate_single(item, messages, cancel_event, executor)
if cancel_event and cancel_event.is_set():
return
# Arbitrate: only callback when LLM upgrades the heuristic
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
callback(llm_verdict)
# else: heuristic already delivered, no duplicate callback
except _ExecutorPoisonedError:
# Timeout left the worker stuck — replace the executor
# so subsequent items don't queue behind it.
executor.shutdown(wait=False, cancel_futures=True)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
except Exception:
log.exception(
"Judge evaluation failed for %s",
item.get("func_name", "?"),
)
finally:
executor.shutdown(wait=False, cancel_futures=True)
def _evaluate_single(
self,
item: dict[str, Any],
messages: list[dict[str, Any]],
cancel_event: threading.Event | None,
executor: ThreadPoolExecutor,
) -> IntentVerdict | None:
"""Run LLM judge for a single tool call. Returns verdict or None."""
start = time.monotonic()
@@ -1020,6 +1041,9 @@ class IntentJudge:
result = None # will hold the last CompletionResult
for turn in range(_JUDGE_MAX_TURNS):
if cancel_event and cancel_event.is_set():
return None
turn_start = time.monotonic()
is_last_turn = turn == _JUDGE_MAX_TURNS - 1
@@ -1043,7 +1067,7 @@ class IntentJudge:
# 10 minutes — far too long for an advisory judge on local models.
per_call_timeout = max(timeout_budget, 5.0) # at least 5s
try:
future = self._executor.submit(
future = executor.submit(
self._provider.create_completion,
client=self._client,
model=self._model,
@@ -1053,17 +1077,24 @@ class IntentJudge:
temperature=0.0,
reasoning_effort="medium",
)
result = future.result(timeout=per_call_timeout)
# Poll in 1s increments so we notice cancellation promptly
# instead of blocking for the full per_call_timeout.
deadline = time.monotonic() + per_call_timeout
while True:
remaining = deadline - time.monotonic()
if cancel_event and cancel_event.is_set():
future.cancel()
return None
if remaining <= 0:
raise TimeoutError
try:
result = future.result(timeout=min(remaining, 1.0))
break
except TimeoutError:
pass # loop back to check remaining/cancel
except TimeoutError:
log.warning("Judge LLM call timed out on turn %d (%.0fs)", turn, per_call_timeout)
# Abandon the lingering API call and replace the executor so
# subsequent items in the batch don't queue behind it.
self._executor.shutdown(wait=False, cancel_futures=True)
self._executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="judge-api",
)
return None
raise _ExecutorPoisonedError from None
except Exception:
log.exception("Judge LLM call failed on turn %d", turn)
return None
+78 -14
View File
@@ -176,6 +176,8 @@ class MCPClientManager:
for name, cfg in self._server_configs.items():
try:
await self._connect_one(name, cfg)
except asyncio.CancelledError:
raise # propagate so the background task can be cleanly stopped
except Exception as exc:
log.warning("Failed to connect MCP server '%s'", name, exc_info=True)
self._set_error(name, f"{type(exc).__name__}: {exc}")
@@ -199,6 +201,49 @@ class MCPClientManager:
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
_CONNECT_TIMEOUT = 30 # seconds — prevents hung connections on broken remotes
_TCP_PROBE_TIMEOUT = 5 # seconds — fast TCP pre-flight for HTTP transports
async def _tcp_probe(self, name: str, url: str) -> None:
"""Fast TCP connect check before entering the MCP transport context.
Fails fast when the server is unreachable, avoiding the anyio
cancel-scope orphan bug that causes 100% CPU spin.
"""
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname
if not host:
raise ConnectionError(f"MCP server '{name}' has invalid URL (no hostname): {url}")
try:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError:
raise ConnectionError(f"MCP server '{name}' has invalid port in URL: {url}") from None
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=self._TCP_PROBE_TIMEOUT,
)
writer.close()
await writer.wait_closed()
except (TimeoutError, OSError) as exc:
raise ConnectionError(
f"MCP server '{name}' unreachable at {host}:{port}: {exc}"
) from None
@staticmethod
async def _safe_close_stack(stack: AsyncExitStack) -> None:
"""Close an AsyncExitStack, suppressing errors from broken anyio scopes.
Called from exception handlers must not raise, otherwise cleanup
errors could mask the original exception. CancelledError is caught
explicitly because it is the primary failure mode (stray cancel from
broken anyio scope) and is BaseException, not Exception.
"""
try:
await asyncio.wait_for(stack.aclose(), timeout=5)
except (Exception, asyncio.CancelledError):
log.debug("Error closing AsyncExitStack; ignoring", exc_info=True)
async def _connect_one(self, name: str, cfg: dict[str, Any]) -> None:
"""Connect to a single MCP server and discover its tools."""
@@ -213,6 +258,13 @@ class MCPClientManager:
transport = cfg.get("type", "stdio")
try:
if transport in ("http", "streamable-http") or "url" in cfg:
# Pre-flight TCP check: fail fast before entering the anyio
# task group in streamablehttp_client. An immediate connect
# failure (ECONNREFUSED) inside the anyio context causes a
# CancelledError that escapes asyncio.wait_for and leaves
# orphaned cancel-scope tasks spinning at 100% CPU.
await self._tcp_probe(name, cfg["url"])
read, write, _ = await asyncio.wait_for(
stack.enter_async_context(
streamablehttp_client(url=cfg["url"], headers=cfg.get("headers"))
@@ -235,15 +287,25 @@ class MCPClientManager:
env=env,
)
read, write = await stack.enter_async_context(stdio_client(params))
except asyncio.CancelledError:
# Stray CancelledError from broken anyio cancel scope — treat as
# connection failure. But if the task is genuinely being cancelled
# (shutdown), re-raise so we don't block teardown.
task = asyncio.current_task()
if task is not None and task.cancelling():
await self._safe_close_stack(stack)
raise
log.warning("MCP server '%s' connection failed (anyio cancel)", name)
await self._safe_close_stack(stack)
raise TimeoutError(f"Connection failed for '{name}'") from None
except TimeoutError:
log.warning(
"MCP server '%s' connection timed out after %ds", name, self._CONNECT_TIMEOUT
)
with contextlib.suppress(Exception):
await stack.aclose()
await self._safe_close_stack(stack)
raise TimeoutError(f"Connection timed out after {self._CONNECT_TIMEOUT}s") from None
except Exception:
await stack.aclose()
await self._safe_close_stack(stack)
raise
# Register notification handler — dispatches tool, resource, and
@@ -274,21 +336,27 @@ class MCPClientManager:
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
)
except Exception:
await stack.aclose()
await self._safe_close_stack(stack)
raise
self._per_server_stacks[name] = stack
try:
await asyncio.wait_for(session.initialize(), timeout=self._CONNECT_TIMEOUT)
except asyncio.CancelledError:
self._per_server_stacks.pop(name, None)
task = asyncio.current_task()
if task is not None and task.cancelling():
await self._safe_close_stack(stack)
raise
await self._safe_close_stack(stack)
raise TimeoutError(f"MCP handshake failed for '{name}'") from None
except TimeoutError:
self._per_server_stacks.pop(name, None)
with contextlib.suppress(Exception):
await stack.aclose()
await self._safe_close_stack(stack)
raise TimeoutError(f"MCP handshake timed out after {self._CONNECT_TIMEOUT}s") from None
except Exception:
self._per_server_stacks.pop(name, None)
with contextlib.suppress(Exception):
await stack.aclose()
await self._safe_close_stack(stack)
raise
self._sessions[name] = session
@@ -873,8 +941,7 @@ class MCPClientManager:
async def _close_all_stacks() -> None:
for stack in self._per_server_stacks.values():
with contextlib.suppress(Exception):
await stack.aclose()
await self._safe_close_stack(stack)
future = asyncio.run_coroutine_threadsafe(_close_all_stacks(), self._loop)
try:
@@ -984,10 +1051,7 @@ class MCPClientManager:
self._sessions.pop(name, None)
stack = self._per_server_stacks.pop(name, None)
if stack is not None:
try:
await asyncio.wait_for(stack.aclose(), timeout=10)
except (TimeoutError, Exception):
log.warning("Timed out closing MCP server '%s', forcing cleanup", name)
await self._safe_close_stack(stack)
# Clean up per-server state (on the event loop thread)
self._per_server_tools.pop(name, None)
self._per_server_resources.pop(name, None)
-2
View File
@@ -37,7 +37,6 @@ def save_message(
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -49,7 +48,6 @@ def save_message(
role,
content,
tool_name,
tool_args,
tool_call_id,
provider_data,
tool_calls=tool_calls,
+8
View File
@@ -50,6 +50,13 @@ NUDGE_TOOL_ERROR = (
"previous session. Use memory(action='search') to find relevant guidance."
)
NUDGE_REPEAT = (
"You just called the same tool with the same arguments as a previous "
"call in this conversation. Repeating the exact same action will produce "
"the same result. Stop and reconsider your approach — try a different "
"tool, different arguments, or ask the user for clarification."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
@@ -57,6 +64,7 @@ _NUDGE_MAP: dict[str, str] = {
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
"tool_error": NUDGE_TOOL_ERROR,
"repeat": NUDGE_REPEAT,
}
# ---------------------------------------------------------------------------
+225 -22
View File
@@ -34,6 +34,7 @@ class ModelConfig:
context_window: int = 32768
provider: str = "openai"
capabilities: dict[str, Any] = field(default_factory=dict)
source: str = "" # "config", "db", or "" (CLI default)
# ---------------------------------------------------------------------------
@@ -81,9 +82,9 @@ class ModelRegistry:
def get_client(self, alias: str) -> Any:
"""Get or lazily create an API client for *alias*. Thread-safe."""
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
with self._client_lock:
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
if alias not in self._clients:
cfg = self._models[alias]
self._clients[alias] = create_client(
@@ -93,9 +94,9 @@ class ModelRegistry:
def get_provider(self, alias: str) -> LLMProvider:
"""Get the ``LLMProvider`` for *alias*. Thread-safe, cached."""
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
with self._client_lock:
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
if alias not in self._providers:
cfg = self._models[alias]
self._providers[alias] = create_provider(cfg.provider)
@@ -129,8 +130,46 @@ class ModelRegistry:
"""Number of registered models."""
return len(self._models)
@property
def models(self) -> dict[str, ModelConfig]:
"""Return a copy of the models dict (public accessor for reload)."""
return dict(self._models)
# -- lifecycle -----------------------------------------------------------
def reload(
self,
models: dict[str, ModelConfig],
default: str,
fallback: list[str] | None = None,
agent_model: str | None = None,
) -> None:
"""Hot-reload all model configs. Thread-safe; clears cached clients.
Validates arguments before mutating state so a bad reload
does not leave the registry in an inconsistent state.
"""
if not models:
raise ValueError("ModelRegistry requires at least one model config")
if default not in models:
raise ValueError(f"Default model '{default}' not found in registry")
if fallback:
for alias in fallback:
if alias not in models:
raise ValueError(f"Fallback model '{alias}' not found in registry")
if agent_model and agent_model not in models:
raise ValueError(f"Agent model '{agent_model}' not found in registry")
with self._client_lock:
self._models = dict(models)
self.default = default
self.fallback = list(fallback) if fallback else []
self.agent_model = agent_model
for client in self._clients.values():
if hasattr(client, "close"):
client.close()
self._clients.clear()
self._providers.clear()
def shutdown(self) -> None:
"""Close all cached client connections."""
with self._client_lock:
@@ -146,32 +185,82 @@ class ModelRegistry:
# ---------------------------------------------------------------------------
def _resolve_env_vars(value: str) -> str:
"""Expand ``${VAR}`` patterns in *value* using environment variables.
Unresolved variables are replaced with empty strings.
"""
import os
import re
def _replace(m: re.Match[str]) -> str:
return os.environ.get(m.group(1), "")
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _replace, value)
def load_model_registry(
base_url: str,
api_key: str,
model: str,
context_window: int = 32768,
provider: str = "openai",
storage: Any | None = None,
) -> ModelRegistry:
"""Build a ModelRegistry from CLI args and ``config.toml``.
"""Build a ModelRegistry from CLI args, ``config.toml``, and database.
Precedence:
Precedence (highest to lowest):
1. ``[models.*]`` sections in config.toml define named models.
2. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
``"default"`` entry (overrides any ``[models.default]`` section).
3. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
1. ``[models.*]`` sections in config.toml define named models
(``source="config"``). These override DB entries with the same
alias in-memory only the DB rows are never modified.
2. Database model definitions (``source="db"``), loaded when
*storage* is provided.
3. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
``"default"`` entry.
4. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
control routing.
4. If no ``[models.*]`` sections exist, a single-entry registry is built
from the CLI args.
"""
import json as _json
cfg = load_config()
models_section: dict[str, Any] = cfg.get("models", {})
model_section: dict[str, Any] = cfg.get("model", {})
configs: dict[str, ModelConfig] = {}
# Build configs from [models.*] sections
# 1. Load DB model definitions (lowest priority, overridden by config.toml)
if storage is not None:
try:
for row in storage.list_model_definitions(enabled_only=True):
alias = row["alias"]
caps: dict[str, Any] = {}
if row.get("capabilities"):
try:
parsed = _json.loads(row["capabilities"])
if isinstance(parsed, dict):
caps = parsed
except (_json.JSONDecodeError, TypeError):
pass
row_provider = row.get("provider", "openai")
row_model = row["model"]
# 0 = auto-detect: inherit CLI-detected context_window,
# same fallback chain as config.toml models
row_ctx = row.get("context_window", 0) or context_window
configs[alias] = ModelConfig(
alias=alias,
base_url=_resolve_env_vars(row.get("base_url", "")),
api_key=_resolve_env_vars(row.get("api_key", "")),
model=row_model,
context_window=row_ctx,
provider=row_provider,
capabilities=caps,
source="db",
)
except Exception:
log.warning("Failed to load model definitions from storage", exc_info=True)
# 2. Build configs from [models.*] sections (overrides DB for same alias)
for alias, entry in models_section.items():
if not isinstance(entry, dict):
continue
@@ -189,17 +278,20 @@ def load_model_registry(
capabilities=entry.get("capabilities", {})
if isinstance(entry.get("capabilities"), dict)
else {},
source="config",
)
# Ensure a "default" entry from CLI args
configs["default"] = ModelConfig(
alias="default",
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider,
)
# 3. Ensure a "default" entry from CLI args (only if not already defined
# by config.toml or DB — those take precedence)
if "default" not in configs:
configs["default"] = ModelConfig(
alias="default",
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider,
)
# Determine default alias
default_alias = model_section.get("default", "default")
@@ -342,3 +434,114 @@ def detect_model(
log_fn(f"Warning: Could not connect to LLM backend: {e}")
log_fn("Starting in degraded mode — requests will fail until backend is reachable.")
return None, None
def probe_model_endpoint(
provider: str,
base_url: str,
api_key: str,
target_model: str = "",
) -> dict[str, Any]:
"""Stateless probe of a model endpoint.
Creates a temporary SDK client, calls ``/v1/models``, and returns
reachability status, available model IDs, detected context window,
and server type. Used by the admin *Detect* button never persists
state or stores the API key.
"""
from turnstone.core.providers import create_client
result: dict[str, Any] = {
"reachable": False,
"model_found": None,
"available_models": [],
"context_window": None,
"server_type": None,
"error": None,
}
client = None
try:
client = create_client(provider, base_url=base_url, api_key=api_key)
fast = client.with_options(timeout=10.0, max_retries=0)
models = fast.models.list()
if not models.data:
result["reachable"] = True
result["error"] = "No models found at endpoint"
return result
all_ids = [m.id for m in models.data]
result["reachable"] = True
result["available_models"] = all_ids
# Determine which model to inspect for context_window
if target_model:
result["model_found"] = target_model in all_ids
inspect_id = target_model if result["model_found"] else all_ids[0]
else:
inspect_id = all_ids[0]
inspect_obj = next((m for m in models.data if m.id == inspect_id), None)
# --- context window detection ---
if provider == "anthropic":
from turnstone.core.providers import lookup_model_capabilities
known = lookup_model_capabilities("anthropic", inspect_id)
if known is not None:
result["context_window"] = known["context_window"]
result["server_type"] = "anthropic"
else:
# OpenAI-compatible path
_detect_openai_compat(result, inspect_obj, inspect_id, base_url)
except Exception as exc:
err_msg = str(exc)
if len(err_msg) > 500:
err_msg = err_msg[:500] + "..."
result["error"] = err_msg
finally:
if client is not None and hasattr(client, "close"):
client.close()
return result
def _detect_openai_compat(
result: dict[str, Any],
model_obj: Any,
model_id: str,
base_url: str,
) -> None:
"""Fill context_window and server_type for an OpenAI-compatible endpoint."""
meta: dict[str, Any] | None = None
owned_by: str = ""
if model_obj is not None:
dumped = model_obj.model_dump()
raw_meta = dumped.get("meta")
if isinstance(raw_meta, dict):
meta = raw_meta
owned_by = str(dumped.get("owned_by", ""))
# Context window: prefer backend metadata, fall back to static table
# (only for known models — the default 200k would be misleading for local servers)
if meta is not None:
n_ctx = meta.get("n_ctx_train")
if isinstance(n_ctx, int) and n_ctx > 0:
result["context_window"] = n_ctx
if result["context_window"] is None:
from turnstone.core.providers import lookup_model_capabilities
known = lookup_model_capabilities("openai", model_id)
if known is not None:
result["context_window"] = known["context_window"]
# Server type heuristics
if base_url and "api.openai.com" in base_url:
result["server_type"] = "openai"
elif meta is not None and "n_ctx_train" in meta:
result["server_type"] = "llama.cpp"
elif "sglang" in owned_by.lower():
result["server_type"] = "sglang"
elif "/" in (model_id or ""):
result["server_type"] = "vllm"
else:
result["server_type"] = "openai-compatible"
+24
View File
@@ -58,6 +58,12 @@ _RE_ENV_SECRET_KEY = re.compile(
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
re.IGNORECASE,
)
_RE_JSON_SECRET = re.compile(
r'"(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|'
r"token|access_token|refresh_token|auth_token|private_key|"
r'client_secret|webhook_secret|signing_key|encryption_key)"\s*:\s*"([^"]{8,})"',
re.IGNORECASE,
)
# (pattern, redact_label) — ordered most-specific first for redaction.
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
@@ -228,6 +234,15 @@ def _check_credentials(
found = True
risk = "high"
if _RE_JSON_SECRET.search(text):
_add_flag(flags, "credential_leak")
flags.append("json_secret_leak")
ann.append(
"Output contains JSON with secret-bearing keys (api_key, password, token, etc.)."
)
found = True
risk = "high"
return risk, _redact_credentials(text) if found else None
@@ -247,6 +262,15 @@ def _redact_credentials(text: str) -> str:
return key + "=[REDACTED:secret]" if _RE_ENV_SECRET_KEY.search(key) else m.group()
result = _RE_ENV_SECRET_LINE.sub(_redact_env, result)
def _redact_json_secret(m: re.Match[str]) -> str:
# Positional replacement to avoid corrupting key when value == key name
start = m.start(1) - m.start()
end = m.end(1) - m.start()
full = m.group()
return full[:start] + "[REDACTED:secret]" + full[end:]
result = _RE_JSON_SECRET.sub(_redact_json_secret, result)
return result
+49 -5
View File
@@ -25,6 +25,8 @@ __all__ = [
"UsageInfo",
"create_client",
"create_provider",
"list_known_models",
"lookup_model_capabilities",
]
# Singleton instances (stateless, safe to share)
@@ -36,7 +38,7 @@ _anthropic_provider: LLMProvider | None = None
def create_provider(provider_name: str) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe."""
global _anthropic_provider # noqa: PLW0603
if provider_name == "openai":
if provider_name in ("openai", "openai-compatible"):
return _openai_provider
if provider_name == "anthropic":
with _provider_lock:
@@ -45,15 +47,19 @@ def create_provider(provider_name: str) -> LLMProvider:
_anthropic_provider = AnthropicProvider()
return _anthropic_provider
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
)
def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
"""Create an SDK client for the given provider."""
if provider_name == "openai":
if provider_name in ("openai", "openai-compatible"):
from openai import OpenAI
return OpenAI(base_url=base_url, api_key=api_key)
if base_url:
return OpenAI(base_url=base_url, api_key=api_key)
return OpenAI(api_key=api_key)
if provider_name == "anthropic":
from turnstone.core.providers._anthropic import _ensure_anthropic
@@ -62,4 +68,42 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
if base_url and base_url != "https://api.anthropic.com":
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
)
def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | None:
"""Return static capabilities for a known model, or ``None`` if unknown.
The returned dict has JSON-friendly values (tuples converted to lists).
Returns ``None`` for ``openai-compatible`` (no static table for local models).
"""
import dataclasses
if provider == "openai-compatible":
return None
prov = create_provider(provider)
caps = prov.get_capabilities(model)
default = prov.get_capabilities("")
if caps is default:
return None
result = dataclasses.asdict(caps)
# Convert tuples to lists for JSON serialisation
for key, val in result.items():
if isinstance(val, tuple):
result[key] = list(val)
return result
def list_known_models(provider: str) -> list[str]:
"""Return the model name prefixes in the static capability table."""
if provider == "openai":
from turnstone.core.providers._openai import _OPENAI_CAPABILITIES
return sorted(_OPENAI_CAPABILITIES.keys())
if provider == "anthropic":
from turnstone.core.providers._anthropic import _ANTHROPIC_CAPABILITIES
return sorted(_ANTHROPIC_CAPABILITIES.keys())
return []
+79 -2
View File
@@ -7,6 +7,8 @@ The ``anthropic`` SDK is imported lazily so it remains an optional dependency.
from __future__ import annotations
import json
import logging
import sys
from typing import TYPE_CHECKING, Any
from turnstone.core.providers._protocol import (
@@ -21,6 +23,8 @@ from turnstone.core.providers._protocol import (
if TYPE_CHECKING:
from collections.abc import Iterator
log = logging.getLogger(__name__)
def _ensure_anthropic() -> Any:
"""Lazy import anthropic SDK, raising helpful error if not installed."""
@@ -328,6 +332,38 @@ class AnthropicProvider:
)
if content_blocks:
converted.append({"role": "assistant", "content": content_blocks})
# Repair orphaned tool_use blocks: if this assistant message
# has tool_use blocks but the next messages don't provide
# matching tool_results, synthesize error results. This
# happens when a cancel interrupts tool execution — the
# assistant message is saved to DB before tools run, but
# GenerationCancelled prevents tool results from being created.
tool_use_ids = {b["id"] for b in content_blocks if b.get("type") == "tool_use"}
if tool_use_ids:
# Peek ahead to collect tool_result IDs
j = i + 1
result_ids: set[str] = set()
while j < len(messages) and messages[j]["role"] == "tool":
result_ids.add(messages[j].get("tool_call_id", ""))
j += 1
orphaned = tool_use_ids - result_ids
if orphaned:
log.debug(
"Synthesizing %d tool_result(s) for orphaned tool_use IDs",
len(orphaned),
)
synthetic = [
{
"type": "tool_result",
"tool_use_id": uid,
"content": "Tool execution was cancelled.",
"is_error": True,
}
for uid in orphaned
]
converted.append({"role": "user", "content": synthetic})
i += 1
continue
@@ -459,6 +495,7 @@ class AnthropicProvider:
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
_ensure_anthropic()
caps = self.get_capabilities(model)
@@ -476,8 +513,25 @@ class AnthropicProvider:
deferred_names,
)
with client.messages.stream(**kwargs) as stream:
manager = client.messages.stream(**kwargs)
try:
stream = manager.__enter__()
except BaseException:
manager.__exit__(*sys.exc_info())
raise
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_with_cleanup(stream, manager)
def _iter_with_cleanup(self, stream: Any, manager: Any) -> Iterator[StreamChunk]:
"""Iterate the Anthropic stream, ensuring the context manager exits."""
try:
yield from self._iter_anthropic_stream(stream)
except BaseException:
manager.__exit__(*sys.exc_info())
raise
else:
manager.__exit__(None, None, None)
def _iter_anthropic_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert Anthropic streaming events to normalized StreamChunks."""
@@ -541,6 +595,12 @@ class AnthropicProvider:
raw_blocks[event.index]["thinking"] = (
raw_blocks[event.index].get("thinking", "") + delta.thinking
)
elif delta.type == "signature_delta":
# Accumulate signature into raw block for round-trip
if event.index in raw_blocks:
raw_blocks[event.index]["signature"] = (
raw_blocks[event.index].get("signature", "") + delta.signature
)
elif delta.type == "input_json_delta":
if event.index in server_tool_blocks:
# Accumulate server tool input (search query)
@@ -647,7 +707,24 @@ class AnthropicProvider:
deferred_names,
)
response = client.messages.create(**kwargs)
# Use streaming internally to avoid the Anthropic SDK's 10-minute
# timeout on non-streaming requests. get_final_message() returns the
# same Message object as messages.create() would.
# Mirror create_streaming's defensive __enter__/__exit__ pattern so
# resources are cleaned up even if __enter__ fails.
manager = client.messages.stream(**kwargs)
try:
stream = manager.__enter__()
except BaseException:
manager.__exit__(*sys.exc_info())
raise
try:
response = stream.get_final_message()
except BaseException:
manager.__exit__(*sys.exc_info())
raise
else:
manager.__exit__(None, None, None)
# Extract content and tool_calls from content blocks.
# Skip server-side blocks (server_tool_use, web_search_tool_result)
+76 -2
View File
@@ -11,6 +11,8 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
import structlog
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
@@ -20,6 +22,8 @@ from turnstone.core.providers._protocol import (
_lookup_capabilities,
)
log = structlog.get_logger(__name__)
# -- model capabilities -------------------------------------------------------
_OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
@@ -273,6 +277,29 @@ class OpenAIProvider:
result.append(tool)
return result
# -- message sanitisation ------------------------------------------------
@staticmethod
def _sanitize_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Ensure assistant messages always have ``content`` or ``tool_calls``.
OpenAI-compatible APIs reject assistant messages that have neither.
This is a defensive catch-all; the upstream layers should already
guarantee well-formed messages.
"""
out: list[dict[str, Any]] = []
for msg in messages:
if (
msg.get("role") == "assistant"
and msg.get("content") is None
and not msg.get("tool_calls")
):
msg = {**msg, "content": ""}
out.append(msg)
return out
# -- streaming -----------------------------------------------------------
def create_streaming(
@@ -287,8 +314,10 @@ class OpenAIProvider:
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
caps = self.get_capabilities(model)
messages = self._sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -305,19 +334,34 @@ class OpenAIProvider:
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=True,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
stream = client.chat.completions.create(**kwargs)
yield from self._iter_stream(stream)
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_stream(stream)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert OpenAI stream chunks to normalized StreamChunks."""
first = True
annotations: list[Any] = []
content_len = 0
tool_call_count = 0
last_finish_reason: str | None = None
completion_tokens: int | None = None
for chunk in stream:
sc = StreamChunk()
# Finish reason
if chunk.choices and chunk.choices[0].finish_reason:
sc.finish_reason = chunk.choices[0].finish_reason
last_finish_reason = sc.finish_reason
# Usage from final chunk
if hasattr(chunk, "usage") and chunk.usage is not None:
@@ -325,6 +369,7 @@ class OpenAIProvider:
pt = getattr(u, "prompt_tokens", None)
ct = getattr(u, "completion_tokens", None)
tt = getattr(u, "total_tokens", None)
completion_tokens = ct
if pt is not None and ct is not None:
# Extract cached_tokens from prompt_tokens_details.
# OpenAI caching is automatic with no write premium, so
@@ -353,6 +398,7 @@ class OpenAIProvider:
# Content
if delta.content:
sc.content_delta = delta.content
content_len += len(delta.content)
# Tool calls
if delta.tool_calls:
@@ -366,6 +412,7 @@ class OpenAIProvider:
if tc_delta.function.arguments:
tcd.arguments_delta = tc_delta.function.arguments
sc.tool_call_deltas.append(tcd)
tool_call_count += 1
# Accumulate url_citation annotations from search models
delta_anns = getattr(delta, "annotations", None)
@@ -380,6 +427,15 @@ class OpenAIProvider:
if has_content or sc.finish_reason or sc.usage:
yield sc
log.debug(
"openai.response",
stream=True,
finish_reason=last_finish_reason,
content_length=content_len,
tool_call_deltas=tool_call_count,
completion_tokens=completion_tokens,
)
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = self._format_citations("", annotations).strip()
@@ -402,6 +458,7 @@ class OpenAIProvider:
deferred_names: frozenset[str] | None = None,
) -> CompletionResult:
caps = self.get_capabilities(model)
messages = self._sanitize_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -417,6 +474,14 @@ class OpenAIProvider:
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=False,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
response = client.chat.completions.create(**kwargs)
choice = response.choices[0]
msg = choice.message
@@ -454,12 +519,21 @@ class OpenAIProvider:
cache_read_tokens=cached or 0,
)
return CompletionResult(
result = CompletionResult(
content=content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
)
log.debug(
"openai.response",
stream=False,
finish_reason=result.finish_reason,
content_length=len(content),
tool_call_count=len(tool_calls) if tool_calls else 0,
completion_tokens=usage.completion_tokens if usage else None,
)
return result
@staticmethod
def _format_citations(content: str, annotations: list[Any]) -> str:
+8 -1
View File
@@ -125,8 +125,15 @@ class LLMProvider(Protocol):
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
deferred_names: frozenset[str] | None = None,
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
"""Create a streaming request, yielding normalized StreamChunks."""
"""Create a streaming request, yielding normalized StreamChunks.
If *cancel_ref* is provided the provider appends the underlying SDK
stream object (which has a ``.close()`` method) before yielding the
first chunk. The caller can then close it from another thread to
abort a blocked HTTP read immediately.
"""
...
def create_completion(
+706 -220
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -528,6 +528,29 @@ def _build_registry() -> dict[str, SettingDef]:
"information from conversations into long-term memory. This helps the AI "
"remember context across separate conversations.",
),
# -- tls ----------------------------------------------------------------
SettingDef(
"tls.enabled",
"bool",
False,
"Enable mTLS for inter-service communication",
"tls",
restart_required=True,
help="When enabled, the console runs an internal Certificate Authority and "
"ACME server. All cluster services (servers, bridge, channels) auto-provision "
"short-lived certificates for mutual TLS. Requires lacme: pip install turnstone[tls]",
),
SettingDef(
"tls.acme_directory",
"str",
"",
"External ACME CA URL for the console's frontend HTTPS cert",
"tls",
restart_required=True,
help="Set to a public ACME directory URL (e.g. https://acme-v02.api.letsencrypt.org/"
"directory) to get a publicly trusted certificate for the console's HTTPS endpoint. "
"Leave empty to self-issue from the internal CA (use when behind a reverse proxy).",
),
]
return {d.key: d for d in defs}
@@ -543,7 +566,7 @@ BOOTSTRAP_SECTIONS: frozenset[str] = frozenset(
"auth",
"bridge",
"console",
}
},
)
+205 -3
View File
@@ -17,6 +17,7 @@ from turnstone.core.storage._schema import (
intent_verdicts,
mcp_servers,
metadata,
model_definitions,
oidc_identities,
oidc_pending_states,
orgs,
@@ -30,6 +31,9 @@ from turnstone.core.storage._schema import (
skill_versions,
structured_memories,
system_settings,
tls_account_keys,
tls_ca,
tls_certificates,
tool_policies,
usage_events,
user_roles,
@@ -41,6 +45,9 @@ from turnstone.core.storage._schema import (
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
from turnstone.core.storage._utils import (
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
)
from turnstone.core.storage._utils import (
ORG_MUTABLE as _ORG_MUTABLE,
)
@@ -100,7 +107,6 @@ class PostgreSQLBackend:
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -115,7 +121,6 @@ class PostgreSQLBackend:
"role": role,
"content": content,
"tool_name": tool_name,
"tool_args": tool_args,
"tool_call_id": tool_call_id,
"provider_data": provider_data,
"tool_calls": tool_calls,
@@ -133,7 +138,6 @@ class PostgreSQLBackend:
conversations.c.role,
conversations.c.content,
conversations.c.tool_name,
conversations.c.tool_args,
conversations.c.tool_call_id,
conversations.c.provider_data,
conversations.c.tool_calls,
@@ -2641,6 +2645,102 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Model definitions -----------------------------------------------------
def create_model_definition(
self,
definition_id: str,
alias: str,
model: str,
provider: str = "openai",
base_url: str = "",
api_key: str = "",
context_window: int = 32768,
capabilities: str = "{}",
enabled: bool = True,
created_by: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
postgresql.insert(model_definitions)
.values(
definition_id=definition_id,
alias=alias,
model=model,
provider=provider,
base_url=base_url,
api_key=api_key,
context_window=context_window,
capabilities=capabilities,
enabled=1 if enabled else 0,
created_by=created_by,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(model_definitions.c.alias == alias)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
if enabled_only:
q = q.where(model_definitions.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
with self._engine.connect() as conn:
result = conn.execute(
sa.update(model_definitions)
.where(model_definitions.c.definition_id == definition_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_model_definition(self, definition_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
@@ -2805,6 +2905,108 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount
# -- TLS / ACME ------------------------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
stmt = stmt.on_conflict_do_update(
index_elements=["id"],
set_={"key_pem": key_pem},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_account_key(self, key_id: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
).first()
return row[0] if row else None
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
stmt = pg_insert(tls_ca).values(name=name, cert_pem=cert_pem, key_pem=key_pem, created=now)
stmt = stmt.on_conflict_do_update(
index_elements=["name"],
set_={"cert_pem": cert_pem, "key_pem": key_pem},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
if not row:
return None
return _row_to_dict(row)
def save_tls_cert(
self,
domain: str,
cert_pem: str,
fullchain_pem: str,
key_pem: str,
issued_at: str,
expires_at: str,
meta: str | None = None,
) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(tls_certificates).values(
domain=domain,
cert_pem=cert_pem,
fullchain_pem=fullchain_pem,
key_pem=key_pem,
issued_at=issued_at,
expires_at=expires_at,
meta=meta,
)
stmt = stmt.on_conflict_do_update(
index_elements=["domain"],
set_={
"cert_pem": cert_pem,
"fullchain_pem": fullchain_pem,
"key_pem": key_pem,
"issued_at": issued_at,
"expires_at": expires_at,
"meta": meta,
},
)
with self._engine.connect() as conn:
conn.execute(stmt)
conn.commit()
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
).first()
if not row:
return None
return _row_to_dict(row)
def list_tls_certs(self) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
).fetchall()
return [_row_to_dict(r) for r in rows]
def delete_tls_cert(self, domain: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
)
conn.commit()
return result.rowcount > 0
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:

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