feat(server): opt-in server.require_project gate

Add an opt-in, default-off `server.require_project` setting. When an admin
enables it, creating an interactive chat is refused unless it is filed under a
project. The feature is inert and byte-identical when off, and can only ever
fail toward "off" (a missing config store or unset key reads as disabled).

- settings_registry: server.require_project (bool, default False, live read).
- auth: require_project_enabled + require_project_denies_create predicates
  (service scope / coordinator token_source exempt; NOT admin.coordinator),
  plus REQUIRE_PROJECT_ERROR / REQUIRE_PROJECT_CODE.
- node create gate via a declarative cfg.create_gate_require_project (wired True
  on the interactive mount only; coordinator spawns stay ungated).
- fork/resume: a fork's project is structurally its source's. Any explicit
  project_id is discarded, so a fork can never be re-filed under an unrelated
  project (which would move its copied history across a tenancy boundary).
  Inaccessible / projectless / nonexistent sources are uniform on body and
  status, so there is no cross-tenant oracle.
- console cluster-create proxy surfaces only the coded require_project 400 and
  masks every other node outcome (401/429/3xx/5xx, un-coded 400) to a sanitized
  502, guarding both body reads.
- list_projects advisory field + projects.js requireProject() (fail-open).
- fresh-create project picker requires an explicit project choice under the flag
  (no silent auto-select); forks hide the picker (inheritance is server-enforced)
  and get an accurate refusal message.
- tests: predicate matrix, resume-inheritance oracle discriminators, console
  masking, and end-to-end node-gate mount wiring.
