Files
openworker/tests/test_server.py
T
Rohit C Prasad eae1ed315f Auto-title at turn start, not turn completion
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.
2026-08-24 15:12:56 -07:00

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