From 1032e3a6648a94e6c289a3189be53fef7478e016 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Wed, 22 Jul 2026 23:32:14 -0700 Subject: [PATCH] Persist Always-allow grants with the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grants were in-memory only — every restart re-asked for approved tools/commands. Saved on the session record, re-applied on engine rebuild. --- coworker/conversations.py | 19 ++++++++++++++++--- coworker/server/manager.py | 25 ++++++++++++++++++++++--- coworker/sessions.py | 4 ++++ tests/test_server.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/coworker/conversations.py b/coworker/conversations.py index 239578f3..fd2131bf 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -30,6 +30,16 @@ def _load_roots(raw: Optional[str]) -> list[dict]: return value if isinstance(value, list) else [] +def _load_grants(raw: Optional[str]) -> dict: + if not raw: + return {} + try: + value = json.loads(raw) + except json.JSONDecodeError: + return {} + return value if isinstance(value, dict) else {} + + def _display_title(row: sqlite3.Row) -> Optional[str]: """Title precedence for every read path: a manual rename (renamed=1) always wins, then the generated auto_title, then the first-line snapshot `save()` wrote.""" @@ -84,6 +94,7 @@ class ConversationStore: "ALTER TABLE sessions ADD COLUMN origin_label TEXT", "ALTER TABLE sessions ADD COLUMN auto_title TEXT", "ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0", + "ALTER TABLE sessions ADD COLUMN grants TEXT", ): try: self._conn.execute(ddl) @@ -180,13 +191,13 @@ class ConversationStore: title = record.title or title_from(record.messages) self._conn.execute( """ - INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, CURRENT_TIMESTAMP) + INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET workspace = excluded.workspace, model = excluded.model, mode = excluded.mode, title = COALESCE(sessions.title, excluded.title), agent = excluded.agent, n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots, - updated_at = CURRENT_TIMESTAMP + grants = excluded.grants, updated_at = CURRENT_TIMESTAMP """, ( sid, @@ -197,6 +208,7 @@ class ConversationStore: record.agent, len(record.messages), json.dumps(record.extra_roots or []), + json.dumps(record.grants or {}), ), ) self._conn.commit() @@ -228,6 +240,7 @@ class ConversationStore: extra_roots=_load_roots( row["extra_roots"] if "extra_roots" in row.keys() else None ), + grants=_load_grants(row["grants"] if "grants" in row.keys() else None), pinned=bool(row["pinned"]), archived=bool(row["archived"]), origin=row["origin"], diff --git a/coworker/server/manager.py b/coworker/server/manager.py index fd7b7622..2501e9b9 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -85,6 +85,13 @@ _SCOPES = {s.value for s in Scope} logger = logging.getLogger("coworker.manager") +def _grants_of(engine) -> dict[str, Any]: + """The engine's session-scoped "Always allow" approvals, in persistable shape.""" + tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ()) + commands = sorted(getattr(engine.permissions, "session_allow_commands", None) or ()) + return {"tools": tools, "commands": commands} if (tools or commands) else {} + + def _approval_body(request) -> str: """Approval card body: the tool's reason (if any) plus a compact preview of its args, so a mirrored 'Run `write_file`?' shows the path/content rather than just the tool name. @@ -384,6 +391,8 @@ class SessionManager: engine.permissions.task_rules.setdefault("send_message", set()).add( thread_target ) + if record is not None and record.grants: + self._apply_grants(engine, record.grants) self._engines[session_id] = engine if is_new_session: self._emit_session_created(session_id, agent_name) @@ -2393,7 +2402,7 @@ class SessionManager: else: await self.gateway.deliver( target, - f"{body}\n(Open the app to respond.)\n[ocw:{item.id}]".strip(), + f"{body}\n(Open the app to respond.)\n[ow:{item.id}]".strip(), ) except Exception: pass @@ -2429,7 +2438,7 @@ class SessionManager: # -- inbox replies over messaging connectors -------------------------------- def _resolve_inbox_reply(self, event) -> bool: """Try to handle an inbound Slack/Telegram message as an Inbox reply. Returns True if the - message carried an `[ocw:]` token (so it's consumed here, not routed as a new turn) — + message carried an `[ow:]` token (so it's consumed here, not routed as a new turn) — resolving the item also releases any agent suspended on it.""" from ..inbox_routing import resolve_from_reply @@ -2591,7 +2600,7 @@ class SessionManager: # -- mention router (§31) ---------------------------------------------------- async def _route_mention(self, event, ms: MessageSource, subs) -> None: - """@ocw tagged in a channel. A subscribed (user-connected) coworker owns the channel + """@OpenWorker tagged in a channel. A subscribed (user-connected) coworker owns the channel and must answer; otherwise the per-thread coworker session handles it — spawned on the first tag, steered by follow-ups (deduped on the thread target).""" from ..connectors.base import format_target @@ -2975,9 +2984,19 @@ class SessionManager: title=title_from(engine.messages), agent=getattr(engine, "agent_name", "code"), extra_roots=self._extra_roots_of(engine), + grants=_grants_of(engine), ) ) + @staticmethod + def _apply_grants(engine: TurnEngine, grants: dict[str, Any]) -> None: + """Re-apply a reloaded session's persisted "Always allow" approvals — they're + session-scoped, and the session outlives the process (owner-hit 2026-07-22).""" + for tool in grants.get("tools") or []: + engine.permissions.allow_tool_for_session(str(tool)) + for command in grants.get("commands") or []: + engine.permissions.allow_command_for_session(str(command)) + @staticmethod def _extra_roots_of(engine: TurnEngine) -> list[dict[str, Any]]: """Added folders = the engine's roots minus the primary scratch (index 0).""" diff --git a/coworker/sessions.py b/coworker/sessions.py index a1395fa1..cc6c4cf5 100644 --- a/coworker/sessions.py +++ b/coworker/sessions.py @@ -24,6 +24,10 @@ class SessionRecord: # Folders added to the session beyond its primary scratch dir, each {path, writable, label}. # The primary scratch is re-provisioned at engine build, so only these extras are persisted. extra_roots: list[dict[str, Any]] = field(default_factory=list) + # "Always allow" approvals granted in this session ({tools: [...], commands: [...]}) — + # session-scoped by design, but the session outlives the process, so they must too + # (owner-hit 2026-07-22: grants forgotten on every restart). + grants: dict[str, Any] = field(default_factory=dict) pinned: bool = False archived: bool = False # Where the session came from, when not user-started (§31): machine key + display label diff --git a/tests/test_server.py b/tests/test_server.py index 686fdb47..b1605ea7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -716,3 +716,38 @@ def test_provider_set_and_remove_roundtrip(tmp_path): 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)