mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
122 lines
4.2 KiB
Python
122 lines
4.2 KiB
Python
"""Tests for console _proxy_auth_headers preserving the coordinator src claim.
|
|
|
|
Verifies C8 of the coordinator plan: when a console handler processes an
|
|
inbound request authenticated with a coordinator-minted JWT (``src ==
|
|
"coordinator"``), the upstream JWT the console mints for the proxied
|
|
request preserves that source plus the ``coord_ws_id`` custom claim.
|
|
For non-coordinator inbound tokens the re-mint still uses
|
|
``"console-proxy"`` as before — the existing behaviour is unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import jwt as pyjwt
|
|
|
|
from turnstone.console.server import _proxy_auth_headers
|
|
from turnstone.core.auth import JWT_AUD_SERVER, AuthResult
|
|
|
|
_SECRET = "x" * 64
|
|
|
|
|
|
def _build_request(auth_result: AuthResult | None):
|
|
"""Minimal Request-alike for _proxy_auth_headers."""
|
|
state = SimpleNamespace(auth_result=auth_result)
|
|
app_state = SimpleNamespace(jwt_secret=_SECRET, proxy_token_mgr=None)
|
|
app = MagicMock()
|
|
app.state = app_state
|
|
req = MagicMock()
|
|
req.state = state
|
|
req.app = app
|
|
return req
|
|
|
|
|
|
def _decode(headers: dict[str, str]) -> dict:
|
|
token = headers["Authorization"].removeprefix("Bearer ")
|
|
return pyjwt.decode(token, _SECRET, algorithms=["HS256"], audience=JWT_AUD_SERVER)
|
|
|
|
|
|
def test_console_proxy_uses_console_proxy_source_by_default():
|
|
"""Non-coordinator inbound tokens still mint src='console-proxy'."""
|
|
auth = AuthResult(
|
|
user_id="user-1",
|
|
scopes=frozenset({"write"}),
|
|
token_source="jwt",
|
|
permissions=frozenset(),
|
|
)
|
|
headers = _proxy_auth_headers(_build_request(auth))
|
|
payload = _decode(headers)
|
|
assert payload["src"] == "console-proxy"
|
|
assert "coord_ws_id" not in payload
|
|
|
|
|
|
def test_console_service_source_is_preserved_for_trusted_forwarding():
|
|
"""Only the console service identity may retain ``src=console``."""
|
|
auth = AuthResult(
|
|
user_id="console-service",
|
|
scopes=frozenset({"read", "write", "service"}),
|
|
token_source="console",
|
|
permissions=frozenset({"workstreams.create"}),
|
|
)
|
|
payload = _decode(_proxy_auth_headers(_build_request(auth)))
|
|
assert payload["src"] == "console"
|
|
assert set(payload["scopes"].split(",")) == {"read", "write", "service"}
|
|
|
|
|
|
def test_unscoped_console_claim_is_demoted_to_console_proxy():
|
|
"""An ordinary principal cannot gain owner-override trust through ``src``."""
|
|
auth = AuthResult(
|
|
user_id="ordinary-user",
|
|
scopes=frozenset({"read", "write"}),
|
|
token_source="console",
|
|
permissions=frozenset({"workstreams.create"}),
|
|
)
|
|
payload = _decode(_proxy_auth_headers(_build_request(auth)))
|
|
assert payload["src"] == "console-proxy"
|
|
|
|
|
|
def test_coordinator_source_is_preserved_on_remint():
|
|
"""Inbound src='coordinator' → outbound src='coordinator'."""
|
|
auth = AuthResult(
|
|
user_id="user-1",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="coordinator",
|
|
permissions=frozenset({"admin.coordinator"}),
|
|
extra_claims={"coord_ws_id": "coord-42"},
|
|
)
|
|
headers = _proxy_auth_headers(_build_request(auth))
|
|
payload = _decode(headers)
|
|
assert payload["src"] == "coordinator"
|
|
assert payload["coord_ws_id"] == "coord-42"
|
|
|
|
|
|
def test_coord_ws_id_absent_when_not_in_inbound_claims():
|
|
"""Defensive: if the inbound token is src=coordinator but missing the
|
|
coord_ws_id claim (shouldn't happen in practice), the re-mint skips
|
|
the custom claim rather than panicking."""
|
|
auth = AuthResult(
|
|
user_id="user-1",
|
|
scopes=frozenset({"write"}),
|
|
token_source="coordinator",
|
|
permissions=frozenset(),
|
|
)
|
|
headers = _proxy_auth_headers(_build_request(auth))
|
|
payload = _decode(headers)
|
|
assert payload["src"] == "coordinator"
|
|
assert "coord_ws_id" not in payload
|
|
|
|
|
|
def test_empty_auth_falls_back_to_service_token_or_empty():
|
|
"""Without auth_result.user_id, falls through to ServiceTokenManager."""
|
|
auth = AuthResult(
|
|
user_id="",
|
|
scopes=frozenset(),
|
|
token_source="config",
|
|
permissions=frozenset(),
|
|
)
|
|
# No proxy_token_mgr configured → empty headers.
|
|
headers = _proxy_auth_headers(_build_request(auth))
|
|
assert headers == {}
|