diff --git a/coworker/connectors/setup.py b/coworker/connectors/setup.py index 6280f6f0..0a987f4a 100644 --- a/coworker/connectors/setup.py +++ b/coworker/connectors/setup.py @@ -184,6 +184,10 @@ def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]: "domain": profile.get("domain") or "", "allowed_users": list(profile.get("allowed_users") or []), "allow_all": bool(profile.get("allow_all")), + # Who installed (authed_user) — the GUI marks their chip "you" and + # keys the post-connect card's "your mentions get through" line. + "installer_user_id": profile.get("slack_user_id") or "", + "installer_name": profile.get("sender_name") or "", } for team_id, profile in sorted( _slack_team_profiles(secrets), key=lambda t: t[0] @@ -414,6 +418,15 @@ def managed_connect_slack_install( bot_token = form.get("access_token", "") if not team_id or not bot_token: return {"ok": False, "error": "missing team_id or bot token"} + # A reinstall replaces the token but must not reset authorization state. + existing = secrets.get(f"slack:team:{team_id}") or {} + allowed = set(existing.get("allowed_users") or []) + installer = form.get("slack_user_id", "") + if installer: + # Pre-add the installer (UX-027): connecting the workspace is consent to + # talk to your own bot — without this, the connector's very first mention + # comes from the installer and parks. + allowed.add(installer) secrets.put( f"slack:team:{team_id}", { @@ -423,7 +436,7 @@ def managed_connect_slack_install( "bot_user_id": form.get("bot_user_id", ""), # The INSTALLER's Slack member id (authed_user) — who this workspace's # outbound posts speak for (attribution.py resolves + caches the name). - "slack_user_id": form.get("slack_user_id", ""), + "slack_user_id": installer, "team_id": team_id, "account": form.get("account", ""), # The workspace's slack.com subdomain (broker resolves it via auth.test) @@ -431,6 +444,9 @@ def managed_connect_slack_install( "domain": form.get("team_domain", ""), "scope": form.get("scope", ""), "connection_id": form.get("connection_id", ""), + "allowed_users": sorted(allowed), + "allow_all": bool(existing.get("allow_all")), + "sender_name": existing.get("sender_name", ""), }, ) default = secrets.get("slack:default") or {} diff --git a/coworker/mcp/client.py b/coworker/mcp/client.py index 2663e795..04f8fcc0 100644 --- a/coworker/mcp/client.py +++ b/coworker/mcp/client.py @@ -41,14 +41,22 @@ class MCPManager: # so library/CLI construction without secrets keeps working. self._secrets = secrets - async def ensure(self, server: MCPServerDef) -> _Conn: - """Return a live connection for `server`, connecting (once) if needed.""" + async def ensure(self, server: MCPServerDef, *, interactive: bool = False) -> _Conn: + """Return a live connection for `server`, connecting (once) if needed. + + `interactive=True` (explicit connect actions only) lets an OAuth server run + the browser sign-in flow; the default refuses it — stored tokens and silent + refresh still work, but a server that insists on re-authorization raises + InteractiveAuthRequired instead of hijacking the user's browser. + """ async with self._lock: existing = self._conns.get(server.name) if existing is not None: return existing ready: asyncio.Future = asyncio.get_running_loop().create_future() - self._tasks[server.name] = asyncio.create_task(self._serve(server, ready)) + self._tasks[server.name] = asyncio.create_task( + self._serve(server, ready, interactive=interactive) + ) conn = await ready # propagates connection errors self._conns[server.name] = conn return conn @@ -77,7 +85,9 @@ class MCPManager: self._tasks.clear() # -- per-server lifecycle (one task owns enter+exit) ------------------------ - async def _serve(self, server: MCPServerDef, ready: asyncio.Future) -> None: + async def _serve( + self, server: MCPServerDef, ready: asyncio.Future, *, interactive: bool = False + ) -> None: try: async with AsyncExitStack() as stack: if server.transport == "http": @@ -92,7 +102,12 @@ class MCPManager: if self._secrets is None: self._secrets = SecretStore() - auth = build_auth(server.name, server.url, self._secrets) + auth = build_auth( + server.name, + server.url, + self._secrets, + interactive=interactive, + ) read, write, *_ = await stack.enter_async_context( streamablehttp_client( server.url, headers=server.headers or None, auth=auth diff --git a/coworker/mcp/oauth.py b/coworker/mcp/oauth.py index 1bc07b63..85f8e35c 100644 --- a/coworker/mcp/oauth.py +++ b/coworker/mcp/oauth.py @@ -87,6 +87,31 @@ class SecretStoreTokenStorage(TokenStorage): self._merge({"client_info": info.model_dump(mode="json", exclude_none=True)}) +class InteractiveAuthRequired(RuntimeError): + """The server wants a browser sign-in, but this context must not open one. + + Interactive OAuth (browser + loopback wait) is an explicit-connect-only + privilege: a background context that hit this — an engine turn, a tools + listing — raises instead, and the caller skips the server. Without this, a + server whose refresh token the vendor rejected (Atlassian rotates them + aggressively) would hijack the user's browser from ANY code path that + touched it — owner-hit 2026-07-20: an authorize page opened at app launch. + """ + + +def is_auth_required(exc: BaseException) -> bool: + """True if InteractiveAuthRequired is anywhere in the exception tree — the SDK + transport runs in anyio task groups, so it often arrives wrapped in an + ExceptionGroup (or chained as a cause) rather than bare.""" + if isinstance(exc, InteractiveAuthRequired): + return True + for sub in getattr(exc, "exceptions", None) or []: # ExceptionGroup + if is_auth_required(sub): + return True + cause = exc.__cause__ or exc.__context__ + return is_auth_required(cause) if cause is not None else False + + # -- single-slot interactive flow ------------------------------------------------ _pending: Optional[asyncio.Future] = None # The last authorize URL we sent the user to — surfaced over REST so the GUI can offer @@ -113,6 +138,22 @@ async def _open_browser(url: str) -> None: await asyncio.get_running_loop().run_in_executor(None, webbrowser.open, url) +async def _refuse_browser(url: str) -> None: + """Non-interactive redirect handler: never open a browser, but keep the URL so + the GUI's "reopen sign-in page" affordance still works after the refusal.""" + global last_authorize_url + last_authorize_url = url + raise InteractiveAuthRequired( + "sign-in required — reconnect this server from its page" + ) + + +async def _refuse_callback() -> tuple[str, Optional[str]]: + raise InteractiveAuthRequired( + "sign-in required — reconnect this server from its page" + ) + + async def _wait_for_callback() -> tuple[str, Optional[str]]: global _pending if _pending is not None and not _pending.done(): @@ -130,9 +171,18 @@ async def _wait_for_callback() -> tuple[str, Optional[str]]: def build_auth( - server_name: str, server_url: str, secrets: SecretStore + server_name: str, + server_url: str, + secrets: SecretStore, + *, + interactive: bool = True, ) -> OAuthClientProvider: - """The httpx auth for one OAuth MCP server (pass as streamablehttp_client(auth=…)).""" + """The httpx auth for one OAuth MCP server (pass as streamablehttp_client(auth=…)). + + `interactive=False` still uses stored tokens and silent refresh, but the moment + the SDK wants a browser authorization it raises InteractiveAuthRequired instead + of opening one — only explicit connect actions pass True. + """ metadata = OAuthClientMetadata.model_validate( { "client_name": CLIENT_NAME, @@ -147,8 +197,8 @@ def build_auth( server_url=server_url, client_metadata=metadata, storage=SecretStoreTokenStorage(server_name, secrets), - redirect_handler=_open_browser, - callback_handler=_wait_for_callback, + redirect_handler=_open_browser if interactive else _refuse_browser, + callback_handler=_wait_for_callback if interactive else _refuse_callback, ) diff --git a/coworker/server/app.py b/coworker/server/app.py index a0f1e2a4..5c50bcb3 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1657,6 +1657,24 @@ def create_app(manager: SessionManager) -> FastAPI: finally: manager.unregister_session_client(session_id, ws.send_json) + @app.websocket("/ws/events") + async def ws_events(ws: WebSocket) -> None: + """App-wide event stream (session-independent): the GUI keeps one open for + pushes like automation_run_started (the UX-026 toast). Read-only — inbound + frames are ignored; the receive loop just detects disconnect.""" + if not _origin_allowed(ws.headers.get("origin")): + await ws.close(code=1008) + return + await ws.accept() + manager.register_event_client(ws.send_json) + try: + while True: + await ws.receive_text() + except WebSocketDisconnect: + pass + finally: + manager.unregister_event_client(ws.send_json) + return app diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 476b87fa..cb13f613 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -161,6 +161,9 @@ class SessionManager: # whoever drives the turn (foreground user_message, channel delivery, self-wake, resume). # Delivery itself is socket-independent — this only governs *live visibility*. self._session_clients: dict[str, set[Any]] = {} + # App-wide event sockets (/ws/events): session-independent pushes — today the + # automation-run-started toast (UX-026); badges could ride it later. + self._event_clients: set[Any] = set() # Automation: scheduled tasks store + the tick scheduler (started in the lifespan). # The scheduler also resumes self-wake'd sessions each tick (extra_tick). self.task_store = TaskStore(base / "automation.db") @@ -833,9 +836,19 @@ class SessionManager: ] try: conn = await self.mcp.ensure(server) - except ( - Exception - ): # bad command / unreachable url — skip, don't break the session + except Exception as exc: + if mcp_oauth.is_auth_required(exc): + # Stored tokens no longer refresh (vendor rotated/expired + # them) — the non-interactive connect refused to open a + # browser. Record it so the MCP page shows WHY the server is + # dark; the session just runs without its tools. + self._mcp_errors[server.name] = ( + "sign-in required — reconnect this server from its page" + ) + logger.info( + "mcp %s needs re-auth; skipped for this session", server.name + ) + # else: bad command / unreachable url — skip, don't break the session continue callables = build_callables( server, @@ -914,7 +927,8 @@ class SessionManager: self._mcp_authorizing.add(name) self._mcp_errors.pop(name, None) try: - conn = await self.mcp.ensure(server) + # The ONE place a browser sign-in may start: an explicit connect. + conn = await self.mcp.ensure(server, interactive=True) return {"ok": True, "tools": len(conn.tools)} except Exception as exc: self._mcp_errors[name] = str(exc) or exc.__class__.__name__ @@ -2139,6 +2153,21 @@ class SessionManager: return {"ok": True} # -- per-session live view -------------------------------------------------- + def register_event_client(self, send_cb: Any) -> None: + self._event_clients.add(send_cb) + + def unregister_event_client(self, send_cb: Any) -> None: + self._event_clients.discard(send_cb) + + async def broadcast_event(self, message: dict) -> None: + """Fan an app-wide event out to every /ws/events socket. Best-effort: a dead + socket is dropped, never fatal to the caller.""" + for cb in list(self._event_clients): + try: + await cb(message) + except Exception: + self.unregister_event_client(cb) + def register_session_client(self, session_id: str, send_cb: Any) -> None: self._session_clients.setdefault(session_id, set()).add(send_cb) @@ -2650,6 +2679,22 @@ class SessionManager: task_id=task.id, trigger=trigger ) # __post_init__ sets run.session_id self.task_store.add_run(run) # mark "running" + # UX-026: tell every open app window a SCHEDULED run just started (the 5s + # top-right toast). Manual runs never come through here — the user is + # already watching those live. + await self.broadcast_event( + { + "type": "automation_run_started", + "data": { + "task_id": task.id, + "task_title": task.title, + "session_id": run.session_id, + "workspace": task.workspace, + "agent": task.agent, + "trigger": trigger, + }, + } + ) # Each run is a real, persisted conversation thread: it runs the instructions under its # own session id, then saves the transcript. The user can reopen that session and ask a # follow-up — the scheduled agent is no longer fire-and-forget. diff --git a/surfaces/gui/e2e/automation-toast.spec.ts b/surfaces/gui/e2e/automation-toast.spec.ts new file mode 100644 index 00000000..40aef7b9 --- /dev/null +++ b/surfaces/gui/e2e/automation-toast.spec.ts @@ -0,0 +1,44 @@ +// UX-026: the automation-start toast — top-right, 5s, schedule-fired runs only. +// The server pushes automation_run_started over the app-wide /ws/events stream; +// the toast names the automation, offers one View-run action (opens the run's +// session), an ✕, and auto-dismisses via the drain bar. +import { expect } from "@playwright/test"; +import { sendAppEvent, test } from "./fixtures"; + +const RUN_STARTED = { + type: "automation_run_started", + data: { + task_id: "task-1", + task_title: "Daily AI News", + session_id: "run-live-1", + workspace: "/tmp/aw", + agent: "cowork", + trigger: "schedule", + }, +}; + +test("a schedule-fired run pops the toast; View run opens its session", async ({ page }) => { + await page.goto("/"); + await sendAppEvent(page, RUN_STARTED); + const toast = page.getByTestId("automation-toast"); + await expect(toast).toContainText("Automation started"); + await expect(toast).toContainText("Daily AI News"); + + await toast.getByTestId("toast-view-run").click(); + await expect(page.getByTestId("automation-toast")).toHaveCount(0); + // the run's session is now the active conversation (composer visible = session surface) + await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); +}); + +test("the toast dismisses on ✕ and by itself after ~5s", async ({ page }) => { + await page.goto("/"); + await sendAppEvent(page, RUN_STARTED); + await expect(page.getByTestId("automation-toast")).toBeVisible(); + await page.getByTestId("toast-dismiss").click(); + await expect(page.getByTestId("automation-toast")).toHaveCount(0); + + await sendAppEvent(page, { ...RUN_STARTED, data: { ...RUN_STARTED.data, task_title: "Weekly CRM digest" } }); + await expect(page.getByTestId("automation-toast")).toContainText("Weekly CRM digest"); + // auto-dismiss: gone within the 5s drain (+ slack for CI) + await expect(page.getByTestId("automation-toast")).toHaveCount(0, { timeout: 7000 }); +}); diff --git a/surfaces/gui/e2e/automations-manage.spec.ts b/surfaces/gui/e2e/automations-manage.spec.ts index 0d188ca2..9604399d 100644 --- a/surfaces/gui/e2e/automations-manage.spec.ts +++ b/surfaces/gui/e2e/automations-manage.spec.ts @@ -7,7 +7,7 @@ import { test } from "./fixtures"; async function openAutomations(page) { await page.goto("/"); await page.getByTestId("account-row").click(); - await page.getByRole("button", { name: "Automations", exact: true }).click(); + await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click(); await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible(); } diff --git a/surfaces/gui/e2e/automations-quickstart.spec.ts b/surfaces/gui/e2e/automations-quickstart.spec.ts index cc00479b..24a9b79d 100644 --- a/surfaces/gui/e2e/automations-quickstart.spec.ts +++ b/surfaces/gui/e2e/automations-quickstart.spec.ts @@ -8,7 +8,7 @@ import { test } from "./fixtures"; async function openAutomations(page) { await page.goto("/"); await page.getByTestId("account-row").click(); - await page.getByRole("button", { name: "Automations", exact: true }).click(); + await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click(); await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible(); } diff --git a/surfaces/gui/e2e/automations.spec.ts b/surfaces/gui/e2e/automations.spec.ts index 260500fc..146b1c40 100644 --- a/surfaces/gui/e2e/automations.spec.ts +++ b/surfaces/gui/e2e/automations.spec.ts @@ -8,7 +8,7 @@ test("scheduled run session shows the run banner; Back returns to the task detai }) => { await page.goto("/"); await page.getByTestId("account-row").click(); - await page.getByRole("button", { name: "Automations", exact: true }).click(); + await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click(); // Task list → detail (runs list). await page.getByText("Daily AI News").first().click(); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 7de4203d..4fea47d4 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -1,4 +1,17 @@ -import { test as base, expect } from "@playwright/test"; +import { test as base, expect, type Page } from "@playwright/test"; + +// The app-wide /ws/events socket each page opened (UX-026 toast et al.) — specs +// push server events through it via sendAppEvent below. +const eventSockets = new WeakMap void }>(); + +/** Push an app-wide event exactly as the server would over /ws/events. Waits for + * the GUI to have connected its socket first. */ +export async function sendAppEvent(page: Page, obj: unknown): Promise { + for (let i = 0; i < 50 && !eventSockets.get(page); i++) await page.waitForTimeout(100); + const ws = eventSockets.get(page); + if (!ws) throw new Error("the app never opened /ws/events"); + ws.send(JSON.stringify(obj)); +} // Hermetic API mock. Every /v1 request the GUI makes is fulfilled from the fixtures below (shapes // mirrored from the real backend), and the event WebSocket is a SCRIPTED FAKE AGENT (ready on @@ -542,6 +555,11 @@ export async function mockApi(page: import("@playwright/test").Page) { // assistant_deltas → assistant_message "Echo: " → turn_done // · a message containing "run a tool": tool_proposed + permission_required, then the turn // SUSPENDS until the client's approval decision arrives (deny → skipped; else → ran) + // App-wide event stream: register the socket so sendAppEvent can push into it. + await page.routeWebSocket(/\/ws\/events$/, (ws) => { + eventSockets.set(page, ws); + }); + await page.routeWebSocket(/\/ws\/session\//, (ws) => { const send = (type: string, data: Record = {}) => ws.send(JSON.stringify({ type, data })); diff --git a/surfaces/gui/e2e/standing-approvals.spec.ts b/surfaces/gui/e2e/standing-approvals.spec.ts index 12f77e1a..7eb0ec97 100644 --- a/surfaces/gui/e2e/standing-approvals.spec.ts +++ b/surfaces/gui/e2e/standing-approvals.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from "./fixtures"; async function openTaskDetail(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("account-row").click(); - await page.getByRole("button", { name: "Automations", exact: true }).click(); + await page.getByTestId("account-menu").getByRole("button", { name: "Automations", exact: true }).click(); await page.getByText("Daily AI News").first().click(); await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible(); } diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index b5378189..5f5d5b1b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -7,6 +7,8 @@ import { getRecentWorkspaces, getSessionMessages, getSessions, + announceAutomationsChanged, + connectEvents, getSettings, getPersonas, getInbox, @@ -811,6 +813,33 @@ export function App() { setSessionId(newId()); }; // Inbox → session: the item carries its session's workspace/agent, so open it directly. + // UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for + // manual Run-now — the user is already watching). Rides the app-wide /ws/events + // stream; View run opens the run's live session. + const [runToast, setRunToast] = useState<{ + title: string; sessionId: string; workspace: string; agent: string; time: string; + } | null>(null); + useEffect(() => { + const stop = connectEvents((msg) => { + if (msg.type !== "automation_run_started") return; + const d = (msg.data ?? {}) as Record; + setRunToast({ + title: d.task_title || "Automation", + sessionId: d.session_id || "", + workspace: d.workspace || "", + agent: d.agent || "cowork", + time: new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), + }); + announceAutomationsChanged(); // the Scheduled band's badge is now stale + }); + return stop; + }, []); + useEffect(() => { + if (!runToast) return; + const t = window.setTimeout(() => setRunToast(null), 5000); + return () => window.clearTimeout(t); + }, [runToast]); + const openSessionFromInbox = (sid: string, ws: string, ag: string) => selectSession(sid, ws, ag); const selectSession = async (id: string, ws: string, ag: string) => { setSurface("session"); // selecting a conversation always returns to the conversation view @@ -1043,6 +1072,45 @@ export function App() { )} {/* Desktop-only auto-update prompt (15s after boot, then every 30 min; inert in browser). */} + {/* UX-026: automation-start toast — quiet panel, neutral dot/drain, accent only + on the action (rev 2); auto-dismisses with the 5s drain bar. */} + {runToast && ( +
+
+ + Automation started +
+
+ {runToast.title} · {runToast.time} run +
+
+ + +
+
+ +
+
+ )} {/* When collapsed, a thin left-edge zone peeks the nav back as a floating overlay. */} {navCollapsed && (
}) => void +): () => void { + let ws: WebSocket | null = null; + let timer: number | null = null; + let closed = false; + const open = () => { + if (closed) return; + ws = new WebSocket(`${wsBase()}/ws/events`); + ws.onmessage = (e) => { + try { + onEvent(JSON.parse(e.data)); + } catch { + /* malformed frame — ignore */ + } + }; + ws.onclose = () => { + if (!closed) timer = window.setTimeout(open, 5000); + }; + }; + open(); + return () => { + closed = true; + if (timer !== null) window.clearTimeout(timer); + ws?.close(); + }; +} + /** Advance the automation's seen mark — clears its unseen-runs badge (UX-023). */ export async function markAutomationSeen(id: string): Promise<{ ok: boolean }> { const res = await fetch(`${httpBase()}/v1/automations/${id}/seen`, { method: "POST" }); diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 8eed9f5a..2c07cf49 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1354,3 +1354,9 @@ html[data-theme="dark"] :not(.connector-badge) > .connector-icon[data-dark-mark] .art-chip-meta b { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .art-chip-meta span { font-size: 11px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .art-chip-open { margin-left: 8px; flex: none; font-size: 11.5px; color: var(--accent); font-weight: 500; } + +/* ---- UX-026: automation-start toast (top-right, 5s; schedule-fired runs only) ---- */ +.toast-pulse { animation: toast-pulse 1.2s ease infinite; } +@keyframes toast-pulse { 50% { opacity: .35; } } +.toast-drain { animation: toast-drain 5s linear forwards; } +@keyframes toast-drain { to { width: 0; } } diff --git a/tests/test_automation.py b/tests/test_automation.py index 1a2631bf..4fa38a43 100644 --- a/tests/test_automation.py +++ b/tests/test_automation.py @@ -391,3 +391,44 @@ def test_unseen_runs_counted_and_cleared_by_mark_seen(tmp_path, monkeypatch): assert row["unseen_runs"] == 1 and row["unseen_failed"] is False assert not manager.mark_automation_seen("task-nope")["ok"] + + +@pytest.mark.asyncio +async def test_scheduled_run_broadcasts_run_started_event(tmp_path, monkeypatch): + """UX-026: the moment a scheduled run starts, every /ws/events socket hears + automation_run_started (the top-right toast). Dead sockets drop silently.""" + from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient + from coworker.server.manager import SessionManager + + class ScriptedProvider(ProviderClient): + def complete(self, *, model, messages, tools=None, **settings): + return AssistantTurn(text="done", finish_reason="stop") + + def capabilities(self, model): + return ModelCapabilities() + + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + ws = tmp_path / "ws" + ws.mkdir() + manager = SessionManager(data_dir=tmp_path / "data", provider=ScriptedProvider()) + task = _task(workspace=str(ws), agent="cowork") + manager.task_store.save(task) + + heard: list = [] + + async def listener(message): + heard.append(message) + + async def dead(message): + raise RuntimeError("socket gone") + + manager.register_event_client(listener) + manager.register_event_client(dead) + run = await manager._run_scheduled_task(task, trigger="schedule") + + (event,) = [m for m in heard if m["type"] == "automation_run_started"] + assert event["data"]["task_id"] == task.id + assert event["data"]["task_title"] == task.title + assert event["data"]["session_id"] == run.session_id + assert event["data"]["trigger"] == "schedule" + assert dead not in manager._event_clients # dropped, not fatal diff --git a/tests/test_mcp_connectors.py b/tests/test_mcp_connectors.py index 2c6ea674..0a68984f 100644 --- a/tests/test_mcp_connectors.py +++ b/tests/test_mcp_connectors.py @@ -228,3 +228,50 @@ def test_prepare_mcp_tools_gates_by_session_pin_and_toggles(tmp_path, monkeypatc # Session gating: a session whose effective set excludes monday gets nothing. monkeypatch.setattr(manager, "effective_connectors", lambda sid, agent=None: set()) assert asyncio.run(manager.prepare_mcp_tools("s2")) == [] + + +def test_stale_token_reauth_never_opens_a_browser_mid_turn(tmp_path, monkeypatch): + """Tokens PRESENT but the vendor rejects the refresh → the SDK wants a browser + re-auth. Non-interactive contexts refuse (InteractiveAuthRequired, often + wrapped in an ExceptionGroup by the anyio transport): the session skips the + server and the failure is recorded — owner-hit 2026-07-20: an Atlassian + authorize page opened at app LAUNCH from a background session start.""" + from coworker.mcp import oauth as mcp_oauth + + _state(tmp_path, monkeypatch) + manager = SessionManager(data_dir=tmp_path / "data") + put_global_server( + "granola", + {"url": "https://mcp.granola.ai/mcp", "auth": "oauth", "enabled": True}, + ) + manager.secrets.put("mcp-oauth:granola", {"tokens": {"access_token": "stale"}}) + + async def refuses(server): + raise ExceptionGroup( + "transport", [mcp_oauth.InteractiveAuthRequired("sign-in required")] + ) + + monkeypatch.setattr(manager.mcp, "ensure", refuses) + assert asyncio.run(manager.prepare_mcp_tools("s1")) == [] + assert "sign-in required" in manager._mcp_errors["granola"] + + +def test_non_interactive_auth_wiring_refuses_the_browser(): + """build_auth(interactive=False) must never open a browser: its redirect + handler raises (keeping the authorize URL for the GUI's reopen affordance), + and is_auth_required() finds the marker bare, wrapped, or chained.""" + import pytest + + from coworker.mcp import oauth as mcp_oauth + + with pytest.raises(mcp_oauth.InteractiveAuthRequired): + asyncio.run(mcp_oauth._refuse_browser("https://vendor/authorize?x=1")) + assert mcp_oauth.last_authorize_url == "https://vendor/authorize?x=1" + + bare = mcp_oauth.InteractiveAuthRequired("x") + assert mcp_oauth.is_auth_required(bare) + assert mcp_oauth.is_auth_required(ExceptionGroup("g", [ValueError(), bare])) + chained = RuntimeError("wrapped") + chained.__cause__ = bare + assert mcp_oauth.is_auth_required(chained) + assert not mcp_oauth.is_auth_required(ValueError("no")) diff --git a/tests/test_team_allowlist.py b/tests/test_team_allowlist.py index ea7c56ae..60e37ee5 100644 --- a/tests/test_team_allowlist.py +++ b/tests/test_team_allowlist.py @@ -212,3 +212,52 @@ def test_rest_allow_with_team_and_workspaces_field(tmp_path): "/v1/connectors/slack/disallow", json={"user_id": "U_W", "team_id": "T1"} ) assert r.json()["allowed_users"] == [] + + +# -- installer pre-add on managed install (UX-027) -------------------------------- +def test_managed_install_preadds_the_installer(tmp_path): + from coworker.connectors.setup import managed_connect_slack_install + + s = SecretStore(tmp_path / "secrets.json") + managed_connect_slack_install( + s, {"team_id": "T1", "access_token": "xoxb-t1", "slack_user_id": "U_ME"} + ) + assert s.get("slack:team:T1")["allowed_users"] == ["U_ME"] + src = SessionSource("slack", "T1/C1", user_id="U_ME", team_id="T1") + assert is_authorized(load_settings(s)["slack"], src) is True + + +def test_reinstall_preserves_the_existing_allow_list(tmp_path): + from coworker.connectors.setup import managed_connect_slack_install + + s = SecretStore(tmp_path / "secrets.json") + s.put( + "slack:team:T1", + { + "bot_token": "xoxb-old", + "allowed_users": ["U_ANNA", "U_ME"], + "sender_name": "Rohit", + }, + ) + managed_connect_slack_install( + s, {"team_id": "T1", "access_token": "xoxb-new", "slack_user_id": "U_ME"} + ) + profile = s.get("slack:team:T1") + assert profile["allowed_users"] == ["U_ANNA", "U_ME"] + assert profile["bot_token"] == "xoxb-new" + assert profile["sender_name"] == "Rohit" + + +def test_workspace_listing_carries_installer_identity(tmp_path): + from coworker.connectors.setup import ( + _slack_workspaces, + managed_connect_slack_install, + ) + + s = SecretStore(tmp_path / "secrets.json") + managed_connect_slack_install( + s, {"team_id": "T1", "access_token": "xoxb-t1", "slack_user_id": "U_ME"} + ) + (w,) = _slack_workspaces(s) + assert w["installer_user_id"] == "U_ME" + assert w["installer_name"] == ""