mirror of
https://github.com/andrewyng/openworker.git
synced 2026-08-30 22:53:41 +00:00
The title rides the user message the moment it lands — a long agentic turn no longer holds the session name hostage; an opener signature keeps the completion hook (background turns) from burning duplicate attempts.
1176 lines
46 KiB
Python
1176 lines
46 KiB
Python
"""P6 gate tests — server: OpenAI-compatible endpoint, WS session API, REST."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from coworker.providers import (
|
|
AssistantTurn,
|
|
ModelCapabilities,
|
|
ProviderClient,
|
|
ToolCall,
|
|
)
|
|
from coworker.server import SessionManager, create_app
|
|
from coworker.sessions import SessionRecord
|
|
|
|
|
|
class ScriptedProvider(ProviderClient):
|
|
"""A ProviderClient that returns queued AssistantTurns (streams via base default)."""
|
|
|
|
def __init__(self, turns):
|
|
self._turns = list(turns)
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
return self._turns.pop(0)
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
|
|
def _text(text):
|
|
return AssistantTurn(text=text, finish_reason="stop")
|
|
|
|
|
|
def _tool(name, args, call_id="call_1"):
|
|
return AssistantTurn(tool_calls=[ToolCall(id=call_id, name=name, arguments=args)])
|
|
|
|
|
|
def _client(tmp_path, turns):
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider(turns))
|
|
return TestClient(create_app(manager))
|
|
|
|
|
|
# -- REST -----------------------------------------------------------------------
|
|
|
|
|
|
def test_chat_completions_openai_shape(tmp_path):
|
|
client = _client(tmp_path, [_text("hello world")])
|
|
resp = client.post(
|
|
"/v1/chat/completions",
|
|
json={"model": "gpt-5.5", "messages": [{"role": "user", "content": "hi"}]},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["object"] == "chat.completion"
|
|
assert body["choices"][0]["message"]["content"] == "hello world"
|
|
assert body["choices"][0]["finish_reason"] == "stop"
|
|
|
|
|
|
def test_agents_and_memory_rest(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
agents = client.get("/v1/agents").json()["agents"]
|
|
# The picker lists enabled+surfaced personas. Release lineup (owner 2026-08-21):
|
|
# OpenWorker + the security bundles; Code ships disabled, Chat is gone, and
|
|
# ships:false personas (teams, ops, design) need OPENWORKER_UNSHIPPED=1.
|
|
names = [a["name"] for a in agents]
|
|
assert names[0] == "cowork"
|
|
assert set(names) == {"cowork", "security", "cloud-posture", "dep-audit"}
|
|
assert "skills" in client.get("/v1/skills").json() # catalog (may be empty)
|
|
|
|
added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json()
|
|
assert added["content"] == "prefer pathlib"
|
|
assert any(
|
|
m["content"] == "prefer pathlib"
|
|
for m in client.get("/v1/memory").json()["memory"]
|
|
)
|
|
|
|
|
|
def test_disable_persona_archives_its_sessions(tmp_path):
|
|
"""Disable = "put this coworker and its history away": the persona's real sessions are
|
|
archived atomically server-side (so its sidebar section disappears with it), internal
|
|
__run__ threads and other personas are untouched, and re-enable never unarchives."""
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
store = manager.session_store
|
|
|
|
def mk(sid, agent):
|
|
store.save(
|
|
SessionRecord(
|
|
session_id=sid,
|
|
workspace=str(tmp_path),
|
|
model="m",
|
|
mode="interactive",
|
|
agent=agent,
|
|
)
|
|
)
|
|
|
|
mk("chat-a", "code")
|
|
mk("chat-b", "code")
|
|
mk("chat-old", "code")
|
|
store.set_flags(
|
|
"chat-old", archived=True
|
|
) # already archived — must not be re-counted
|
|
mk("cowork-a", "cowork")
|
|
mk("__run__r1", "code") # internal automation thread — never touched
|
|
|
|
client = TestClient(create_app(manager))
|
|
body = client.post("/v1/personas/code", json={"enabled": False}).json()
|
|
assert body["ok"] is True
|
|
assert body["archived_sessions"] == 2
|
|
assert store.load("chat-a").archived and store.load("chat-b").archived
|
|
assert store.load("cowork-a").archived is False
|
|
assert store.load("__run__r1").archived is False
|
|
|
|
# Re-enable brings the persona back but never rewrites the user's archive state.
|
|
client.post("/v1/personas/chat", json={"enabled": True})
|
|
assert store.load("chat-a").archived
|
|
|
|
# The dedicated §5/§8 enable route shares the same semantic.
|
|
mk("chat-c", "code")
|
|
client.post("/v1/personas/code/enable", json={"enabled": False})
|
|
assert store.load("chat-c").archived
|
|
|
|
|
|
def test_connector_tool_settings_and_audit_rest(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
connectors = {
|
|
c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]
|
|
}
|
|
assert any(t["name"] == "browser_open_url" for t in connectors["browser"]["tools"])
|
|
|
|
res = client.patch(
|
|
"/v1/connectors/browser/tools", json={"enabled": {"browser_open_url": False}}
|
|
).json()
|
|
assert res["ok"] is True
|
|
connectors = {
|
|
c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]
|
|
}
|
|
browser_tools = {t["name"]: t for t in connectors["browser"]["tools"]}
|
|
assert browser_tools["browser_open_url"]["enabled"] is False
|
|
|
|
assert client.get("/v1/audit", params={"session_id": "none"}).json()["events"] == []
|
|
assert client.get("/v1/browser/state").json()["status"] in {
|
|
"closed",
|
|
"open",
|
|
"error",
|
|
}
|
|
|
|
|
|
def test_artifacts_list_and_read_previewable_files(tmp_path):
|
|
(tmp_path / "brief.md").write_text("# Brief\n\nHello", encoding="utf-8")
|
|
(tmp_path / "page.html").write_text("<h1>Preview</h1>", encoding="utf-8")
|
|
(tmp_path / ".secret.md").write_text("hidden", encoding="utf-8")
|
|
(tmp_path / "node_modules").mkdir()
|
|
(tmp_path / "node_modules" / "noise.md").write_text("skip", encoding="utf-8")
|
|
|
|
client = _client(tmp_path, [])
|
|
artifacts = client.get("/v1/sessions/unknown/artifacts").json()["artifacts"]
|
|
by_path = {a["path"]: a for a in artifacts}
|
|
|
|
assert by_path["brief.md"]["kind"] == "markdown"
|
|
assert by_path["page.html"]["kind"] == "html"
|
|
assert ".secret.md" not in by_path
|
|
assert "node_modules/noise.md" not in by_path
|
|
|
|
md = client.get(
|
|
"/v1/sessions/unknown/artifacts/read", params={"path": "brief.md"}
|
|
).json()
|
|
assert md["ok"] is True
|
|
assert md["kind"] == "markdown"
|
|
assert md["content"].startswith("# Brief")
|
|
|
|
html = client.get(
|
|
"/v1/sessions/unknown/artifacts/read", params={"path": "page.html"}
|
|
).json()
|
|
assert html["ok"] is True
|
|
assert html["kind"] == "html"
|
|
assert "<h1>Preview</h1>" in html["content"]
|
|
|
|
|
|
def test_artifact_read_folder_returns_listing(tmp_path):
|
|
"""A linked directory (e.g. a skill package dir) renders as a listing, never a dead
|
|
'not found' (owner report 2026-07-27). Dirs first, then files, sizes on files only."""
|
|
pkg = tmp_path / "directory-statistics"
|
|
pkg.mkdir()
|
|
(pkg / "SKILL.md").write_text("---\nname: x\n---\nbody", encoding="utf-8")
|
|
(pkg / "stats.py").write_text("print(1)", encoding="utf-8")
|
|
(pkg / "examples").mkdir()
|
|
|
|
client = _client(tmp_path, [])
|
|
res = client.get(
|
|
"/v1/sessions/unknown/artifacts/read", params={"path": "directory-statistics"}
|
|
).json()
|
|
assert res["ok"] is True and res["kind"] == "folder"
|
|
names = [e["name"] for e in res["entries"]]
|
|
assert names == ["examples", "SKILL.md", "stats.py"] # dirs first, then files by name
|
|
assert res["entries"][0]["dir"] is True
|
|
assert res["entries"][2]["size"] > 0
|
|
|
|
# A genuinely missing path keeps a friendly, non-jargon error.
|
|
missing = client.get(
|
|
"/v1/sessions/unknown/artifacts/read", params={"path": "nope.md"}
|
|
).json()
|
|
assert missing["ok"] is False
|
|
assert "moved or deleted" in missing["error"]
|
|
|
|
|
|
def test_artifact_read_rejects_path_escape(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
escaped = client.get(
|
|
"/v1/sessions/unknown/artifacts/read", params={"path": "../outside.md"}
|
|
).json()
|
|
assert escaped["ok"] is False
|
|
assert "escapes" in escaped["error"]
|
|
|
|
|
|
def test_sessions_hide_scheduled_internal_runs(tmp_path):
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="normal",
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
mode="interactive",
|
|
messages=[{"role": "user", "content": "normal task"}],
|
|
title="Normal task",
|
|
agent="cowork",
|
|
)
|
|
)
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="__run__daily-news-1",
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
mode="interactive",
|
|
messages=[{"role": "user", "content": "scheduled run"}],
|
|
title="Daily news briefing",
|
|
agent="cowork",
|
|
)
|
|
)
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="__task__daily-news",
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
mode="interactive",
|
|
messages=[{"role": "user", "content": "scheduled task"}],
|
|
title="Daily news briefing",
|
|
agent="cowork",
|
|
)
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
session_ids = {
|
|
s["session_id"] for s in client.get("/v1/sessions").json()["sessions"]
|
|
}
|
|
assert "normal" in session_ids
|
|
assert "__run__daily-news-1" not in session_ids
|
|
assert "__task__daily-news" not in session_ids
|
|
|
|
|
|
def test_sessions_can_be_renamed_and_deleted(tmp_path):
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="rename-me",
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
mode="interactive",
|
|
messages=[{"role": "user", "content": "original"}],
|
|
title="Original title",
|
|
agent="cowork",
|
|
)
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
|
|
renamed = client.patch(
|
|
"/v1/sessions/rename-me", json={"title": " Better title "}
|
|
).json()
|
|
assert renamed["ok"] is True
|
|
sessions = client.get("/v1/sessions").json()["sessions"]
|
|
assert any(
|
|
s["session_id"] == "rename-me" and s["title"] == "Better title"
|
|
for s in sessions
|
|
)
|
|
|
|
deleted = client.delete("/v1/sessions/rename-me").json()
|
|
assert deleted["ok"] is True
|
|
sessions = client.get("/v1/sessions").json()["sessions"]
|
|
assert all(s["session_id"] != "rename-me" for s in sessions)
|
|
assert client.get("/v1/sessions/rename-me/messages").json()["messages"] == []
|
|
|
|
|
|
def test_sessions_can_be_pinned_and_archived(tmp_path):
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
for sid in ("older", "newer"):
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id=sid,
|
|
workspace=str(tmp_path),
|
|
model="gpt-5.5",
|
|
mode="interactive",
|
|
messages=[{"role": "user", "content": sid}],
|
|
agent="cowork",
|
|
)
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
|
|
assert (
|
|
client.patch("/v1/sessions/older", json={"pinned": True}).json()["ok"] is True
|
|
)
|
|
sessions = client.get("/v1/sessions").json()["sessions"]
|
|
assert sessions[0]["session_id"] == "older" and sessions[0]["pinned"] is True
|
|
|
|
assert (
|
|
client.patch("/v1/sessions/newer", json={"archived": True}).json()["ok"] is True
|
|
)
|
|
by_id = {s["session_id"]: s for s in client.get("/v1/sessions").json()["sessions"]}
|
|
assert by_id["newer"]["archived"] is True
|
|
|
|
assert (
|
|
client.patch("/v1/sessions/older", json={"pinned": False}).json()["ok"] is True
|
|
)
|
|
assert (
|
|
client.patch("/v1/sessions/newer", json={"archived": False}).json()["ok"]
|
|
is True
|
|
)
|
|
by_id = {s["session_id"]: s for s in client.get("/v1/sessions").json()["sessions"]}
|
|
assert by_id["older"]["pinned"] is False and by_id["newer"]["archived"] is False
|
|
|
|
|
|
# -- WebSocket ------------------------------------------------------------------
|
|
|
|
|
|
def _drain(ws, on_permission=None):
|
|
"""Collect event types until turn_done; optionally answer permission_required."""
|
|
types = []
|
|
while True:
|
|
event = ws.receive_json()
|
|
types.append(event["type"])
|
|
if event["type"] == "permission_required" and on_permission:
|
|
ws.send_json({"type": "approval", "decision": on_permission})
|
|
if event["type"] == "turn_done":
|
|
return types
|
|
|
|
|
|
def test_ws_simple_turn(tmp_path):
|
|
client = _client(tmp_path, [_text("done thinking")])
|
|
with client.websocket_connect("/ws/session/s1") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "hello"})
|
|
types = _drain(ws)
|
|
assert "assistant_message" in types
|
|
assert "turn_end" in types
|
|
|
|
|
|
def test_ws_rejects_oversized_message(tmp_path):
|
|
from coworker.server import app as app_mod
|
|
from coworker.attachments import MAX_ATTACHMENTS
|
|
|
|
client = _client(tmp_path, [_text("should not run")])
|
|
with client.websocket_connect("/ws/session/big") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
|
|
# Oversized text → single input-rejected frame, no turn runs.
|
|
ws.send_json(
|
|
{"type": "user_message", "text": "x" * (app_mod._MAX_MESSAGE_TEXT_CHARS + 1)}
|
|
)
|
|
evt = ws.receive_json()
|
|
assert evt["type"] == "input_rejected"
|
|
assert "too long" in evt["data"]["error"].lower()
|
|
|
|
# The ingress cap is the same cap the attachment builder enforces.
|
|
assert app_mod._MAX_ATTACHMENTS == MAX_ATTACHMENTS
|
|
ws.send_json(
|
|
{
|
|
"type": "user_message",
|
|
"text": "hi",
|
|
"attachments": ["a"] * (app_mod._MAX_ATTACHMENTS + 1),
|
|
}
|
|
)
|
|
evt = ws.receive_json()
|
|
assert evt["type"] == "input_rejected"
|
|
assert "attachment" in evt["data"]["error"].lower()
|
|
|
|
# A normal message still works afterwards (the socket wasn't torn down).
|
|
ws.send_json({"type": "user_message", "text": "hello"})
|
|
assert "turn_done" in _drain(ws)
|
|
|
|
|
|
def test_ws_rejects_malformed_payloads_without_killing_socket(tmp_path):
|
|
client = _client(tmp_path, [_text("normal")])
|
|
with client.websocket_connect("/ws/session/malformed") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
|
|
invalid = [
|
|
[],
|
|
{"type": "user_message", "text": ["not", "text"]},
|
|
{"type": "user_message", "text": "x", "attachments": {}},
|
|
{
|
|
"type": "user_message",
|
|
"text": "x",
|
|
"attachments": [{"kind": "image", "data_url": "https://example.com/x"}],
|
|
},
|
|
{"type": "set_model", "model": {"unexpected": True}},
|
|
{"type": "unknown"},
|
|
]
|
|
for payload in invalid:
|
|
ws.send_json(payload)
|
|
evt = ws.receive_json()
|
|
assert evt["type"] == "input_rejected"
|
|
|
|
ws.send_json({"type": "user_message", "text": "still works"})
|
|
assert "turn_done" in _drain(ws)
|
|
|
|
|
|
def test_ws_allows_only_one_inflight_turn_per_session(tmp_path):
|
|
import threading
|
|
import time
|
|
|
|
class SlowProvider(ProviderClient):
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.active = 0
|
|
self.max_active = 0
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
# The fire-and-forget auto-title completion legitimately runs CONCURRENTLY
|
|
# with the chat turn (it fires at turn start, owner catch 2026-08-24) — the
|
|
# invariant under test is one CHAT turn at a time, so exclude title calls.
|
|
if messages and "title chat sessions" in str(messages[0].get("content", "")):
|
|
return _text("A Title")
|
|
with self._lock:
|
|
self.active += 1
|
|
self.max_active = max(self.max_active, self.active)
|
|
try:
|
|
time.sleep(0.08)
|
|
return _text("done")
|
|
finally:
|
|
with self._lock:
|
|
self.active -= 1
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
provider = SlowProvider()
|
|
manager = SessionManager(workspace=tmp_path, provider=provider)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/serialized") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "first"})
|
|
ws.send_json({"type": "user_message", "text": "second"})
|
|
|
|
types = []
|
|
while "turn_done" not in types:
|
|
types.append(ws.receive_json()["type"])
|
|
|
|
assert "input_rejected" in types
|
|
assert provider.max_active == 1
|
|
engine = manager._engines["serialized"]
|
|
user_messages = [m for m in engine.messages if m.get("role") == "user"]
|
|
assert [m["content"] for m in user_messages] == ["first"]
|
|
|
|
|
|
def test_ws_rate_limits_inbound_frames(tmp_path):
|
|
from coworker.server import app as app_mod
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
client = _client(tmp_path, [])
|
|
with pytest.raises(WebSocketDisconnect):
|
|
with client.websocket_connect("/ws/session/rate") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
for _ in range(app_mod._WS_RATE_LIMIT_COUNT):
|
|
ws.send_json({"type": "unknown"})
|
|
assert ws.receive_json()["type"] == "input_rejected"
|
|
ws.send_json({"type": "unknown"})
|
|
assert ws.receive_json()["type"] == "input_rejected"
|
|
ws.receive_json()
|
|
|
|
|
|
def test_server_sets_explicit_websocket_frame_limit(tmp_path, monkeypatch):
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
from coworker.server import run as server_run
|
|
|
|
seen = {}
|
|
fake_app = object()
|
|
|
|
monkeypatch.setattr(server_run, "_ensure_ca_bundle", lambda: None)
|
|
monkeypatch.setattr(server_run, "_exit_when_orphaned", lambda: None)
|
|
monkeypatch.setattr(server_run, "build_app", lambda *args: fake_app)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"uvicorn",
|
|
SimpleNamespace(run=lambda app, **kwargs: seen.update(app=app, **kwargs)),
|
|
)
|
|
|
|
server_run.main(["--cwd", str(tmp_path), "--port", "8766"])
|
|
|
|
assert seen["app"] is fake_app
|
|
assert seen["ws_max_size"] == server_run._WS_MAX_FRAME_BYTES
|
|
|
|
|
|
def test_standalone_server_token_file_is_user_only(tmp_path, monkeypatch):
|
|
import os
|
|
|
|
from coworker.server import run as server_run
|
|
|
|
monkeypatch.delenv("COWORKER_API_TOKEN", raising=False)
|
|
path = server_run._ensure_api_token(9876)
|
|
try:
|
|
assert path == tmp_path / "coworker-state" / "sidecar-9876.token"
|
|
assert path.read_text().strip() == os.environ["COWORKER_API_TOKEN"]
|
|
assert len(path.read_text().strip()) == 64
|
|
assert (path.stat().st_mode & 0o777) == 0o600
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
os.environ.pop("COWORKER_API_TOKEN", None)
|
|
|
|
|
|
def test_ws_error_persists_notice_and_retry_reruns(tmp_path):
|
|
class FlakyProvider(ProviderClient):
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise RuntimeError("outage")
|
|
return _text("recovered")
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
manager = SessionManager(workspace=tmp_path, provider=FlakyProvider())
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/flaky") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "hello"})
|
|
assert "error" in _drain(ws)
|
|
# The error survives as a persisted notice (reload shows what happened)…
|
|
messages = client.get("/v1/sessions/flaky/messages").json()["messages"]
|
|
assert messages[-1]["role"] == "notice" and messages[-1]["kind"] == "error"
|
|
# …and retry re-runs the turn without a new user message.
|
|
ws.send_json({"type": "retry"})
|
|
types = _drain(ws)
|
|
assert "turn_start" in types and "assistant_message" in types
|
|
messages = client.get("/v1/sessions/flaky/messages").json()["messages"]
|
|
assert messages[-1]["role"] == "assistant" and messages[-1]["content"] == "recovered"
|
|
assert sum(1 for m in messages if m["role"] == "user") == 1
|
|
|
|
|
|
# -- origin gate (local-API hardening): a browser page on a foreign origin must not be able to
|
|
# read the API cross-origin or open the driving WebSocket. -------------------------------------
|
|
|
|
|
|
def test_cors_rejects_foreign_origin(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
# A random website's origin gets no ACAO header, so the browser blocks the read.
|
|
resp = client.get("/v1/sessions", headers={"Origin": "https://evil.example"})
|
|
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
|
# The desktop webview's own origin is allowed.
|
|
ok = client.get("/v1/sessions", headers={"Origin": "tauri://localhost"})
|
|
assert ok.headers.get("access-control-allow-origin") == "tauri://localhost"
|
|
# Localhost dev/browser build is allowed too.
|
|
dev = client.get("/v1/sessions", headers={"Origin": "http://localhost:1420"})
|
|
assert dev.headers.get("access-control-allow-origin") == "http://localhost:1420"
|
|
|
|
|
|
def test_ws_rejects_foreign_origin(tmp_path):
|
|
from starlette.websockets import WebSocketDisconnect as WSD
|
|
|
|
client = _client(tmp_path, [_text("hi")])
|
|
with pytest.raises(WSD) as e:
|
|
with client.websocket_connect(
|
|
"/ws/session/x", headers={"Origin": "https://evil.example"}
|
|
) as ws:
|
|
ws.receive_json()
|
|
assert e.value.code == 1008
|
|
|
|
|
|
def test_ws_allows_webview_origin(tmp_path):
|
|
client = _client(tmp_path, [_text("hi")])
|
|
with client.websocket_connect(
|
|
"/ws/session/x", headers={"Origin": "http://tauri.localhost"}
|
|
) as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
|
|
|
|
def test_sidecar_token_gates_rest_and_websockets(tmp_path, monkeypatch):
|
|
from coworker.mcp.config import global_mcp_path
|
|
from starlette.websockets import WebSocketDisconnect as WSD
|
|
|
|
monkeypatch.setenv("COWORKER_API_TOKEN", "a" * 64)
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
client = TestClient(create_app(manager))
|
|
|
|
assert client.get("/v1/health").json() == {"status": "ok"}
|
|
assert client.get("/v1/sessions").status_code == 401
|
|
assert client.get(
|
|
"/v1/sessions", headers={"X-OpenWorker-Token": "wrong"}
|
|
).status_code == 401
|
|
|
|
headers = {"X-OpenWorker-Token": "a" * 64}
|
|
assert client.get("/v1/health", headers=headers).json()[
|
|
"default_workspace"
|
|
] == str(tmp_path.resolve())
|
|
assert client.get("/v1/sessions", headers=headers).status_code == 200
|
|
|
|
rejected = client.post(
|
|
"/v1/mcp",
|
|
json={"name": "evil", "config": {"command": "sh", "args": ["-c", "id"]}},
|
|
)
|
|
assert rejected.status_code == 401
|
|
assert not global_mcp_path().exists()
|
|
|
|
with pytest.raises(WSD) as denied:
|
|
with client.websocket_connect("/ws/session/tokenless") as ws:
|
|
ws.receive_json()
|
|
assert denied.value.code == 1008
|
|
|
|
with client.websocket_connect(
|
|
"/ws/session/authed", subprotocols=["openworker", "a" * 64]
|
|
) as ws:
|
|
assert ws.accepted_subprotocol == "openworker"
|
|
assert ws.receive_json()["type"] == "ready"
|
|
|
|
with client.websocket_connect(
|
|
"/ws/events", subprotocols=["openworker", "a" * 64]
|
|
) as ws:
|
|
assert ws.accepted_subprotocol == "openworker"
|
|
|
|
# Redirect callbacks remain tokenless, then enforce their own signed state.
|
|
assert client.get(
|
|
"/auth/callback", params={"code": "x", "state": "bad"}
|
|
).status_code == 400
|
|
assert client.get("/mcp/oauth/callback").status_code == 400
|
|
assert client.post("/oauth/callback", data={"app_state": "bad"}).status_code == 400
|
|
|
|
|
|
def test_ws_approval_round_trip(tmp_path):
|
|
client = _client(
|
|
tmp_path,
|
|
[
|
|
_tool("write_file", {"path": "made.py", "content": "print(1)\n"}),
|
|
_text("wrote it"),
|
|
],
|
|
)
|
|
with client.websocket_connect("/ws/session/s2") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "create made.py"})
|
|
types = _drain(ws, on_permission="once")
|
|
assert "permission_required" in types
|
|
assert "tool_finished" in types
|
|
assert (tmp_path / "made.py").read_text() == "print(1)\n"
|
|
|
|
|
|
def test_ws_session_persisted_while_parked_on_approval(tmp_path):
|
|
"""A crash mid-turn must not eat the conversation: by the time the engine parks on an
|
|
approval, the session (user message + assistant tool call) is already on disk."""
|
|
manager = SessionManager(
|
|
workspace=tmp_path,
|
|
provider=ScriptedProvider(
|
|
[_tool("write_file", {"path": "x.py", "content": "1\n"}), _text("done")]
|
|
),
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/persist1") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "make x.py"})
|
|
while ws.receive_json()["type"] != "permission_required":
|
|
pass
|
|
# Parked on the approval — nothing approved, turn far from done. Already saved?
|
|
rec = manager.session_store.load("persist1")
|
|
assert rec is not None
|
|
roles = [m.get("role") for m in rec.messages]
|
|
assert "user" in roles # turn_start checkpoint
|
|
assert "assistant" in roles # iteration progress checkpoint
|
|
ws.send_json({"type": "approval", "decision": "deny"})
|
|
while ws.receive_json()["type"] != "turn_done":
|
|
pass
|
|
|
|
|
|
def test_ws_browser_tool_audit_round_trip(tmp_path):
|
|
client = _client(tmp_path, [_tool("browser_close", {}), _text("closed")])
|
|
with client.websocket_connect("/ws/session/browser-audit?agent=cowork") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "close browser"})
|
|
types = _drain(ws, on_permission="once")
|
|
assert "permission_required" in types
|
|
assert "tool_finished" in types
|
|
|
|
rows = client.get(
|
|
"/v1/audit", params={"session_id": "browser-audit", "connector": "browser"}
|
|
).json()["events"]
|
|
assert any(
|
|
r["tool"] == "browser_close" and r["stage"] == "approval_resolved" for r in rows
|
|
)
|
|
assert any(r["tool"] == "browser_close" and r["stage"] == "finished" for r in rows)
|
|
|
|
|
|
def test_open_and_recent_workspaces(tmp_path):
|
|
proj = tmp_path / "proj"
|
|
proj.mkdir()
|
|
client = _client(tmp_path, [])
|
|
opened = client.post("/v1/workspaces/open", json={"path": str(proj)}).json()
|
|
assert opened["ok"] is True
|
|
recents = client.get("/v1/workspaces/recent").json()["workspaces"]
|
|
assert any(w["path"] == str(proj.resolve()) for w in recents)
|
|
|
|
|
|
def test_workspace_command_trust_controls_live_engine(tmp_path):
|
|
from urllib.parse import quote
|
|
|
|
proj = tmp_path / "trusted-project"
|
|
(proj / ".coworker").mkdir(parents=True)
|
|
(proj / ".coworker" / "config.toml").write_text(
|
|
'allowed_commands = ["pytest"]\nauto_allow = ["write_file"]\n'
|
|
)
|
|
manager = SessionManager(
|
|
workspace=None, data_dir=tmp_path / "data", provider=ScriptedProvider([])
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
|
|
with client.websocket_connect(
|
|
f"/ws/session/trust?workspace={quote(str(proj))}"
|
|
) as ws:
|
|
ready = ws.receive_json()
|
|
policy = ready["data"]["command_trust"]
|
|
assert policy["required"] is True
|
|
assert policy["requested_commands"] == ["pytest"]
|
|
|
|
engine = manager._engines["trust"]
|
|
before = engine.permissions.evaluate(
|
|
"run_shell", {"command": "pytest -q"}, None
|
|
)
|
|
assert not before.allowed and before.needs_user
|
|
# Workspace auto_allow remains ignored even after command trust.
|
|
assert "write_file" not in engine.permissions.auto_allow_tools
|
|
|
|
trusted = client.post(
|
|
"/v1/workspaces/trust",
|
|
json={"path": str(proj), "trusted": True},
|
|
).json()
|
|
assert trusted["ok"] and trusted["trusted"]
|
|
assert engine.permissions.evaluate(
|
|
"run_shell", {"command": "pytest -q"}, None
|
|
).allowed
|
|
|
|
listed = client.get("/v1/workspaces/trusted").json()["workspaces"]
|
|
assert [item["workspace"] for item in listed] == [str(proj.resolve())]
|
|
|
|
revoked = client.post(
|
|
"/v1/workspaces/trust",
|
|
json={"path": str(proj), "trusted": False},
|
|
).json()
|
|
assert revoked["ok"] and not revoked["trusted"]
|
|
after = engine.permissions.evaluate(
|
|
"run_shell", {"command": "pytest -q"}, None
|
|
)
|
|
assert not after.allowed and after.needs_user
|
|
|
|
manager.workspace_trust.set_trusted(proj, True)
|
|
proj.rename(tmp_path / "moved-project")
|
|
assert client.post(
|
|
"/v1/workspaces/trust",
|
|
json={"path": str(proj), "trusted": False},
|
|
).json()["ok"]
|
|
assert manager.trusted_workspaces() == []
|
|
|
|
|
|
def test_recent_workspaces_exclude_scratch_dirs(tmp_path):
|
|
# Scratch dirs get touched like any workspace, but must never show up as
|
|
# "recent projects" in the folder gate (owner call, 2026-07-03).
|
|
from coworker.server.manager import SessionManager
|
|
|
|
proj = tmp_path / "real-project"
|
|
proj.mkdir()
|
|
mgr = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
mgr._prefs["scratch_base"] = str(tmp_path / "scratch")
|
|
scratch = mgr._provision_scratch("sess-1")
|
|
mgr.session_store.touch_workspace(str(proj.resolve()))
|
|
mgr.session_store.touch_workspace(scratch)
|
|
paths = [w["path"] for w in mgr.recent_workspaces()]
|
|
assert str(proj.resolve()) in paths
|
|
assert scratch not in paths
|
|
|
|
|
|
def test_delete_session_removes_its_scratch_dir_only(tmp_path):
|
|
# Deleting a session also deletes its per-conversation scratch dir (owner call,
|
|
# 2026-07-03) — but NEVER a real project folder the user picked.
|
|
from pathlib import Path
|
|
|
|
from coworker.server.manager import SessionManager
|
|
from coworker.sessions import SessionRecord
|
|
|
|
mgr = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
mgr._prefs["scratch_base"] = str(tmp_path / "scratch")
|
|
|
|
scratch = Path(mgr._provision_scratch("sess-scratch"))
|
|
mgr.session_store.save(
|
|
SessionRecord(
|
|
session_id="sess-scratch",
|
|
workspace=str(scratch),
|
|
model="m",
|
|
mode="interactive",
|
|
)
|
|
)
|
|
assert mgr.delete_session("sess-scratch")["ok"]
|
|
assert not scratch.exists()
|
|
|
|
proj = tmp_path / "real-project"
|
|
proj.mkdir()
|
|
mgr.session_store.save(
|
|
SessionRecord(
|
|
session_id="sess-proj", workspace=str(proj), model="m", mode="interactive"
|
|
)
|
|
)
|
|
assert mgr.delete_session("sess-proj")["ok"]
|
|
assert proj.is_dir() # user folders are sacred
|
|
|
|
|
|
def test_open_invalid_workspace(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
bad = client.post(
|
|
"/v1/workspaces/open", json={"path": str(tmp_path / "nope")}
|
|
).json()
|
|
assert bad["ok"] is False
|
|
|
|
|
|
def test_open_workspace_create(tmp_path):
|
|
client = _client(tmp_path, [])
|
|
fresh = tmp_path / "fresh-project"
|
|
assert not fresh.exists()
|
|
res = client.post(
|
|
"/v1/workspaces/open", json={"path": str(fresh), "create": True}
|
|
).json()
|
|
assert res["ok"] is True
|
|
assert fresh.is_dir()
|
|
|
|
|
|
def test_ws_requires_workspace_when_no_default(tmp_path):
|
|
# Manager with no default workspace: a session with no folder is rejected.
|
|
manager = SessionManager(
|
|
workspace=None, data_dir=tmp_path, provider=ScriptedProvider([])
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/nofolder") as ws:
|
|
first = ws.receive_json()
|
|
assert first["type"] == "error"
|
|
assert "workspace" in first["data"]["error"]
|
|
|
|
|
|
def test_ws_with_workspace_query(tmp_path):
|
|
from urllib.parse import quote
|
|
|
|
proj = tmp_path / "proj"
|
|
proj.mkdir()
|
|
manager = SessionManager(
|
|
workspace=None,
|
|
data_dir=tmp_path,
|
|
provider=ScriptedProvider([_text("hi from proj")]),
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect(f"/ws/session/s?workspace={quote(str(proj))}") as ws:
|
|
ready = ws.receive_json()
|
|
assert ready["type"] == "ready"
|
|
assert ready["data"]["workspace"] == str(proj.resolve())
|
|
ws.send_json({"type": "user_message", "text": "hello"})
|
|
assert "turn_end" in _drain(ws)
|
|
|
|
|
|
def test_ws_removed_agent_id_falls_back_to_default(tmp_path):
|
|
# Chat is removed (owner 2026-08-21): a stored session or deep link carrying
|
|
# agent=chat resolves to the default persona instead of erroring.
|
|
manager = SessionManager(
|
|
workspace=None,
|
|
data_dir=tmp_path,
|
|
provider=ScriptedProvider([_text("hi")]),
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/chat1?agent=chat") as ws:
|
|
ready = ws.receive_json()
|
|
assert ready["type"] == "ready"
|
|
assert ready["data"]["agent"] == "cowork"
|
|
ws.send_json({"type": "user_message", "text": "hello"})
|
|
assert "turn_end" in _drain(ws)
|
|
|
|
|
|
def test_ws_set_mode_auto_skips_approval(tmp_path):
|
|
from urllib.parse import quote
|
|
|
|
proj = tmp_path / "proj"
|
|
proj.mkdir()
|
|
manager = SessionManager(
|
|
workspace=None,
|
|
data_dir=tmp_path,
|
|
provider=ScriptedProvider(
|
|
[_tool("write_file", {"path": "a.py", "content": "x"}), _text("done")]
|
|
),
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect(f"/ws/session/sm?workspace={quote(str(proj))}") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "set_mode", "mode": "auto"})
|
|
ws.send_json({"type": "user_message", "text": "write a.py"})
|
|
types = _drain(ws) # no approval handler — would hang if it asked
|
|
assert "permission_required" not in types
|
|
assert (proj / "a.py").read_text() == "x"
|
|
|
|
|
|
def test_ws_session_resume_via_store(tmp_path):
|
|
# First connection runs a turn and persists the session.
|
|
client = _client(tmp_path, [_text("first answer")])
|
|
with client.websocket_connect("/ws/session/keep") as ws:
|
|
ws.receive_json()
|
|
ws.send_json({"type": "user_message", "text": "remember this"})
|
|
_drain(ws)
|
|
# The session is now listed via REST.
|
|
sessions = client.get("/v1/sessions").json()["sessions"]
|
|
assert any(s["session_id"] == "keep" and s["messages"] > 0 for s in sessions)
|
|
|
|
|
|
def test_ws_first_message_binds_then_midsession_switch_persists_notice(tmp_path):
|
|
"""The FIRST user_message's model binds the session silently (race-proof across
|
|
reconnects — found 2026-07-04). Mid-session rebinds are ALLOWED (roadmap item 3,
|
|
2026-07-22, supersedes the 07-04 lock): the switch lands as a persisted model_switch
|
|
notice and a model_changed broadcast, and the next turn runs on the new model."""
|
|
# 4 turns: 3 user turns + the autotitle's fire-and-forget complete() after turn 1.
|
|
client = _client(
|
|
tmp_path, [_text("ok"), _text("Session title"), _text("ok again"), _text("still ok")]
|
|
)
|
|
with client.websocket_connect("/ws/session/model-per-msg") as ws:
|
|
ready = ws.receive_json()
|
|
assert ready["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "hi", "model": "zai:glm-5.2"})
|
|
assert "model_changed" not in _drain(ws) # first bind is silent
|
|
# message WITHOUT a model keeps the bound one (no silent reset to default)
|
|
ws.send_json({"type": "user_message", "text": "again"})
|
|
_drain(ws)
|
|
ws.send_json({"type": "set_model", "model": "kimi:kimi-k2.6"})
|
|
changed = ws.receive_json()
|
|
assert changed["type"] == "model_changed"
|
|
assert changed["data"]["model"] == "kimi:kimi-k2.6"
|
|
assert "Kimi" in changed["data"]["text"]
|
|
ws.send_json({"type": "user_message", "text": "switched now"})
|
|
_drain(ws)
|
|
mgr = client.app.state.manager
|
|
engine = mgr._engines["model-per-msg"]
|
|
assert engine.model == "kimi:kimi-k2.6"
|
|
# The marker is persisted between the turns; the provider never sees it.
|
|
messages = client.get("/v1/sessions/model-per-msg/messages").json()["messages"]
|
|
notices = [m for m in messages if m["role"] == "notice"]
|
|
assert [n["kind"] for n in notices] == ["model_switch"]
|
|
assert all(m.get("role") != "notice" for m in engine._outbound_messages())
|
|
|
|
|
|
def test_session_messages_prefers_the_live_engine(tmp_path):
|
|
"""Opening a RUNNING session (e.g. a scheduled automation's first turn) must show the live
|
|
conversation: the persisted record may not exist yet mid-turn — reading only the store gave
|
|
a blank transcript on first open (owner report, 2026-07-04)."""
|
|
client = _client(tmp_path, [_text("ok")])
|
|
mgr = client.app.state.manager
|
|
engine = mgr.get_engine("__run__live", agent="chat")
|
|
engine.messages.append({"role": "user", "content": "hi from a running automation"})
|
|
|
|
msgs = client.get("/v1/sessions/__run__live/messages").json()["messages"]
|
|
assert any(m.get("content") == "hi from a running automation" for m in msgs)
|
|
|
|
|
|
def test_pick_native_folder_paths(tmp_path, monkeypatch):
|
|
"""The sidecar-side folder picker (for browser GUIs): picked path round-trips; cancel and
|
|
missing-picker degrade to ok:False without raising."""
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
|
|
client = _client(tmp_path, [])
|
|
mgr = client.app.state.manager
|
|
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
lambda *a, **k: SimpleNamespace(
|
|
returncode=0, stdout="/tmp/picked\n", stderr=""
|
|
),
|
|
)
|
|
assert client.post("/v1/workspaces/pick").json() == {
|
|
"ok": True,
|
|
"path": "/tmp/picked",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
lambda *a, **k: SimpleNamespace(
|
|
returncode=1, stdout="", stderr="User canceled."
|
|
),
|
|
)
|
|
assert client.post("/v1/workspaces/pick").json()["ok"] is False
|
|
|
|
def boom(*a, **k):
|
|
raise OSError("no zenity")
|
|
|
|
monkeypatch.setattr(subprocess, "run", boom)
|
|
out = mgr.pick_native_folder()
|
|
assert out["ok"] is False and "picker" in out["error"]
|
|
|
|
|
|
def test_provider_set_and_remove_roundtrip(tmp_path):
|
|
"""Settings ▸ Models "Remove key": DELETE /v1/providers/{name} forgets the stored
|
|
profile so the provider reads unconfigured again; unknown names are a clean error.
|
|
"""
|
|
client = _client(tmp_path, [])
|
|
assert client.post(
|
|
"/v1/providers", json={"name": "zai", "fields": {"api_key": "zk-test"}}
|
|
).json()["ok"]
|
|
prov = {p["name"]: p for p in client.get("/v1/providers").json()}
|
|
assert prov["zai"]["configured"] and prov["zai"]["key_set_at"]
|
|
|
|
assert client.delete("/v1/providers/zai").json()["ok"]
|
|
prov = {p["name"]: p for p in client.get("/v1/providers").json()}
|
|
assert not prov["zai"]["configured"]
|
|
assert not prov["zai"]["key_set_at"]
|
|
|
|
assert not client.delete("/v1/providers/nope").json()["ok"]
|
|
|
|
|
|
def test_always_allow_grants_survive_restart(tmp_path):
|
|
""""Always allow" is session-scoped, and the session outlives the process — a restart
|
|
(fresh manager over the same store) must not re-ask for an approved command
|
|
(owner-hit 2026-07-22 on the 0.1.6 walkthrough)."""
|
|
|
|
def _shell_turns():
|
|
return ScriptedProvider(
|
|
[
|
|
_tool("run_shell", {"command": "uname -a"}, call_id="c1"),
|
|
_text("done"),
|
|
]
|
|
)
|
|
|
|
def _run_turn(client, expect_prompts):
|
|
with client.websocket_connect("/ws/session/grants1?agent=cowork") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "user_message", "text": "run it"})
|
|
asked = 0
|
|
while True:
|
|
ev = ws.receive_json()
|
|
if ev["type"] == "permission_required":
|
|
asked += 1
|
|
ws.send_json({"type": "approval", "decision": "always_command"})
|
|
if ev["type"] == "turn_done":
|
|
break
|
|
assert asked == expect_prompts
|
|
|
|
mgr = SessionManager(workspace=None, provider=_shell_turns())
|
|
_run_turn(TestClient(create_app(mgr)), expect_prompts=1)
|
|
|
|
# "Restart": new manager + engine rebuilt from the persisted record.
|
|
mgr2 = SessionManager(workspace=None, provider=_shell_turns())
|
|
_run_turn(TestClient(create_app(mgr2)), expect_prompts=0)
|
|
|
|
|
|
def test_google_one_click_paused_but_manual_alive(tmp_path):
|
|
"""CASA verification pending: Gmail/Calendar/Drive expose managed_paused (GUI badges
|
|
"Coming soon"), the managed-connect route refuses, and the manual fields stay."""
|
|
client = _client(tmp_path, [])
|
|
connectors = {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}
|
|
for name in ("gmail", "google_calendar", "google_drive"):
|
|
c = connectors[name]
|
|
assert c["managed"] is True and c["managed_paused"] is True
|
|
assert c["fields"], f"{name} lost its manual fields"
|
|
assert connectors["slack"]["managed_paused"] is False # only Google is paused
|
|
|
|
refused = client.post("/v1/connectors/gmail/connect-managed", json={}).json()
|
|
assert refused["ok"] is False and "coming soon" in refused["error"]
|
|
|
|
|
|
def test_set_provider_persists_extra_fields(tmp_path):
|
|
"""Non-secret descriptor extras (ollama's endpoint) round-trip: saved into the
|
|
profile, echoed by get_providers for form prefill, cleared by an empty save."""
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
|
|
assert manager.set_provider("ollama", {"base_url": "http://127.0.0.1:9999"})["ok"]
|
|
providers = {p["name"]: p for p in manager.get_providers()}
|
|
assert providers["ollama"]["values"]["base_url"] == "http://127.0.0.1:9999"
|
|
|
|
manager.set_provider("ollama", {"base_url": ""})
|
|
providers = {p["name"]: p for p in manager.get_providers()}
|
|
assert "base_url" not in providers["ollama"]["values"]
|
|
|
|
|
|
def test_mcp_connect_route_flags_authorizing_immediately(tmp_path, monkeypatch):
|
|
"""Owner-hit 2026-08-21: the Test button looked dead — the connect ran as a
|
|
background task, and the GUI's first refresh landed before the task set
|
|
`authorizing`, so the fast poll never armed. The route must flag it
|
|
synchronously (and only for known servers, so nothing wedges)."""
|
|
import asyncio
|
|
|
|
from coworker.server import SessionManager
|
|
|
|
mgr = SessionManager(data_dir=tmp_path / "data")
|
|
monkeypatch.setattr(
|
|
"coworker.server.manager.read_global", lambda: {"sales-db": {"command": "x"}}
|
|
)
|
|
mgr.begin_mcp_connect("sales-db")
|
|
assert "sales-db" in mgr._mcp_authorizing
|
|
mgr.begin_mcp_connect("nope")
|
|
assert "nope" not in mgr._mcp_authorizing
|
|
# An unmatched name clears the flag instead of wedging "Testing…" forever.
|
|
monkeypatch.setattr("coworker.server.manager.load_mcp_servers", lambda *a, **k: [])
|
|
res = asyncio.run(mgr.connect_mcp("sales-db"))
|
|
assert not res["ok"] and "sales-db" not in mgr._mcp_authorizing
|
|
|
|
|
|
def test_ws_ready_reports_live_turn(tmp_path):
|
|
# A reconnect can land mid-turn (sidebar revisit, relaunch, dropped socket). `ready`
|
|
# must carry server truth on the running turn or the GUI loses Stop + the waiting row
|
|
# (owner catch 2026-08-24, v0.2.0 walkthrough).
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([_text("hi")]))
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/live1") as ws:
|
|
assert ws.receive_json()["data"]["running"] is False
|
|
|
|
manager.mark_running("live1")
|
|
try:
|
|
with client.websocket_connect("/ws/session/live1") as ws:
|
|
assert ws.receive_json()["data"]["running"] is True
|
|
finally:
|
|
manager.mark_idle("live1")
|
|
|
|
|
|
def test_set_mode_persists_notice_once_then_markers(tmp_path):
|
|
# Owner ruling 2026-08-24: the Auto-Approve explainer is server-authored and persisted
|
|
# ONCE per session; later switches persist one-line markers. Restarts re-show nothing.
|
|
manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([_text("hi")]))
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/modes1") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "set_mode", "mode": "auto-approve"})
|
|
ev = ws.receive_json()
|
|
assert ev["type"] == "mode_notice"
|
|
assert ev["data"]["title"] == "Auto-approve is on."
|
|
assert "uses a model" in ev["data"]["text"]
|
|
ws.send_json({"type": "set_mode", "mode": "interactive"})
|
|
assert ws.receive_json()["data"] == {"text": "Ask for approval is on."}
|
|
# Re-entering auto-approve: marker, never the banner again.
|
|
ws.send_json({"type": "set_mode", "mode": "auto-approve"})
|
|
assert ws.receive_json()["data"] == {"text": "Auto-approve is on."}
|
|
|
|
engine = manager._engines["modes1"]
|
|
kinds = [m.get("kind") for m in engine.messages if m.get("role") == "notice"]
|
|
assert kinds.count("mode_notice") == 1
|
|
assert kinds.count("mode_switch") == 2
|
|
|
|
|
|
def test_connect_banners_a_session_already_in_auto_approve(tmp_path):
|
|
from coworker.permissions import Mode
|
|
|
|
manager = SessionManager(
|
|
workspace=tmp_path,
|
|
provider=ScriptedProvider([_text("hi")]),
|
|
mode=Mode("auto-approve"),
|
|
)
|
|
client = TestClient(create_app(manager))
|
|
with client.websocket_connect("/ws/session/modes2") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ev = ws.receive_json()
|
|
assert ev["type"] == "mode_notice" and ev["data"]["title"] == "Auto-approve is on."
|
|
# A reconnect stays quiet: the banner is persisted (asserted below), not re-announced —
|
|
# a set_mode echo of the SAME mode also stays silent (previous is new_mode).
|
|
with client.websocket_connect("/ws/session/modes2") as ws:
|
|
assert ws.receive_json()["type"] == "ready"
|
|
ws.send_json({"type": "set_mode", "mode": "interactive"})
|
|
assert ws.receive_json()["data"] == {"text": "Ask for approval is on."}
|
|
engine = manager._engines["modes2"]
|
|
kinds = [m.get("kind") for m in engine.messages if m.get("role") == "notice"]
|
|
assert kinds.count("mode_notice") == 1
|