This commit is contained in:
Patrick Buckley
2026-07-18 15:51:59 -07:00
parent cdc360bd98
commit c019ab41d7
12 changed files with 1134 additions and 40 deletions
+34
View File
@@ -430,6 +430,40 @@ def mock_openai_client():
return client
@pytest.fixture
def make_config_store():
"""Factory for a lightweight ConfigStore double.
``make_config_store(**overrides)`` returns an object whose ``.get(key)``
yields the override when present, else the registered SettingDef default —
mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads
as its ``False`` default on a miss, never ``None``). Shared by the
``server.require_project`` gate / advisory tests.
"""
_unset = object()
def _make(**overrides: Any) -> Any:
from turnstone.core.settings_registry import SETTINGS
class _ConfigStoreDouble:
def get(self, key: str, default: Any = _unset) -> Any:
# Mirror ConfigStore.get precedence exactly: cache (overrides)
# first, then a caller-supplied default, then the registry
# default, then None — so a reused caller passing an explicit
# default for an unset key gets the same value production would.
if key in overrides:
return overrides[key]
if default is not _unset:
return default
defn = SETTINGS.get(key)
return defn.default if defn else None
return _ConfigStoreDouble()
return _make
@pytest.fixture(autouse=True)
def _clear_policy_cache():
"""Drop the in-process tool-policy cache between tests.
+46
View File
@@ -2311,3 +2311,49 @@ def test_console_has_matching_pane_hotkeys() -> None:
assert '"Fork"' not in index and "New workstream" not in index, (
"Fork + New are intentionally omitted on the console"
)
def test_fork_hides_project_picker() -> None:
"""A fork must NOT show a project picker. A fork inherits its source's project
(enforced server-side), and a re-fileable fork picker was a cross-tenant
history-relocation vector — so showNewWsModal hides the picker for forks and
the "Keep source's project" fork option is gone."""
body = _APP_JS.read_text(encoding="utf-8")
assert "projSelect.hidden = !!_forkFromWsId" in body, (
"the new-ws project picker must be hidden for forks"
)
assert "Keep source's project" not in body, (
"the fork project picker (and its 'Keep source's project' option) must be removed"
)
def test_submit_gates_project_on_fork_flag() -> None:
"""submitNewWs must send body.project_id ONLY for a fresh create
(!_forkFromWsId) — a fork never sends a project (its project is the source's,
enforced server-side). Not gated on picker visibility or a requireProject()
re-read."""
body = _APP_JS.read_text(encoding="utf-8")
m = re.search(r"function submitNewWs\(\)\s*\{(.*?)\n\}", body, re.S)
assert m is not None, "could not locate submitNewWs"
fn = m.group(1)
assert "TurnstoneProjects.requireProject" not in fn, (
"submit must not re-read the requireProject() advisory"
)
assert re.search(r"project_id && !_forkFromWsId", fn), (
"submit must gate project_id on !_forkFromWsId (a fork never sends a project)"
)
def test_strict_picker_requires_explicit_pick() -> None:
"""Under require_project the fresh picker must NOT auto-select the first
project (which silently mis-files a required chat under a possibly-shared
project) — it offers a 'Select a project…' prompt so the user consciously
chooses."""
body = _APP_JS.read_text(encoding="utf-8")
assert "Select a project" in body, (
"strict picker must offer an explicit 'Select a project…' prompt"
)
m = re.search(r"function _reconcileRequiredProjectSelection\(sel\)\s*\{(.*?)\n\}", body, re.S)
assert m is not None, "could not locate _reconcileRequiredProjectSelection"
fn = m.group(1)
assert "real[0]" not in fn, "strict picker must not auto-select the first project"
+608
View File
@@ -0,0 +1,608 @@
"""``server.require_project`` — the opt-in, default-off gate refusing projectless
interactive creates.
Three surfaces:
* the predicate matrix (``require_project_enabled`` / ``require_project_denies_create``);
* the fork/resume project inheritance + the cross-tenant 403-vs-400 oracle in the
interactive create validator (``_interactive_create_validate_request``);
* the console cluster-create proxy's surface-only-require_project / mask-everything
-else policy (``create_workstream``).
Validator tests drive the coroutine synchronously via ``asyncio.run`` so they need no
async-plugin marker. Storage is a MagicMock patched onto the singleton getter that both
the RAW resume-resolve and ``ensure_project_attachable`` read.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
# ---------------------------------------------------------------------------
# Predicate matrix
# ---------------------------------------------------------------------------
class _Auth:
"""Minimal AuthResult stand-in: ``has_scope`` + ``token_source``."""
def __init__(
self, scopes: tuple[str, ...] = (), token_source: str = "jwt", user_id: str = "alice"
) -> None:
self._scopes = frozenset(scopes)
self.token_source = token_source
self.user_id = user_id
def has_scope(self, scope: str) -> bool:
return scope in self._scopes
class TestRequireProjectPredicate:
def test_enabled_off_by_default(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_enabled
assert require_project_enabled(make_config_store()) is False
def test_enabled_when_set(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_enabled
assert (
require_project_enabled(make_config_store(**{"server.require_project": True})) is True
)
def test_enabled_none_config_store_fails_open(self) -> None:
from turnstone.core.auth import require_project_enabled
assert require_project_enabled(None) is False
def test_denies_projectless_when_on(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, _Auth(), "") is True
def test_allows_when_off(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
# Flag off: even a projectless create is allowed (byte-identical to today).
assert require_project_denies_create(make_config_store(), _Auth(), "") is False
def test_allows_none_config_store(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
# Storage unwired: fail open.
assert require_project_denies_create(None, _Auth(), "") is False
def test_allows_with_project(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, _Auth(), "p1") is False
def test_whitespace_project_is_projectless(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, _Auth(), " ") is True
def test_service_scope_exempt(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, _Auth(scopes=("service",)), "") is False
def test_coordinator_source_exempt(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, _Auth(token_source="coordinator"), "") is False
def test_console_proxy_human_not_exempt(self, make_config_store: Any) -> None:
# The normal proxied human carries their OWN scopes (no service) and
# token_source "console-proxy" — gated, not exempt.
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
auth = _Auth(scopes=("read", "write"), token_source="console-proxy")
assert require_project_denies_create(cs, auth, "") is True
def test_admin_operator_not_exempt(self, make_config_store: Any) -> None:
# An operator carries admin-derived scopes (approve) but never `service`,
# so `admin.coordinator` humans are gated — the predicate keys on scope /
# token_source, never a permission.
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
auth = _Auth(scopes=("read", "write", "approve"), token_source="jwt")
assert require_project_denies_create(cs, auth, "") is True
def test_none_auth_denied_when_projectless(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, None, "") is True
def test_none_auth_allowed_with_project(self, make_config_store: Any) -> None:
from turnstone.core.auth import require_project_denies_create
cs = make_config_store(**{"server.require_project": True})
assert require_project_denies_create(cs, None, "p1") is False
# ---------------------------------------------------------------------------
# Fork/resume inheritance + the 403-vs-400 cross-tenant oracle (node validator)
# ---------------------------------------------------------------------------
def _src_storage(
*,
project_id: str | None = None,
project_visibility: str = "private",
project_owner: str = "other",
members: tuple[str, ...] = (),
resolve_none: bool = False,
get_project_missing: bool = False,
) -> MagicMock:
"""Storage double for the resume source: resolve + get_workstream (RAW) and
the get_project/is_project_member surface ``ensure_project_attachable`` reads."""
storage = MagicMock()
storage.resolve_workstream.side_effect = lambda _x: None if resolve_none else "src-canon"
storage.get_workstream.return_value = {
"ws_id": "src-canon",
"project_id": project_id,
"user_id": "other",
}
if get_project_missing or project_id is None:
storage.get_project.return_value = None
else:
storage.get_project.return_value = {
"project_id": project_id,
"name": "P",
"owner_id": project_owner,
"visibility": project_visibility,
"state": "active",
}
storage.is_project_member.side_effect = lambda pid, uid: uid in members
return storage
def _validate(monkeypatch: Any, body: dict[str, Any], uid: str, cs: Any, storage: Any) -> Any:
"""Run ``_interactive_create_validate_request`` with a patched storage getter."""
import turnstone.server as server_mod
monkeypatch.setattr("turnstone.core.storage._registry.get_storage", lambda: storage)
req = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config_store=cs)))
return asyncio.run(server_mod._interactive_create_validate_request(req, body, uid, []))
class TestResumeInheritanceOracle:
def _on(self, make_config_store: Any) -> Any:
return make_config_store(**{"server.require_project": True})
def test_inherits_attachable_source_project(
self, monkeypatch: Any, make_config_store: Any
) -> None:
storage = _src_storage(project_id="ppub", project_visibility="public")
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body["project_id"] == "ppub" # inherited (attachable)
def test_member_of_private_source_inherits(
self, monkeypatch: Any, make_config_store: Any
) -> None:
storage = _src_storage(
project_id="psecret", project_visibility="private", members=("alice",)
)
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body["project_id"] == "psecret"
def test_private_source_no_403_oracle(self, monkeypatch: Any, make_config_store: Any) -> None:
# Source under a private project alice can't access → MUST NOT surface a
# distinguishable 403; drop to projectless so the gate 400s it uniformly.
storage = _src_storage(project_id="psecret", project_visibility="private", members=())
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None # NOT a 403 JSONResponse
assert body.get("project_id", "") == ""
def test_projectless_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None:
storage = _src_storage(project_id=None)
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body.get("project_id", "") == ""
def test_nonexistent_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None:
storage = _src_storage(resolve_none=True)
body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body.get("project_id", "") == ""
def test_dangling_source_project_no_oracle(
self, monkeypatch: Any, make_config_store: Any
) -> None:
# Source's project was deleted → attach 400 → drop (uniform with the rest).
storage = _src_storage(project_id="pdead", get_project_missing=True)
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body.get("project_id", "") == ""
def test_private_and_projectless_indistinguishable(
self, monkeypatch: Any, make_config_store: Any
) -> None:
# The R1 core: private-source and projectless-source produce IDENTICAL
# observable outcomes — no cross-tenant oracle.
cs = self._on(make_config_store)
b_priv: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
_validate(monkeypatch, b_priv, "alice", cs, _src_storage(project_id="psecret"))
b_none: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
_validate(monkeypatch, b_none, "alice", cs, _src_storage(project_id=None))
assert b_priv.get("project_id", "") == b_none.get("project_id", "") == ""
def test_flag_off_never_resolves(self, monkeypatch: Any, make_config_store: Any) -> None:
# Byte-identical when off: the source is never resolved, nothing inherited.
storage = _src_storage(project_id="ppub", project_visibility="public")
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"}
res = _validate(monkeypatch, body, "alice", make_config_store(), storage)
assert res is None
assert body.get("project_id", "") == ""
storage.resolve_workstream.assert_not_called()
def test_explicit_project_discarded_for_projected_source(
self, monkeypatch: Any, make_config_store: Any
) -> None:
# A fork DISCARDS any explicit project_id and inherits its SOURCE's
# project — an explicit pick can never re-file a fork's history ([1]).
storage = _src_storage(project_id="ppub", project_visibility="public")
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "pchosen"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body["project_id"] == "ppub" # overridden to the source's project
storage.resolve_workstream.assert_called() # a fork always resolves its source
def test_explicit_project_discarded_projectless_source(
self, monkeypatch: Any, make_config_store: Any
) -> None:
# The safe-vs-leaky discriminator: a fork of a PROJECTLESS source carrying
# an explicit owned project_id must NOT file under the pick — the pick is
# discarded, nothing inherited, so it funnels to the uniform projectless
# "" (400 downstream), indistinguishable from inaccessible/nonexistent.
storage = _src_storage(project_id=None)
body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "powned"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body.get("project_id", "") == ""
def test_explicit_project_discarded_nonexistent_source(
self, monkeypatch: Any, make_config_store: Any
) -> None:
# Same discriminator for a NONEXISTENT source + explicit owned pid: "".
storage = _src_storage(resolve_none=True)
body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive", "project_id": "powned"}
res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage)
assert res is None
assert body.get("project_id", "") == ""
# ---------------------------------------------------------------------------
# Console cluster-create proxy: surface only require_project, mask the rest
# ---------------------------------------------------------------------------
_CONSOLE_JWT_SECRET = "test-jwt-secret-minimum-32-chars!"
def _console_headers() -> dict[str, str]:
from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt
tok = create_jwt(
user_id="op",
scopes=frozenset({"read", "write", "approve", "service"}),
source="test",
secret=_CONSOLE_JWT_SECRET,
audience=JWT_AUD_CONSOLE,
)
return {"Authorization": f"Bearer {tok}"}
def _node_resp(
status_code: int, json_body: dict[str, Any] | None = None, content: bytes | None = None
) -> httpx.Response:
req = httpx.Request("POST", "http://a:8080/v1/api/workstreams/new")
if content is not None:
return httpx.Response(status_code, content=content, request=req)
return httpx.Response(status_code, json=json_body if json_body is not None else {}, request=req)
@contextlib.contextmanager
def _console_client(
node_response: httpx.Response | None = None, raise_exc: BaseException | None = None
) -> Any:
"""A console TestClient whose proxied node create returns *node_response* (an
``httpx.Response``) or raises *raise_exc*. Lifespan is not entered (matches the
existing cluster-create tests) so the manually-attached proxy_client survives."""
from starlette.testclient import TestClient
from turnstone.console.collector import ClusterCollector
from turnstone.console.server import _load_static, create_app
collector = MagicMock(spec=ClusterCollector)
collector.get_node_detail.return_value = {
"node_id": "node-a",
"server_url": "http://a:8080",
"health": {},
"workstreams": [],
"aggregate": {},
"reachable": True,
}
collector.get_nodes.return_value = (
[{"node_id": "node-a", "reachable": True, "max_ws": 10, "ws_total": 1}],
1,
)
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
collector.get_overview.return_value = {
"nodes": 1,
"workstreams": 0,
"states": {"running": 0, "idle": 0, "thinking": 0, "attention": 0, "error": 0},
"aggregate": {"total_tokens": 0, "total_tool_calls": 0},
}
_load_static()
app = create_app(collector=collector, jwt_secret=_CONSOLE_JWT_SECRET)
async def _mock_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
if raise_exc is not None:
raise raise_exc
assert node_response is not None
return node_response
mock_proxy = MagicMock(spec=httpx.AsyncClient)
mock_proxy.post = MagicMock(side_effect=_mock_post)
app.state.proxy_client = mock_proxy
client = TestClient(app, raise_server_exceptions=False, headers=_console_headers())
try:
yield client
finally:
client.close()
def _create(client: Any) -> httpx.Response:
return client.post("/v1/api/cluster/workstreams/new", json={"node_id": "node-a", "name": "x"})
class TestConsoleRequireProjectSurfacing:
def test_require_project_400_surfaced(self) -> None:
from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR
node = _node_resp(400, {"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE})
with _console_client(node) as client:
resp = _create(client)
assert resp.status_code == 400
data = resp.json()
assert data["code"] == REQUIRE_PROJECT_CODE
assert data["error"] == REQUIRE_PROJECT_ERROR
def test_uncoded_400_masked_no_leak(self) -> None:
node = _node_resp(400, {"error": "cannot fork abc: SECRETPERSONA missing"})
with _console_client(node) as client:
resp = _create(client)
assert resp.status_code == 502
assert "SECRETPERSONA" not in resp.text
assert resp.json()["error"] == "Dispatch to node node-a failed"
def test_other_coded_400_masked(self) -> None:
node = _node_resp(400, {"error": "too many files", "code": "too_many"})
with _console_client(node) as client:
resp = _create(client)
assert resp.status_code == 502
def test_401_masked(self) -> None:
with _console_client(_node_resp(401, {"error": "unauthorized"})) as client:
resp = _create(client)
assert resp.status_code == 502
def test_429_masked(self) -> None:
with _console_client(_node_resp(429, {"error": "capacity"})) as client:
resp = _create(client)
assert resp.status_code == 502
def test_attach_denied_403_masked(self) -> None:
node = _node_resp(
403, {"error": "cannot attach a workstream to a private project you don't belong to"}
)
with _console_client(node) as client:
resp = _create(client)
assert resp.status_code == 502
assert "private project" not in resp.text
def test_500_masked(self) -> None:
with _console_client(_node_resp(500, {"error": "boom"})) as client:
resp = _create(client)
assert resp.status_code == 502
def test_non_json_2xx_masked(self) -> None:
# R7: a 2xx with no JSON body must mask to 502, not crash the console.
with _console_client(_node_resp(200, content=b"<html>not json</html>")) as client:
resp = _create(client)
assert resp.status_code == 502
def test_network_error_masked(self) -> None:
with _console_client(raise_exc=httpx.ConnectError("boom")) as client:
resp = _create(client)
assert resp.status_code == 502
def test_success_200_regression(self) -> None:
with _console_client(_node_resp(200, {"ws_id": "ws_new", "name": "x"})) as client:
resp = _create(client)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["correlation_id"] == "ws_new"
assert data["target_node"] == "node-a"
# ---------------------------------------------------------------------------
# Node gate kind-scoping — drives the REAL make_create_handler for BOTH kinds.
# The handler emits `code: "require_project"` ONLY at the gate, so that marker's
# presence/absence in the response is an exact witness of whether the gate fired
# — closing the load-bearing `cfg.list_kind == "interactive"` guard end-to-end.
# ---------------------------------------------------------------------------
def _gate_request(body: dict[str, Any], cs: Any, auth: Any) -> Any:
"""A minimal Starlette Request: JSON body + auth_result on request.state +
config_store on request.app.state — enough to reach the require_project gate."""
from starlette.requests import Request
payload = json.dumps(body).encode()
delivered = {"done": False}
async def _receive() -> dict[str, Any]:
if delivered["done"]:
return {"type": "http.disconnect"}
delivered["done"] = True
return {"type": "http.request", "body": payload, "more_body": False}
scope = {
"type": "http",
"method": "POST",
"path": "/v1/api/workstreams/new",
"headers": [(b"content-type", b"application/json")],
"query_string": b"",
"app": SimpleNamespace(state=SimpleNamespace(config_store=cs)),
"state": {"auth_result": auth},
}
return Request(scope, _receive)
def _is_require_project_400(resp: Any) -> bool:
if resp.status_code != 400:
return False
try:
return json.loads(resp.body).get("code") == "require_project"
except Exception:
return False
def _run_gate(list_kind: Any, flag_on: bool, body: dict[str, Any], make_config_store: Any) -> Any:
"""Drive make_create_handler.create() to the require_project gate for one
(kind, flag, body). A gate pass-through raises out of a build_kwargs stub that
the handler's own try/except turns into a non-require_project response."""
from turnstone.core.session_routes import SessionEndpointConfig, make_create_handler
from turnstone.core.workstream import WorkstreamKind
def _build(*_a: Any, **_k: Any) -> dict[str, Any]:
raise RuntimeError("stopped just past the require_project gate")
mgr = MagicMock()
mgr.kind = list_kind
cfg = SessionEndpointConfig(
permission_gate=lambda _req: None,
manager_lookup=lambda _req: (mgr, None),
tenant_check=None,
not_found_label="workstream",
audit_action_prefix="ws",
list_kind=list_kind,
# Derived per-kind HERE the way the real mounts wire it (interactive True,
# coordinator False); production keeps it a declarative field, not a kind check.
create_gate_require_project=(list_kind == WorkstreamKind.INTERACTIVE),
create_validate_request=None,
create_build_kwargs=_build,
create_supports_attachments=False,
create_supports_user_id_override=False,
)
handler = make_create_handler(cfg)
cs = make_config_store(**({"server.require_project": True} if flag_on else {}))
auth = _Auth(scopes=("read", "write"), token_source="jwt")
return asyncio.run(handler(_gate_request(body, cs, auth)))
class TestNodeGateKindScoping:
def test_interactive_projectless_gated(self, make_config_store: Any, tmp_db: Any) -> None:
from turnstone.core.workstream import WorkstreamKind
resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {}, make_config_store)
assert _is_require_project_400(resp)
def test_interactive_with_project_passes(self, make_config_store: Any, tmp_db: Any) -> None:
from turnstone.core.workstream import WorkstreamKind
resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {"project_id": "p1"}, make_config_store)
assert not _is_require_project_400(resp)
def test_interactive_flag_off_passes(self, make_config_store: Any, tmp_db: Any) -> None:
from turnstone.core.workstream import WorkstreamKind
resp = _run_gate(WorkstreamKind.INTERACTIVE, False, {}, make_config_store)
assert not _is_require_project_400(resp)
def test_coordinator_projectless_exempt(self, make_config_store: Any, tmp_db: Any) -> None:
# The coordinator mount leaves create_gate_require_project False, so a
# projectless coordinator create is NOT gated even with the flag on.
from turnstone.core.workstream import WorkstreamKind
resp = _run_gate(WorkstreamKind.COORDINATOR, True, {}, make_config_store)
assert not _is_require_project_400(resp)
# ---------------------------------------------------------------------------
# list_projects advisory field — the frontend composer reads data.require_project
# into requireProject(); pin that the endpoint actually emits it (and reflects
# the flag), so a refactor can't silently drop it and fail the picker open.
# ---------------------------------------------------------------------------
def _list_projects_request(cs: Any, auth: Any) -> Any:
from starlette.requests import Request
async def _receive() -> dict[str, Any]:
return {"type": "http.request", "body": b"", "more_body": False}
scope = {
"type": "http",
"method": "GET",
"path": "/v1/api/projects",
"headers": [],
"query_string": b"",
"app": SimpleNamespace(state=SimpleNamespace(config_store=cs)),
"state": {"auth_result": auth},
}
return Request(scope, _receive)
class TestListProjectsAdvisory:
def test_require_project_field_on(self, make_config_store: Any, tmp_db: Any) -> None:
import turnstone.server as server_mod
cs = make_config_store(**{"server.require_project": True})
auth = _Auth(scopes=("service",)) # service scope bypasses require_permission
resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth)))
data = json.loads(resp.body)
assert data["require_project"] is True
assert "projects" in data
def test_require_project_field_off_by_default(
self, make_config_store: Any, tmp_db: Any
) -> None:
import turnstone.server as server_mod
cs = make_config_store()
auth = _Auth(scopes=("service",))
resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth)))
assert json.loads(resp.body)["require_project"] is False
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-v"]))
+29
View File
@@ -2468,3 +2468,32 @@ class TestCompactCommandDispatch:
assert resp.status_code == 409
assert resp.json()["status"] == "busy"
assert ws.session.commands == []
class TestRequireProjectMountWiring:
"""server.require_project is wired on the REAL interactive create mount
(create_gate_require_project=True on interactive_endpoint_config). Synthetic-
cfg unit tests can't catch a mis-wire on the actual mount, so drive the
mounted endpoint end-to-end."""
def test_projectless_interactive_create_gated_when_on(self, app_client, make_config_store):
client, _mgr = app_client
client.app.state.config_store = make_config_store(**{"server.require_project": True})
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "no-project"},
headers=_auth("user-1", permissions=frozenset({"workstreams.create"})),
)
assert resp.status_code == 400, resp.json()
assert resp.json().get("code") == "require_project"
def test_projectless_interactive_create_allowed_when_off(self, app_client, make_config_store):
client, _mgr = app_client
client.app.state.config_store = make_config_store() # flag off (default)
resp = client.post(
"/v1/api/workstreams/new",
json={"name": "no-project"},
headers=_auth("user-1", permissions=frozenset({"workstreams.create"})),
)
body = resp.json()
assert not (resp.status_code == 400 and body.get("code") == "require_project"), body
+78 -8
View File
@@ -652,6 +652,13 @@ def _bounded_body_preview(text: str | bytes, cap: int = 200) -> str:
return _CONTROL_CHAR_RE.sub(" ", text)[:cap]
def _dispatch_failed(node_id: str) -> JSONResponse:
"""The sanitized 502 the cluster-create proxy returns for every masked node
outcome (network failure, unparseable 2xx body, and any non-require_project
node status). One wording, one status each caller keeps its own distinct log."""
return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502)
def _proxy_auth_headers(request: Request) -> dict[str, str]:
"""Build auth headers for proxied requests to upstream servers.
@@ -2205,18 +2212,81 @@ async def create_workstream(request: Request) -> JSONResponse:
)
else:
resp = await client.post(node_url, json=ws_body, headers=headers)
resp.raise_for_status()
except httpx.HTTPError as exc:
log.warning("Workstream dispatch to %s failed: %s", node_id, exc)
return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502)
return _dispatch_failed(node_id)
return JSONResponse(
{
"status": "ok",
"correlation_id": resp.json().get("ws_id", ""),
"target_node": node_id,
}
# The node is the authoritative require_project gate. Surface ONLY its
# coded require_project 400 to the operator; mask every OTHER node outcome
# as an opaque 502. Rationale (do not "simplify" by re-emitting the node
# status/body generically):
# * a node 401 re-emitted here trips authFetch's reactive refresh +
# force-logout, dumping a VALID operator to the login overlay and
# double-sending the (non-idempotent) create;
# * a node 429 re-emitted here trips authFetch's auto-retry, minting a
# DUPLICATE workstream;
# * any non-require_project node body may carry internal detail
# ("cannot fork <id>: <persona ValueError>", skill/persona/path text)
# that would land in the operator's browser DOM.
# The discriminator is an EXACT code match: coded-but-different bodies
# (too_many/too_large/upload) and every un-coded 400 mask to 502. The
# actionable attach-denied 403 and factory-misconfig 503 are also masked
# to 502 — a deliberate leak boundary; do NOT surface 403/503 here without
# re-checking the leak. Branch on the explicit success range (not
# raise_for_status) so a future httpx that lets a 3xx through still cannot
# reach the 2xx json parse. Guard BOTH body reads (a 204 / non-JSON 2xx
# would otherwise raise a JSONDecodeError → console 500 → operator retry →
# orphan/duplicate ws).
try:
_raw = resp.json()
node_body = _raw if isinstance(_raw, dict) else None
except Exception:
# Broad by design at this console->node proxy boundary: ANY parse failure
# (a 204 with no body, an intermediary's HTML, a truncated body) must fall
# through to the 502 mask below, never propagate. Deliberately wider than
# the codebase's narrow (ValueError, JSONDecodeError) json-guard.
node_body = None
if 200 <= resp.status_code < 300:
if node_body is None:
log.warning(
"Workstream dispatch to %s: unparseable %s success body: %s",
node_id,
resp.status_code,
_bounded_body_preview(resp.content),
)
return _dispatch_failed(node_id)
return JSONResponse(
{
"status": "ok",
"correlation_id": node_body.get("ws_id", ""),
"target_node": node_id,
}
)
from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR
if (
resp.status_code == 400
and node_body is not None
and node_body.get("code") == REQUIRE_PROJECT_CODE
):
# Surface OUR canonical wording, not the node's echoed `error` string: a
# version-skewed or misbehaving node must not control the operator-facing
# text, and this keeps the message identical across every refusal cause.
log.info("Workstream dispatch to %s refused: require_project", node_id)
return JSONResponse(
{"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE},
status_code=400,
)
log.warning(
"Workstream dispatch to %s failed: node status %s: %s",
node_id,
resp.status_code,
_bounded_body_preview(resp.content),
)
return _dispatch_failed(node_id)
# ---------------------------------------------------------------------------
+7
View File
@@ -1529,6 +1529,13 @@ function _refreshAndPopulateProjects() {
// Populate the launcher's Project picker from the shared cache, preserving the
// current pick across the rebuild (same reason as _populateLauncherNodes) and
// always appending the "+ New project…" sentinel after the live list.
//
// Under server.require_project the console launcher intentionally does NOT gate
// this picker on TurnstoneProjects.requireProject() (unlike the node new-ws
// dialog): the node create endpoint is the authoritative gate, and its refusal
// is surfaced to the operator by the console proxy (create_workstream). A
// client-side required-project picker here is a deferred UX nicety, not a
// correctness requirement.
function _populateHomeProjectDropdown() {
if (!_homeCoordComposer) return;
const TP = window.TurnstoneProjects;
+55
View File
@@ -469,6 +469,61 @@ def ensure_project_attachable(
return (403, "project access could not be verified")
# ---------------------------------------------------------------------------
# server.require_project — opt-in, default-off gate refusing projectless
# interactive creates. One predicate is the ONLY flag read (gate + advisory
# both call it) so authoritative and advisory logic cannot drift.
# ---------------------------------------------------------------------------
# Discriminator on the node's 400 body. The console cluster-create proxy
# surfaces ONLY this coded 400 to the operator and masks every other node
# outcome to a sanitized 502, so this string is a shared contract — import it
# on both sides rather than re-spelling the literal.
REQUIRE_PROJECT_CODE = "require_project"
# Operator-facing 400 message. Deliberately generic: a projectless, private,
# dangling, or nonexistent fork source must all yield this IDENTICAL text, or
# the message itself becomes a cross-tenant oracle.
REQUIRE_PROJECT_ERROR = (
"This deployment requires every new chat to be filed under a project. "
"Choose a project and try again."
)
def require_project_enabled(config_store: Any) -> bool:
"""Return True iff the ``server.require_project`` gate is switched on.
The SINGLE flag read, shared by the authoritative create gate and the
advisory ``list_projects`` field so the two cannot diverge in logic.
Fail-open: a missing config store (storage unwired) reads as off. The
ConfigStore returns the registered SettingDef default (``False``) on a
cache miss — so an unset flag is off, not ``None`` — but only because the
SettingDef IS registered; forgetting it silently disables the feature.
"""
return config_store is not None and bool(config_store.get("server.require_project"))
def require_project_denies_create(config_store: Any, auth: Any, project_id: Any) -> bool:
"""Return True to REFUSE an interactive create under ``server.require_project``.
Refuses only when the gate is on, the caller is not exempt automation, and
no project is attached (whitespace-only counts as none). Exempt identities:
the ``service`` scope (channel gateway, scheduler) and coordinator SESSION
spawns (``token_source == "coordinator"``). NOT exempt on any admin
permission — ``admin.coordinator`` is a human-operator permission and would
leak every operator through the gate. ``console-proxy`` (the normal proxied
human) is deliberately NOT exempt: it carries the human's own scopes, which
never include ``service``.
"""
if not require_project_enabled(config_store):
return False
if auth is not None and auth.has_scope("service"):
return False
if getattr(auth, "token_source", "") == "coordinator":
return False
return not str(project_id or "").strip()
def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]:
"""Derive legacy scopes from a granular permission set."""
scopes: set[str] = set()
+29
View File
@@ -428,6 +428,12 @@ class SessionEndpointConfig:
# wires ``False`` — coord create runs only on the console process
# and the operator's auth result is the source of truth.
create_supports_user_id_override: bool = False
# When ``True`` the shared create handler applies the ``server.require_project``
# gate (refuse a projectless interactive create). Declarative per-mount
# capability — interactive wires ``True``; coordinator leaves it ``False`` so
# coordinator spawns are never gated. A cfg flag rather than a hardcoded kind
# literal, matching the ``create_supports_*`` idiom.
create_gate_require_project: bool = False
# (request, body, uid, uploaded_files) -> JSONResponse | None.
# Per-kind pre-create gate (ws_id format, parent ownership, kind
# validation, etc. on interactive; 401-on-empty-uid on coord).
@@ -2564,6 +2570,29 @@ def make_create_handler(
if err_validate is not None:
return err_validate
# --- require_project gate (interactive-create mounts only) -------
# Gated by the declarative cfg.create_gate_require_project capability
# (True on the interactive create mount, default-False on coordinator)
# rather than a hardcoded kind literal, matching the create_supports_*
# idiom — coordinator spawns are exempt by design. By this point the
# validator has applied any parent-/resume-inherited project_id into body
# AND (for a fork) discarded any explicit pick to the source's project or
# "", so a private/dangling/projectless fork SOURCE funnels to the SAME
# uniform 400 as a projectless fresh create.
if cfg.create_gate_require_project:
from turnstone.core.auth import (
REQUIRE_PROJECT_CODE,
REQUIRE_PROJECT_ERROR,
require_project_denies_create,
)
_config_store = getattr(request.app.state, "config_store", None)
if require_project_denies_create(_config_store, auth, body.get("project_id")):
return JSONResponse(
{"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE},
status_code=400,
)
# --- Skill resolution --------------------------------------------
# Both kinds resolve a body ``skill`` field through
# ``get_skill_by_name`` to the skill_data dict + the next
+17
View File
@@ -358,6 +358,23 @@ def _build_registry() -> dict[str, SettingDef]:
"When the limit is reached, the oldest idle workstream is evicted to make room. "
"Each workstream uses memory proportional to its conversation history.",
),
SettingDef(
"server.require_project",
"bool",
False,
"Require new interactive chats to be filed under a project",
"server",
help="When on, starting a new interactive chat is refused unless it is filed "
"under a project. This is off by default and changes nothing until you turn it "
"on. A person can start a chat only once they belong to at least one project: "
"there is no automatic or personal project, so before turning this on in a "
"strict deployment, give each user membership in a project or mark a project "
"public, or they will not be able to start chats at all. Forking or resuming a "
"chat keeps the source chat's project; forking a chat that has no project is "
"refused just like starting a fresh chat without one. Automation such as "
"channel and scheduler activity, and coordinator-spawned sessions, are not "
"affected.",
),
# -- cluster --------------------------------------------------------
SettingDef(
"cluster.node_fan_out_limit",
+81 -3
View File
@@ -2216,6 +2216,7 @@ async def _interactive_create_validate_request(
status_code=400,
)
inherited_pid = False
resume_inherited_pid = False
body_parent = body.get("parent_ws_id") or None
if body_parent is not None:
from turnstone.core.storage._registry import get_storage as _get_storage_for_parent
@@ -2243,6 +2244,60 @@ async def _interactive_create_validate_request(
if not (body.get("project_id") or "") and parent_row.get("project_id"):
body["project_id"] = parent_row.get("project_id")
inherited_pid = True
# Fork/resume: a fork's project is STRUCTURALLY its source's, and only when
# the require_project gate is on (off is byte-identical — no resolve, no
# inherit, explicit project_id untouched). This DELIBERATELY DIVERGES from the
# sibling parent-coordinator block at :2244: that block DEFERS to an explicit
# body project_id (a coordinator spawn carries no history, so the
# spawn_workstream(project=…) escape hatch is legitimate), whereas a fork
# copies the SOURCE's conversation — which must never be re-filed under an
# unrelated project the caller merely owns — so here we DISCARD any explicit
# project_id up front and then inherit only the source's own project. An
# explicit body project_id can never influence a fork.
#
# No cross-tenant oracle, by construction: for a source that is inaccessible-
# private (attach 403 → resume_inherited_pid drop below), projectless, or
# nonexistent/unresolvable, body["project_id"] stays "" and the gate emits ONE
# uniform 400 — identical BODY *and* STATUS (the discard, not body-equalising,
# is what makes them indistinguishable). The residual side-channel is DB-query
# LATENCY only (an inaccessible-private source runs extra get_project/
# is_project_member); constant-time storage is out of scope.
#
# RAW/raising storage is deliberate — the swallowing memory.resolve_workstream
# would turn a transient DB error into None → a misleading "requires a project"
# 400, whereas a raise here surfaces an honest 500 (consistent with the parent
# block's sync get_workstream). resume_ws is only read, never mutated:
# post_install still performs the real fork (one extra indexed lookup on the
# rare fork path). Caveat (pre-existing to the resume mechanic): post_install
# re-resolves via the swallowing resolver, so a delete/blip between here and
# there degrades the fork to a fresh chat in the inherited project.
from turnstone.core.auth import require_project_enabled
if (
isinstance(resume_ws_id, str)
and resume_ws_id
and require_project_enabled(getattr(request.app.state, "config_store", None))
):
# Discard any caller-supplied project_id FIRST: a fork is filed under its
# SOURCE's project, or (no accessible source project) refused — never a
# caller pick. This structural discard is what blocks the re-file and makes
# the {inaccessible/projectless/nonexistent}-source outcomes uniform. It
# gates on require_project_enabled (the flag) and NOT require_project_denies_
# create (flag + service/coordinator exemptions), DELIBERATELY: the
# exemptions waive the "must have a project" MANDATE, but fork-integrity — a
# fork's copied history must never be re-filed under an unrelated project —
# is a security invariant that binds every forker while the feature is on.
# The two require_project predicates differ here on purpose.
body["project_id"] = ""
from turnstone.core.storage._registry import get_storage as _get_storage_for_resume
_rstorage = _get_storage_for_resume()
if _rstorage is not None:
_canonical = _rstorage.resolve_workstream(resume_ws_id)
_src_row = _rstorage.get_workstream(_canonical) if _canonical else None
if _src_row and _src_row.get("project_id"):
body["project_id"] = _src_row["project_id"]
resume_inherited_pid = True
# Project attach gate (explicit or parent-inherited): a private
# project accepts new workstreams only from its owner/members, and a
# nonexistent EXPLICIT project_id is a caller error rather than a
@@ -2260,7 +2315,19 @@ async def _interactive_create_validate_request(
denied = ensure_project_attachable(uid, attach_pid)
if denied is not None:
status, message = denied
if inherited_pid and status == 400:
if resume_inherited_pid:
# Resume-inherited project: ANY denial (unknown 400, private
# 403, storage-blip/None 403) drops to a projectless create, so
# a private/inaccessible/dangling SOURCE is indistinguishable
# from a projectless or nonexistent one. Surfacing the 403 would
# leak that the resume_ws id sits under a private project the
# caller can't see (a cross-tenant oracle). The require_project
# gate then emits ONE uniform 400 downstream.
body["project_id"] = ""
elif inherited_pid and status == 400:
# Parent-inherited dangling project (deleted): the child simply
# isn't attached. A 403 here (revoked coordinator membership)
# still hard-returns below — deliberately loud, unlike resume.
body["project_id"] = ""
else:
return JSONResponse({"error": message}, status_code=status)
@@ -3110,7 +3177,7 @@ def _project_request_uid(request: Request) -> tuple[str, JSONResponse | None]:
async def list_projects(request: Request) -> JSONResponse:
"""GET /v1/api/projects — projects the caller owns, is a member of, or public."""
from turnstone.core.auth import require_permission
from turnstone.core.auth import require_permission, require_project_enabled
from turnstone.core.storage import get_storage
err = require_permission(request, "project.read")
@@ -3126,7 +3193,17 @@ async def list_projects(request: Request) -> JSONResponse:
)
storage = get_storage()
rows = storage.list_projects_for_user(uid, include_archived=include_archived) if storage else []
return JSONResponse({"projects": [_project_view(r) for r in rows], "total": len(rows)})
# Advisory only — the create gate on the enforcing node is authoritative.
# Read via the SAME predicate so advisory and gate can't drift in logic
# (they still read per-process ConfigStore caches, which converge at rest).
cs = getattr(request.app.state, "config_store", None)
return JSONResponse(
{
"projects": [_project_view(r) for r in rows],
"total": len(rows),
"require_project": require_project_enabled(cs),
}
)
async def create_project(request: Request) -> JSONResponse:
@@ -4667,6 +4744,7 @@ def create_app(
sse_executor_lookup=lambda request: request.app.state.sse_executor,
create_supports_attachments=True,
create_supports_user_id_override=True,
create_gate_require_project=True,
create_validate_request=_interactive_create_validate_request,
create_build_kwargs=_interactive_create_build_kwargs,
create_post_install=_interactive_create_post_install,
+16
View File
@@ -21,6 +21,7 @@ let _loaded = false; // has the first refresh attempt completed (ok or failed)?
let _lastError = null; // last failure: HTTP status, 0 for network/parse, null when ok
let _inflight = null; // shared pending refresh so concurrent callers coalesce
let _fingerprint = null; // last fired map signature, for change-detection
let _requireProject = false; // server.require_project advisory; fail-open false
const _subs = []; // () => void, fired after each CHANGED refresh
function _fp(rows) {
@@ -79,12 +80,16 @@ export function refreshProjects() {
// than blanking it, and record the status so the failure is visible
// instead of looking like an empty list.
_lastError = r.status;
// The require_project advisory fails OPEN: a stale-true value would make
// the composer hide options on a transient error, so reset it to false.
_requireProject = false;
console.warn("projects: GET /v1/api/projects -> " + r.status);
return null;
})
.then(function (data) {
if (data) {
_lastError = null;
_requireProject = !!data.require_project;
_setCache(data.projects || []);
}
return _cache;
@@ -94,6 +99,7 @@ export function refreshProjects() {
// preserve the last-known cache, never reject (callers chain a bare
// .then), and surface the failure.
_lastError = 0;
_requireProject = false; // fail-open (see the non-OK branch above)
console.warn("projects: refresh failed", e);
return _cache;
})
@@ -126,6 +132,15 @@ export function projectsError() {
return _lastError;
}
/** Whether this deployment requires new chats to be filed under a project
* (server.require_project). ADVISORY only the create endpoint on the
* enforcing node is authoritative. Fails OPEN to false on any refresh failure
* so the composer never hides options on a stale-true value; read it only
* AFTER {@link refreshProjects} resolves. */
export function requireProject() {
return _requireProject;
}
/** Display name for a project_id, or "" when unknown (not yet loaded, no
* access, or since-removed). */
export function projectName(id) {
@@ -169,6 +184,7 @@ window.TurnstoneProjects = {
getProjects: getProjects,
projectsLoaded: projectsLoaded,
projectsError: projectsError,
requireProject: requireProject,
projectName: projectName,
projectChoices: projectChoices,
onProjectsChange: onProjectsChange,
+134 -29
View File
@@ -399,15 +399,37 @@ function showNewWsModal(forkFromWsId) {
});
// Project picker — populated from the shared projects cache, refreshed on
// open so a project created elsewhere appears. Hidden when forking: a fork
// inherits its parent's project.
// open. Fresh creates SHOW it; forks HIDE it — a fork's project is its
// source's, enforced server-side (an explicit body project_id is discarded for
// a fork), so the frontend never sends a project for a fork.
//
// By design there is NO in-modal project picker for a fork: under
// require_project a fork of a PROJECTLESS source is refused by the node with a
// 400 and no project field (matches the setting help "forking a chat that has
// no project is refused just like starting a fresh chat without one") — the
// operator files the source under a project first. Do NOT "restore" a fork
// project picker here without re-opening the cross-project re-file hole it caused.
const projLabel = document.querySelector('label[for="new-ws-project"]');
const projSelect = document.getElementById("new-ws-project");
const projHint = projLabel ? projLabel.querySelector(".label-hint") : null;
if (projLabel) projLabel.hidden = !!_forkFromWsId;
if (projSelect) projSelect.hidden = !!_forkFromWsId;
// Seed the hint from the warm cache SYNCHRONOUSLY so a fresh create isn't
// mislabeled "optional" for the duration of the (redundant) refresh round-trip
// — requireProject() reads a cache the rail warms at startup. The refresh below
// re-affirms it for the rare cold-cache open.
if (projHint && !_forkFromWsId && window.TurnstoneProjects) {
projHint.textContent = window.TurnstoneProjects.requireProject()
? "required"
: "optional";
}
if (projSelect && !_forkFromWsId && window.TurnstoneProjects) {
window.TurnstoneProjects.refreshProjects().then(function () {
_populateProjectSelect(projSelect);
const strict = !!window.TurnstoneProjects.requireProject();
// Honest label: under require_project a fresh create must resolve to a real
// project, so the static "optional" hint must not claim otherwise.
if (projHint) projHint.textContent = strict ? "required" : "optional";
_populateProjectSelect(projSelect, { requireProject: strict });
});
}
@@ -479,6 +501,9 @@ function _ensureStandaloneProjectCreator(sel) {
},
onClose: function () {
if (sel.value === _PROJECT_NEW) sel.value = "";
// Under require_project a fresh picker must not be left blank after a
// cancelled "+ New project…" — snap back to a valid project.
_reconcileRequiredProjectSelection(sel);
},
});
if (sel.parentNode) sel.parentNode.insertBefore(creator.el, sel.nextSibling);
@@ -502,10 +527,7 @@ function _populatePersonaSelect(sel) {
if (placeholder) sel.appendChild(placeholder);
const choices = window.TurnstonePersonas.personaChoices("interactive");
choices.forEach(function (c) {
const opt = document.createElement("option");
opt.value = c.value;
opt.textContent = c.text;
sel.appendChild(opt);
_appendOption(sel, c.value, c.text, false);
});
const stillValid = choices.some(function (c) {
return c.value === previous;
@@ -518,28 +540,90 @@ function _populatePersonaSelect(sel) {
}
}
// Fill a project <select> from the shared projects cache, preserving its first
// <option> (the "No project" placeholder) and the current selection, and append
// the "+ New project…" sentinel. No-op when the projects bridge is absent
// (project.read denied / still loading).
function _populateProjectSelect(sel) {
// Add one <option> to a project <select>.
function _appendOption(sel, value, text, disabled) {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = text;
if (disabled) opt.disabled = true;
sel.appendChild(opt);
}
// True when *val* is currently an <option> value on *sel*.
function _optionExists(sel, val) {
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === val) return true;
}
return false;
}
// Fill a project <select> from the shared projects cache. The picker MODE is
// passed EXPLICITLY by each caller as {requireProject}; a re-populate from the
// inline "+ New project…" creator passes nothing and reuses the mode stamped on
// the element at open time. This helper NEVER reads the _forkFromWsId module
// global — the dashboard picker shares it. No-op when the projects bridge is
// absent (project.read denied / still loading). Forks don't reach this helper:
// their picker is hidden and their project is inherited server-side.
//
// Option 0 (placeholder) by mode:
// - gate off: "No project" (value "" -> a projectless create).
// - gate on + projects: "Select a project…" (value "") — the user must
// consciously pick; we never auto-select a project they
// didn't choose (it could silently file the chat under a
// shared one).
// - gate on + none: a DISABLED "No projects available" notice (value "").
function _populateProjectSelect(sel, opts) {
if (!sel || !window.TurnstoneProjects) return;
const mode = opts || sel._projMode || {};
sel._projMode = mode;
const strict = !!mode.requireProject;
_ensureStandaloneProjectCreator(sel);
const previous = sel.value;
const placeholder = sel.options.length ? sel.options[0] : null;
const choices = window.TurnstoneProjects.projectChoices();
sel.replaceChildren();
if (placeholder) sel.appendChild(placeholder);
window.TurnstoneProjects.projectChoices().forEach(function (c) {
const opt = document.createElement("option");
opt.value = c.value;
opt.textContent = c.text;
sel.appendChild(opt);
if (!strict) {
_appendOption(sel, "", "No project", false);
} else if (choices.length === 0) {
_appendOption(sel, "", "No projects available", true);
} else {
// strict + has projects: a "Select a project…" prompt (value "") so the user
// must consciously pick — never auto-file under an unchosen (possibly shared)
// project. Zero-touch submit sends no project and the gate returns the 400.
_appendOption(sel, "", "Select a project…", false);
}
choices.forEach(function (c) {
_appendOption(sel, c.value, c.text, false);
});
const newOpt = document.createElement("option");
newOpt.value = _PROJECT_NEW;
newOpt.textContent = "+ New project…";
sel.appendChild(newOpt);
if (previous && previous !== _PROJECT_NEW) sel.value = previous;
_appendOption(sel, _PROJECT_NEW, "+ New project…", false);
if (previous && previous !== _PROJECT_NEW && _optionExists(sel, previous)) {
sel.value = previous;
}
_reconcileRequiredProjectSelection(sel);
}
// Under require_project the picker keeps a VALID selection — an already-chosen
// real project, else the "Select a project…" prompt (value "", installed by
// populate), else (no projects) the disabled "No projects available" notice. It
// must NOT auto-select a real project the user didn't choose — that silently
// files a required chat under a possibly-shared project. No-op when the gate is
// off. Idempotent — only sets the SELECTION, never adds options.
function _reconcileRequiredProjectSelection(sel) {
if (!sel) return;
const mode = sel._projMode || {};
if (!mode.requireProject) return;
const real = window.TurnstoneProjects
? window.TurnstoneProjects.projectChoices()
: [];
if (real.length === 0) {
sel.value = ""; // the disabled "No projects available" notice
return;
}
const isReal = real.some(function (c) {
return c.value === sel.value;
});
// Keep a real pick; otherwise rest on the "Select a project…" prompt (value
// ""), never auto-selecting an unchosen project.
if (!isReal) sel.value = "";
}
function submitNewWs() {
@@ -561,9 +645,14 @@ function submitNewWs() {
if (model) body.model = model;
if (judge_model) body.judge_model = judge_model;
if (skill && !_forkFromWsId) body.skill = skill;
// A fork inherits its parent's project (the picker is hidden); only a fresh
// create carries an explicit project_id.
if (project_id && !_forkFromWsId) body.project_id = project_id;
// Only a FRESH create sends a project_id; a fork's project is its source's,
// enforced server-side (the picker is hidden for forks, and any explicit pid is
// discarded for a fork on the node). No _PROJECT_NEW guard: the select's change
// handler resets the sentinel to "" before opening the creator, so it can't
// reach submit (the dashboard quick-create path likewise doesn't filter it).
if (project_id && !_forkFromWsId) {
body.project_id = project_id;
}
// Persona likewise — a fork resumes the source's stamped persona.
if (persona && !_forkFromWsId) body.persona = persona;
if (_forkFromWsId) body.resume_ws = _forkFromWsId;
@@ -597,7 +686,19 @@ function submitNewWs() {
.then(function (data) {
window.TurnstoneHatch.setBusy(dlg, false);
if (data.error) {
_newWsError(data.error);
// A projectless-source fork can't satisfy require_project in this modal
// (no picker, and a pick would be discarded server-side), so the generic
// "choose a project" text points at an impossible remedy — surface the
// real one (file the source under a project first) instead.
if (_forkFromWsId && data.code === "require_project") {
_newWsError(
"This chat isn't filed under a project, so it can't be forked while " +
"this deployment requires one. File the source chat under a project " +
"first, then fork it.",
);
} else {
_newWsError(data.error);
}
return;
}
if (data.ws_id) {
@@ -1398,7 +1499,11 @@ function _loadDashboardOptionsLists() {
const projSel = document.getElementById("dashboard-project");
if (projSel && window.TurnstoneProjects) {
window.TurnstoneProjects.refreshProjects().then(function () {
_populateProjectSelect(projSel);
// Dashboard quick-create is ALWAYS a fresh create (never a fork); pass the
// mode explicitly so the shared helper never reads the fork-only global.
_populateProjectSelect(projSel, {
requireProject: !!window.TurnstoneProjects.requireProject(),
});
});
}