mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
port aisuite#380: slack installer pre-add, mcp oauth quarantine, automation toast, e2e fixes
Slack installer joins the workspace allow-list on managed connect; MCP interactive oauth only from explicit connects. Run-started toast over a new app-wide /ws/events socket; Automations e2e locators scoped to the account menu.
This commit is contained in:
@@ -184,6 +184,10 @@ def _slack_workspaces(secrets: SecretStore) -> list[dict[str, Any]]:
|
|||||||
"domain": profile.get("domain") or "",
|
"domain": profile.get("domain") or "",
|
||||||
"allowed_users": list(profile.get("allowed_users") or []),
|
"allowed_users": list(profile.get("allowed_users") or []),
|
||||||
"allow_all": bool(profile.get("allow_all")),
|
"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(
|
for team_id, profile in sorted(
|
||||||
_slack_team_profiles(secrets), key=lambda t: t[0]
|
_slack_team_profiles(secrets), key=lambda t: t[0]
|
||||||
@@ -414,6 +418,15 @@ def managed_connect_slack_install(
|
|||||||
bot_token = form.get("access_token", "")
|
bot_token = form.get("access_token", "")
|
||||||
if not team_id or not bot_token:
|
if not team_id or not bot_token:
|
||||||
return {"ok": False, "error": "missing team_id or 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(
|
secrets.put(
|
||||||
f"slack:team:{team_id}",
|
f"slack:team:{team_id}",
|
||||||
{
|
{
|
||||||
@@ -423,7 +436,7 @@ def managed_connect_slack_install(
|
|||||||
"bot_user_id": form.get("bot_user_id", ""),
|
"bot_user_id": form.get("bot_user_id", ""),
|
||||||
# The INSTALLER's Slack member id (authed_user) — who this workspace's
|
# The INSTALLER's Slack member id (authed_user) — who this workspace's
|
||||||
# outbound posts speak for (attribution.py resolves + caches the name).
|
# 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,
|
"team_id": team_id,
|
||||||
"account": form.get("account", ""),
|
"account": form.get("account", ""),
|
||||||
# The workspace's slack.com subdomain (broker resolves it via auth.test)
|
# 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", ""),
|
"domain": form.get("team_domain", ""),
|
||||||
"scope": form.get("scope", ""),
|
"scope": form.get("scope", ""),
|
||||||
"connection_id": form.get("connection_id", ""),
|
"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 {}
|
default = secrets.get("slack:default") or {}
|
||||||
|
|||||||
+20
-5
@@ -41,14 +41,22 @@ class MCPManager:
|
|||||||
# so library/CLI construction without secrets keeps working.
|
# so library/CLI construction without secrets keeps working.
|
||||||
self._secrets = secrets
|
self._secrets = secrets
|
||||||
|
|
||||||
async def ensure(self, server: MCPServerDef) -> _Conn:
|
async def ensure(self, server: MCPServerDef, *, interactive: bool = False) -> _Conn:
|
||||||
"""Return a live connection for `server`, connecting (once) if needed."""
|
"""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:
|
async with self._lock:
|
||||||
existing = self._conns.get(server.name)
|
existing = self._conns.get(server.name)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
ready: asyncio.Future = asyncio.get_running_loop().create_future()
|
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
|
conn = await ready # propagates connection errors
|
||||||
self._conns[server.name] = conn
|
self._conns[server.name] = conn
|
||||||
return conn
|
return conn
|
||||||
@@ -77,7 +85,9 @@ class MCPManager:
|
|||||||
self._tasks.clear()
|
self._tasks.clear()
|
||||||
|
|
||||||
# -- per-server lifecycle (one task owns enter+exit) ------------------------
|
# -- 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:
|
try:
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
if server.transport == "http":
|
if server.transport == "http":
|
||||||
@@ -92,7 +102,12 @@ class MCPManager:
|
|||||||
|
|
||||||
if self._secrets is None:
|
if self._secrets is None:
|
||||||
self._secrets = SecretStore()
|
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(
|
read, write, *_ = await stack.enter_async_context(
|
||||||
streamablehttp_client(
|
streamablehttp_client(
|
||||||
server.url, headers=server.headers or None, auth=auth
|
server.url, headers=server.headers or None, auth=auth
|
||||||
|
|||||||
+54
-4
@@ -87,6 +87,31 @@ class SecretStoreTokenStorage(TokenStorage):
|
|||||||
self._merge({"client_info": info.model_dump(mode="json", exclude_none=True)})
|
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 ------------------------------------------------
|
# -- single-slot interactive flow ------------------------------------------------
|
||||||
_pending: Optional[asyncio.Future] = None
|
_pending: Optional[asyncio.Future] = None
|
||||||
# The last authorize URL we sent the user to — surfaced over REST so the GUI can offer
|
# 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)
|
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]]:
|
async def _wait_for_callback() -> tuple[str, Optional[str]]:
|
||||||
global _pending
|
global _pending
|
||||||
if _pending is not None and not _pending.done():
|
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(
|
def build_auth(
|
||||||
server_name: str, server_url: str, secrets: SecretStore
|
server_name: str,
|
||||||
|
server_url: str,
|
||||||
|
secrets: SecretStore,
|
||||||
|
*,
|
||||||
|
interactive: bool = True,
|
||||||
) -> OAuthClientProvider:
|
) -> 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(
|
metadata = OAuthClientMetadata.model_validate(
|
||||||
{
|
{
|
||||||
"client_name": CLIENT_NAME,
|
"client_name": CLIENT_NAME,
|
||||||
@@ -147,8 +197,8 @@ def build_auth(
|
|||||||
server_url=server_url,
|
server_url=server_url,
|
||||||
client_metadata=metadata,
|
client_metadata=metadata,
|
||||||
storage=SecretStoreTokenStorage(server_name, secrets),
|
storage=SecretStoreTokenStorage(server_name, secrets),
|
||||||
redirect_handler=_open_browser,
|
redirect_handler=_open_browser if interactive else _refuse_browser,
|
||||||
callback_handler=_wait_for_callback,
|
callback_handler=_wait_for_callback if interactive else _refuse_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1657,6 +1657,24 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
finally:
|
finally:
|
||||||
manager.unregister_session_client(session_id, ws.send_json)
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ class SessionManager:
|
|||||||
# whoever drives the turn (foreground user_message, channel delivery, self-wake, resume).
|
# whoever drives the turn (foreground user_message, channel delivery, self-wake, resume).
|
||||||
# Delivery itself is socket-independent — this only governs *live visibility*.
|
# Delivery itself is socket-independent — this only governs *live visibility*.
|
||||||
self._session_clients: dict[str, set[Any]] = {}
|
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).
|
# Automation: scheduled tasks store + the tick scheduler (started in the lifespan).
|
||||||
# The scheduler also resumes self-wake'd sessions each tick (extra_tick).
|
# The scheduler also resumes self-wake'd sessions each tick (extra_tick).
|
||||||
self.task_store = TaskStore(base / "automation.db")
|
self.task_store = TaskStore(base / "automation.db")
|
||||||
@@ -833,9 +836,19 @@ class SessionManager:
|
|||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
conn = await self.mcp.ensure(server)
|
conn = await self.mcp.ensure(server)
|
||||||
except (
|
except Exception as exc:
|
||||||
Exception
|
if mcp_oauth.is_auth_required(exc):
|
||||||
): # bad command / unreachable url — skip, don't break the session
|
# 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
|
continue
|
||||||
callables = build_callables(
|
callables = build_callables(
|
||||||
server,
|
server,
|
||||||
@@ -914,7 +927,8 @@ class SessionManager:
|
|||||||
self._mcp_authorizing.add(name)
|
self._mcp_authorizing.add(name)
|
||||||
self._mcp_errors.pop(name, None)
|
self._mcp_errors.pop(name, None)
|
||||||
try:
|
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)}
|
return {"ok": True, "tools": len(conn.tools)}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._mcp_errors[name] = str(exc) or exc.__class__.__name__
|
self._mcp_errors[name] = str(exc) or exc.__class__.__name__
|
||||||
@@ -2139,6 +2153,21 @@ class SessionManager:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# -- per-session live view --------------------------------------------------
|
# -- 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:
|
def register_session_client(self, session_id: str, send_cb: Any) -> None:
|
||||||
self._session_clients.setdefault(session_id, set()).add(send_cb)
|
self._session_clients.setdefault(session_id, set()).add(send_cb)
|
||||||
|
|
||||||
@@ -2650,6 +2679,22 @@ class SessionManager:
|
|||||||
task_id=task.id, trigger=trigger
|
task_id=task.id, trigger=trigger
|
||||||
) # __post_init__ sets run.session_id
|
) # __post_init__ sets run.session_id
|
||||||
self.task_store.add_run(run) # mark "running"
|
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
|
# 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
|
# 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.
|
# follow-up — the scheduled agent is no longer fire-and-forget.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import { test } from "./fixtures";
|
|||||||
async function openAutomations(page) {
|
async function openAutomations(page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
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();
|
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { test } from "./fixtures";
|
|||||||
async function openAutomations(page) {
|
async function openAutomations(page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
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();
|
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test("scheduled run session shows the run banner; Back returns to the task detai
|
|||||||
}) => {
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
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).
|
// Task list → detail (runs list).
|
||||||
await page.getByText("Daily AI News").first().click();
|
await page.getByText("Daily AI News").first().click();
|
||||||
|
|||||||
@@ -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<Page, { send: (data: string) => 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<void> {
|
||||||
|
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
|
// 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
|
// 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: <text>" → turn_done
|
// assistant_deltas → assistant_message "Echo: <text>" → turn_done
|
||||||
// · a message containing "run a tool": tool_proposed + permission_required, then the turn
|
// · 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)
|
// 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) => {
|
await page.routeWebSocket(/\/ws\/session\//, (ws) => {
|
||||||
const send = (type: string, data: Record<string, unknown> = {}) =>
|
const send = (type: string, data: Record<string, unknown> = {}) =>
|
||||||
ws.send(JSON.stringify({ type, data }));
|
ws.send(JSON.stringify({ type, data }));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { test, expect } from "./fixtures";
|
|||||||
async function openTaskDetail(page: import("@playwright/test").Page) {
|
async function openTaskDetail(page: import("@playwright/test").Page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
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 page.getByText("Daily AI News").first().click();
|
||||||
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getRecentWorkspaces,
|
getRecentWorkspaces,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
getSessions,
|
getSessions,
|
||||||
|
announceAutomationsChanged,
|
||||||
|
connectEvents,
|
||||||
getSettings,
|
getSettings,
|
||||||
getPersonas,
|
getPersonas,
|
||||||
getInbox,
|
getInbox,
|
||||||
@@ -811,6 +813,33 @@ export function App() {
|
|||||||
setSessionId(newId());
|
setSessionId(newId());
|
||||||
};
|
};
|
||||||
// Inbox → session: the item carries its session's workspace/agent, so open it directly.
|
// 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<string, string>;
|
||||||
|
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 openSessionFromInbox = (sid: string, ws: string, ag: string) => selectSession(sid, ws, ag);
|
||||||
const selectSession = async (id: string, ws: string, ag: string) => {
|
const selectSession = async (id: string, ws: string, ag: string) => {
|
||||||
setSurface("session"); // selecting a conversation always returns to the conversation view
|
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). */}
|
{/* Desktop-only auto-update prompt (15s after boot, then every 30 min; inert in browser). */}
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
|
{/* 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 && (
|
||||||
|
<div
|
||||||
|
className="fixed top-3 right-3 z-[45] w-[290px] bg-panel border border-line rounded-xl shadow-lg px-3.5 pt-3 pb-2.5"
|
||||||
|
data-testid="automation-toast"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[12.5px] font-semibold">
|
||||||
|
<span className="w-[7px] h-[7px] rounded-full bg-faint toast-pulse" />
|
||||||
|
Automation started
|
||||||
|
</div>
|
||||||
|
<div className="text-[12.5px] text-muted mt-0.5 ml-[15px] truncate">
|
||||||
|
{runToast.title} · {runToast.time} run
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between ml-[15px] mt-1.5">
|
||||||
|
<button
|
||||||
|
className="text-[12.5px] text-accent font-medium"
|
||||||
|
data-testid="toast-view-run"
|
||||||
|
onClick={() => {
|
||||||
|
selectSession(runToast.sessionId, runToast.workspace, runToast.agent);
|
||||||
|
setRunToast(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View run ›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-[12px] text-faint px-0.5"
|
||||||
|
data-testid="toast-dismiss"
|
||||||
|
title="Dismiss"
|
||||||
|
onClick={() => setRunToast(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-3 right-3 bottom-1 h-[2px] rounded bg-line overflow-hidden">
|
||||||
|
<span className="block h-full bg-faint toast-drain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* When collapsed, a thin left-edge zone peeks the nav back as a floating overlay. */}
|
{/* When collapsed, a thin left-edge zone peeks the nav back as a floating overlay. */}
|
||||||
{navCollapsed && (
|
{navCollapsed && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1341,6 +1341,37 @@ export function announceAutomationsChanged() {
|
|||||||
window.dispatchEvent(new CustomEvent(AUTOMATIONS_CHANGED));
|
window.dispatchEvent(new CustomEvent(AUTOMATIONS_CHANGED));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** App-wide event stream (/ws/events): session-independent server pushes — today
|
||||||
|
* automation_run_started (the UX-026 toast). Quietly reconnects while the app is
|
||||||
|
* open; the returned cleanup stops it for good. */
|
||||||
|
export function connectEvents(
|
||||||
|
onEvent: (msg: { type: string; data?: Record<string, unknown> }) => 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). */
|
/** Advance the automation's seen mark — clears its unseen-runs badge (UX-023). */
|
||||||
export async function markAutomationSeen(id: string): Promise<{ ok: boolean }> {
|
export async function markAutomationSeen(id: string): Promise<{ ok: boolean }> {
|
||||||
const res = await fetch(`${httpBase()}/v1/automations/${id}/seen`, { method: "POST" });
|
const res = await fetch(`${httpBase()}/v1/automations/${id}/seen`, { method: "POST" });
|
||||||
|
|||||||
@@ -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 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-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; }
|
.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; } }
|
||||||
|
|||||||
@@ -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 row["unseen_runs"] == 1 and row["unseen_failed"] is False
|
||||||
|
|
||||||
assert not manager.mark_automation_seen("task-nope")["ok"]
|
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
|
||||||
|
|||||||
@@ -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.
|
# Session gating: a session whose effective set excludes monday gets nothing.
|
||||||
monkeypatch.setattr(manager, "effective_connectors", lambda sid, agent=None: set())
|
monkeypatch.setattr(manager, "effective_connectors", lambda sid, agent=None: set())
|
||||||
assert asyncio.run(manager.prepare_mcp_tools("s2")) == []
|
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"))
|
||||||
|
|||||||
@@ -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"}
|
"/v1/connectors/slack/disallow", json={"user_id": "U_W", "team_id": "T1"}
|
||||||
)
|
)
|
||||||
assert r.json()["allowed_users"] == []
|
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"] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user