diff --git a/coworker/config.py b/coworker/config.py index 336d4af8..eb037657 100644 --- a/coworker/config.py +++ b/coworker/config.py @@ -48,6 +48,11 @@ class Config: cloud_auth_domain: str = "opencoworker.us.auth0.com" cloud_client_id: str = "g1l4Q1lhYWmyS03qPSf4KEJGrgq02Qam" cloud_audience: str = "https://api.opencoworker.app" + # Public OAuth App identifier used by GitHub's brokerless Device Flow. + # This is intentionally configuration (and not a secret) so forks and + # GitHub Enterprise deployments can use an app they own. + github_oauth_client_id: str = "" + github_oauth_scopes: str = "repo" # Managed relay WebSocket endpoint (Slack/GitHub inbound). Defaults to the # PRODUCTION relay so a fresh install relays out of the box — an empty # default shipped once as "connected but relay OFF" on every machine @@ -71,6 +76,8 @@ class Config: "cloud_auth_domain", "cloud_client_id", "cloud_audience", + "github_oauth_client_id", + "github_oauth_scopes", "cloud_relay_ws_url", } diff --git a/coworker/connectors/github/__init__.py b/coworker/connectors/github/__init__.py new file mode 100644 index 00000000..d6638847 --- /dev/null +++ b/coworker/connectors/github/__init__.py @@ -0,0 +1 @@ +"""GitHub connector helpers.""" diff --git a/coworker/connectors/github/auth.py b/coworker/connectors/github/auth.py new file mode 100644 index 00000000..d3cc6507 --- /dev/null +++ b/coworker/connectors/github/auth.py @@ -0,0 +1,318 @@ +"""Brokerless GitHub OAuth device authorization. + +The OAuth client id is public configuration; the device code and resulting token +stay inside the local sidecar. GUI callers only receive an opaque flow id plus +the user-facing code and verification URL. +""" + +from __future__ import annotations + +import math +import secrets as random +import threading +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +from ...secrets import SecretStore + +DEVICE_CODE_URL = "https://github.com/login/device/code" +ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" +GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" +_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "OpenWorker", +} + + +@dataclass +class _PendingFlow: + client_id: str + device_code: str + user_code: str + verification_uri: str + interval: int + expires_at: float + expires_at_monotonic: float + next_poll_at: float + + +class GitHubDeviceAuth: + """Owns in-memory device flows and persists completed user credentials.""" + + def __init__(self, secrets: SecretStore) -> None: + self._secrets = secrets + self._flows: dict[str, _PendingFlow] = {} + self._lock = threading.Lock() + + def start(self, client_id: str, scopes: str = "repo") -> dict[str, Any]: + client_id = str(client_id or "").strip() + if not client_id: + return { + "ok": False, + "error": ( + "GitHub device sign-in is not configured. " + "Set github_oauth_client_id in config.toml." + ), + } + + payload = {"client_id": client_id} + normalized_scopes = " ".join(str(scopes or "").split()) + if normalized_scopes: + payload["scope"] = normalized_scopes + try: + response = httpx.post( + DEVICE_CODE_URL, + data=payload, + headers=_HEADERS, + timeout=20.0, + ) + data = _json_object(response) + except (httpx.HTTPError, ValueError): + return {"ok": False, "error": "Could not reach GitHub to start sign-in."} + + if response.status_code >= 400 or data.get("error"): + return { + "ok": False, + "error": _error_message(data, "GitHub rejected the device sign-in request."), + } + + required = ("device_code", "user_code", "verification_uri", "expires_in") + if not all(data.get(key) for key in required): + return {"ok": False, "error": "GitHub returned an incomplete device sign-in response."} + + try: + expires_in = max(1, int(data["expires_in"])) + interval = max(1, int(data.get("interval") or 5)) + except (TypeError, ValueError): + return {"ok": False, "error": "GitHub returned invalid device sign-in timing."} + + now = time.monotonic() + flow_id = random.token_urlsafe(24) + flow = _PendingFlow( + client_id=client_id, + device_code=str(data["device_code"]), + user_code=str(data["user_code"]), + verification_uri=str(data["verification_uri"]), + interval=interval, + expires_at=time.time() + expires_in, + expires_at_monotonic=now + expires_in, + # RFC 8628 §3.5 requires waiting the server-provided interval + # before the first token request as well as every later request. + next_poll_at=now + interval, + ) + with self._lock: + self._prune_locked(now) + self._flows[flow_id] = flow + return { + "ok": True, + "flow_id": flow_id, + "user_code": flow.user_code, + "verification_uri": flow.verification_uri, + "expires_in": expires_in, + "expires_at": flow.expires_at, + "interval": interval, + } + + def poll(self, flow_id: str) -> dict[str, Any]: + flow_id = str(flow_id or "").strip() + now = time.monotonic() + with self._lock: + flow = self._flows.get(flow_id) + if flow is None: + self._prune_locked(now) + return {"ok": False, "state": "error", "error": "Device sign-in flow not found."} + if now >= flow.expires_at_monotonic: + self._flows.pop(flow_id, None) + return { + "ok": False, + "state": "expired", + "error": "The GitHub device code expired. Start again.", + } + if now < flow.next_poll_at: + return { + "ok": True, + "state": "pending", + "retry_after": max(1, math.ceil(flow.next_poll_at - now)), + } + # Reserve the next interval before releasing the lock so concurrent + # GUI polls cannot make GitHub requests too quickly. + flow.next_poll_at = now + flow.interval + + try: + response = httpx.post( + ACCESS_TOKEN_URL, + data={ + "client_id": flow.client_id, + "device_code": flow.device_code, + "grant_type": GRANT_TYPE, + }, + headers=_HEADERS, + timeout=20.0, + ) + data = _json_object(response) + except (httpx.HTTPError, ValueError): + return { + "ok": True, + "state": "pending", + "retry_after": flow.interval, + "error": "GitHub is temporarily unreachable; retrying.", + } + + token = str(data.get("access_token") or "") + if response.status_code < 400 and token: + identity = self._identity(token) + if not identity.get("ok"): + self.cancel(flow_id) + return {"ok": False, "state": "error", "error": identity["error"]} + # Linearize completion against cancellation. If cancel won the + # lock while either GitHub request was in flight, never persist. + with self._lock: + if self._flows.get(flow_id) is not flow: + return { + "ok": False, + "state": "error", + "error": "Device sign-in was cancelled.", + } + self._store_token(data, identity) + self._flows.pop(flow_id, None) + return { + "ok": True, + "state": "complete", + "account": identity["login"], + } + + error = str(data.get("error") or "") + if error == "authorization_pending": + return {"ok": True, "state": "pending", "retry_after": flow.interval} + if error == "slow_down": + with self._lock: + current = self._flows.get(flow_id) + if current is not None: + current.interval = max( + current.interval + 5, + _positive_int(data.get("interval"), current.interval + 5), + ) + current.next_poll_at = time.monotonic() + current.interval + retry_after = current.interval + else: + retry_after = flow.interval + 5 + return {"ok": True, "state": "pending", "retry_after": retry_after} + if error in {"expired_token", "token_expired"}: + self.cancel(flow_id) + return { + "ok": False, + "state": "expired", + "error": "The GitHub device code expired. Start again.", + } + if error == "access_denied": + self.cancel(flow_id) + return { + "ok": False, + "state": "denied", + "error": "GitHub sign-in was cancelled.", + } + + self.cancel(flow_id) + return { + "ok": False, + "state": "error", + "error": _error_message(data, "GitHub could not complete device sign-in."), + } + + def cancel(self, flow_id: str) -> dict[str, Any]: + with self._lock: + removed = self._flows.pop(str(flow_id or ""), None) is not None + return {"ok": True, "cancelled": removed} + + def _identity(self, token: str) -> dict[str, Any]: + try: + response = httpx.get( + "https://api.github.com/user", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "OpenWorker", + }, + timeout=20.0, + ) + data = _json_object(response) + except (httpx.HTTPError, ValueError): + return {"ok": False, "error": "Could not validate the GitHub account."} + login = str(data.get("login") or "") + if response.status_code >= 400 or not login or not data.get("id"): + return {"ok": False, "error": "GitHub returned a token without a valid account."} + return {"ok": True, "login": login, "id": str(data["id"])} + + def _store_token(self, token_data: dict[str, Any], identity: dict[str, Any]) -> None: + existing = self._secrets.get("github:default") or {} + relay = existing.get("mode") == "relay" + profile = dict(existing) if relay else {} + profile.update( + { + "type": "oauth", + "auth_method": "device", + "token": str(token_data["access_token"]), + "token_type": str(token_data.get("token_type") or "bearer"), + "scope": str(token_data.get("scope") or ""), + "account": identity["login"], + "account_id": identity["id"], + "enabled": True, + } + ) + if token_data.get("expires_in"): + profile["expires_at"] = time.time() + _positive_int( + token_data["expires_in"], 0 + ) + else: + profile.pop("expires_at", None) + if token_data.get("refresh_token"): + profile["refresh_token"] = str(token_data["refresh_token"]) + profile["refresh_token_expires_at"] = time.time() + _positive_int( + token_data.get("refresh_token_expires_in"), 0 + ) + else: + profile.pop("refresh_token", None) + profile.pop("refresh_token_expires_at", None) + self._secrets.put("github:default", profile) + + def _prune_locked(self, now: float) -> None: + expired = [ + flow_id + for flow_id, flow in self._flows.items() + if now >= flow.expires_at_monotonic + ] + for flow_id in expired: + self._flows.pop(flow_id, None) + + +def _json_object(response: httpx.Response) -> dict[str, Any]: + data = response.json() + if not isinstance(data, dict): + raise ValueError("expected JSON object") + return data + + +def _positive_int(value: Any, fallback: int) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return fallback + + +def _error_message(data: dict[str, Any], fallback: str) -> str: + code = str(data.get("error") or "") + messages = { + "device_flow_disabled": ( + "GitHub Device Flow is disabled for this OAuth app. " + "Ask the app owner to enable it." + ), + "incorrect_client_credentials": "The configured GitHub OAuth client ID is invalid.", + "incorrect_device_code": "GitHub rejected the device code. Start again.", + "unsupported_grant_type": "GitHub rejected the device authorization grant.", + } + return messages.get(code) or str(data.get("error_description") or fallback) diff --git a/coworker/connectors/setup.py b/coworker/connectors/setup.py index 57d5476d..7783a7d5 100644 --- a/coworker/connectors/setup.py +++ b/coworker/connectors/setup.py @@ -103,6 +103,9 @@ def connector_list(secrets: SecretStore) -> list[dict[str, Any]]: "managed_paused": d.managed_paused, # Whether THIS profile came from managed OAuth (vs manual paste). "managed_profile": bool(profile.get("managed")), + # Local credential source when present ("device" for GitHub Device + # Flow; empty for legacy/manual profiles). Never exposes credentials. + "auth_method": profile.get("auth_method") or "", # "relay" for the managed cloud path; empty for manual/token connect. "mode": profile.get("mode") or "", } diff --git a/coworker/server/app.py b/coworker/server/app.py index 1902ead6..11247334 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -163,6 +163,10 @@ def _connector_title(name: str) -> str: def create_app(manager: SessionManager) -> FastAPI: + from ..connectors.github.auth import GitHubDeviceAuth + + github_device_auth = GitHubDeviceAuth(manager.secrets) + @asynccontextmanager async def lifespan(_app: FastAPI): try: @@ -817,6 +821,28 @@ async def github_status() -> dict[str, Any]: """GitHub health: relay socket / cloud sign-in / per-installation tokens.""" return manager.github_status() + @app.post("/v1/connectors/github/device/start") + async def github_device_start() -> dict[str, Any]: + """Start a direct GitHub Device Flow; no cloud broker or secret involved.""" + from ..config import load_config + + cfg = load_config(manager.default_workspace) + return await asyncio.to_thread( + github_device_auth.start, + cfg.github_oauth_client_id, + cfg.github_oauth_scopes, + ) + + @app.post("/v1/connectors/github/device/{flow_id}/poll") + async def github_device_poll(flow_id: str) -> dict[str, Any]: + """Poll GitHub no faster than its requested interval and persist success.""" + return await asyncio.to_thread(github_device_auth.poll, flow_id) + + @app.delete("/v1/connectors/github/device/{flow_id}") + def github_device_cancel(flow_id: str) -> dict[str, Any]: + """Forget local pending state; GitHub lets the short-lived code expire.""" + return github_device_auth.cancel(flow_id) + @app.post("/v1/connectors/gmail/accounts/{email}/disconnect") async def gmail_account_disconnect(email: str) -> dict[str, Any]: """Drop ONE mailbox (cloud metadata best-effort first, like a full diff --git a/docs/config.example.toml b/docs/config.example.toml index 9638ad23..5ff1be4c 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -22,3 +22,8 @@ auto_allow = ["write_file", "replace_in_file", "apply_patch", "apply_unified_dif # Server (openworker-server) bind address. host = "127.0.0.1" port = 8765 + +# Public GitHub OAuth App identifier for brokerless Device Flow sign-in. +# The app owner must enable Device Flow. Never put a client secret here. +github_oauth_client_id = "" +github_oauth_scopes = "repo" diff --git a/surfaces/gui/e2e/github-page.spec.ts b/surfaces/gui/e2e/github-page.spec.ts index 6536ce9a..99eccea4 100644 --- a/surfaces/gui/e2e/github-page.spec.ts +++ b/surfaces/gui/e2e/github-page.spec.ts @@ -88,6 +88,67 @@ test("modal has ONE connect button and sends no flow — authorize-first lives i await expect.poll(() => flowSent).toBe(""); }); +test("manual pane completes brokerless device sign-in without disturbing relay installs", async ({ + page, +}) => { + let polls = 0; + await page.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (text: string) => { + (globalThis as any).__githubCopiedCode = text; + }, + }, + }); + window.open = ((url?: string | URL) => { + (globalThis as any).__githubOpenedUrl = String(url || ""); + return null; + }) as typeof window.open; + }); + await page.route("**/v1/connectors/github/device/start", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + ok: true, + flow_id: "e2e-opaque-flow", + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 1, + }), + }), + ); + await page.route("**/v1/connectors/github/device/e2e-opaque-flow/poll", (route) => { + polls += 1; + return route.fulfill({ + contentType: "application/json", + body: JSON.stringify( + polls === 1 + ? { ok: true, state: "pending", retry_after: 1 } + : { ok: true, state: "complete", account: "rohit-dev" }, + ), + }); + }); + + await openGithubPage(page); + await page.getByTestId("add-installation-btn").click(); + const modal = page.getByTestId("add-connection-modal"); + await modal.getByTestId("modal-pane-manual").click(); + await modal.getByTestId("github-device-start").click(); + await expect(modal.getByTestId("github-user-code")).toHaveText("WDJB-MJHT"); + await modal.getByTestId("github-device-copy-open").click(); + await expect + .poll(() => page.evaluate(() => (globalThis as any).__githubCopiedCode)) + .toBe("WDJB-MJHT"); + await expect + .poll(() => page.evaluate(() => (globalThis as any).__githubOpenedUrl)) + .toBe("https://github.com/login/device"); + + await expect(modal).toHaveCount(0, { timeout: 10_000 }); + await expect(page.getByTestId("github-install-101")).toBeVisible(); +}); + test("disconnect removes one installation and keeps the rest", async ({ page }) => { await openGithubPage(page); // add a second installation first (signed-in one-click) diff --git a/surfaces/gui/e2e/search-modal.spec.ts b/surfaces/gui/e2e/search-modal.spec.ts new file mode 100644 index 00000000..84f737e0 --- /dev/null +++ b/surfaces/gui/e2e/search-modal.spec.ts @@ -0,0 +1,76 @@ +// Search command palette (#282): must stay viewport-centered even when opened from the +// collapsed/peeked sidebar, whose CSS transform would otherwise become the containing block +// for a nested `position: fixed` overlay. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function ready(page: import("@playwright/test").Page) { + await page.goto("/"); + await expect(page.locator(".app")).not.toHaveClass(/boot-splash/); + await expect(page.locator(".sidebar")).toBeVisible(); +} + +test("search from expanded sidebar is centered in the viewport", async ({ page }) => { + await ready(page); + await page.locator(".sidebar").getByRole("button", { name: "Search", exact: true }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + await expect(page.getByPlaceholder("Search chats")).toBeVisible(); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); + +test("search from peeked collapsed sidebar stays viewport-centered", async ({ page }) => { + await ready(page); + const app = page.locator(".app"); + + await page.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(app).toHaveClass(/nav-collapsed/); + + // Hover the left-edge zone to peek the floating sidebar, then open Search from it. + await page.locator(".nav-hover-zone").hover(); + await expect(app).toHaveClass(/nav-peek/); + await page.locator(".sidebar").getByRole("button", { name: "Search", exact: true }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + + // Peek should dismiss so the floating sidebar does not cover the palette. + await expect(app).not.toHaveClass(/nav-peek/); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); + +test("collapsed topbar search is also viewport-centered", async ({ page }) => { + await ready(page); + await page.getByRole("button", { name: "Collapse sidebar" }).click(); + await expect(page.locator(".app")).toHaveClass(/nav-collapsed/); + + const cluster = page.getByTestId("topbar-cluster"); + await expect(cluster).toBeVisible(); + await cluster.getByRole("button", { name: "Search" }).click(); + + const panel = page.getByTestId("search-modal-panel"); + await expect(panel).toBeVisible(); + + const box = await panel.boundingBox(); + const viewport = page.viewportSize(); + expect(box).toBeTruthy(); + expect(viewport).toBeTruthy(); + const panelCenter = box!.x + box!.width / 2; + const viewportCenter = viewport!.width / 2; + expect(Math.abs(panelCenter - viewportCenter)).toBeLessThan(8); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 57f1ba8c..d4941068 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -306,8 +306,10 @@ export function App() { window.addEventListener("ocw-open-artifact", show); return () => window.removeEventListener("ocw-open-artifact", show); }, []); - // The command-palette search, openable from the collapsed-sidebar topbar cluster (§22). The - // expanded sidebar owns its own instance; this one exists so search never disappears with it. + // The command-palette search — single app-level instance for both the expanded sidebar's + // Search row and the collapsed-sidebar topbar cluster (§22). Mounting it here (and portaling + // to document.body) keeps `position: fixed` viewport-centered even when the collapsed sidebar + // applies a CSS transform for peek/slide (#282). const [searchOpen, setSearchOpen] = useState(false); // A pending composer prefill (text + attachments) pushed from the session start panel. const [composerPrefill, setComposerPrefill] = useState<{ text: string; attachments?: Attachment[]; nonce: number }>(); @@ -1335,6 +1337,7 @@ export function App() { collapsed={navCollapsed} onCollapse={toggleNav} onPeekLeave={() => setNavPeek(false)} + onOpenSearch={() => setSearchOpen(true)} /> {surface === "scheduled" ? ( )} - {/* Search from the collapsed-sidebar topbar cluster (the sidebar's own instance is - unreachable while it's collapsed). */} + {/* Single SearchModal for sidebar Search and the collapsed topbar cluster. */} {searchOpen && ( { + const res = await fetch(`${httpBase()}/v1/connectors/github/device/start`, { + method: "POST", + }); + return res.json(); +} + +export async function pollGithubDeviceAuth(flowId: string): Promise { + const res = await fetch( + `${httpBase()}/v1/connectors/github/device/${encodeURIComponent(flowId)}/poll`, + { method: "POST" }, + ); + return res.json(); +} + +export async function cancelGithubDeviceAuth(flowId: string): Promise<{ ok: boolean }> { + const res = await fetch( + `${httpBase()}/v1/connectors/github/device/${encodeURIComponent(flowId)}`, + { method: "DELETE" }, + ); + return res.json(); +} + /** One-click connect for an MCP-backed connector (monday, asana, jira): the sidecar * opens the vendor's sign-in in the browser (local OAuth, no cloud account needed); * poll getConnectors until the card flips to connected. */ diff --git a/surfaces/gui/src/components/SearchModal.tsx b/surfaces/gui/src/components/SearchModal.tsx index ebb59d95..2eb39da3 100644 --- a/surfaces/gui/src/components/SearchModal.tsx +++ b/surfaces/gui/src/components/SearchModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { Persona } from "../api"; import type { SessionInfo } from "../types"; import { isProjectScoped, shortPersonaName } from "../personaScope"; @@ -8,6 +9,10 @@ import { baseName } from "../paths"; // Command-palette search (Codex-style): clicking Search opens this overlay over the whole app // rather than filtering the sidebar in place (which made the grouped list collapse). It searches // ALL sessions, split into Pinned + Recent, filters as you type, and supports ↑/↓ + Enter + ⌘1–9. +// +// Portaled to document.body so `position: fixed` is always viewport-relative. The collapsed +// sidebar uses `transform` for peek/slide (#282), which would otherwise become the containing +// block for any fixed descendant mounted inside `.sidebar`. const byRecent = (a: SessionInfo, b: SessionInfo) => (b.updated_at || "").localeCompare(a.updated_at || ""); @@ -109,10 +114,14 @@ export function SearchModal({ ); }; - return ( -
+ // z-[70] sits above the collapsed sidebar peek (z-60) so the palette is never covered. + return createPortal( +
-
+
-
+
, + document.body, ); } diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index 9f0215dd..472d0bf6 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -24,7 +24,6 @@ import { isProjectScoped, shortPersonaName } from "../personaScope"; import { ConnectorIcon } from "../connectors/ConnectorIcon"; import { Icon, type IconName } from "./Icon"; import { PersonaGlyph, personaGlyph } from "./personaIcon"; -import { SearchModal } from "./SearchModal"; import { baseName } from "../paths"; import { showPersonas } from "../flags"; @@ -146,6 +145,9 @@ interface Props { collapsed?: boolean; onCollapse?: () => void; onPeekLeave?: () => void; + // Opens the app-level SearchModal. Kept out of this tree so the collapsed sidebar's + // `transform` cannot become the containing block for `position: fixed` (#282). + onOpenSearch?: () => void; } // Compact age for project session rows: "now" / "5m" / "6h" / "3d" / "2w" / "4mo" / "2y". @@ -171,7 +173,6 @@ const compactAge = (iso?: string | null): string => { // Sessions shown per group before "Show more" comes from Settings (sessions_peek, default 5). export function Sidebar(props: Props) { - const [searchModalOpen, setSearchModalOpen] = useState(false); const [appMenuOpen, setAppMenuOpen] = useState(false); // The account row (§26): cloud sign-in status drives the avatar/name/dot; refreshed on // focus and whenever the menu opens (sign-in completes out-of-band in the browser). @@ -1014,11 +1015,16 @@ export function Sidebar(props: Props) { /> {/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette - SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */} + SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. + Opens via App so the palette is never mounted under the transformed collapsed sidebar. */}
@@ -1270,17 +1276,6 @@ export function Sidebar(props: Props) {
- {searchModalOpen && ( - { - setSearchModalOpen(false); - props.onSelectSession(id, ws, ag); - }} - onClose={() => setSearchModalOpen(false)} - /> - )}
); } diff --git a/surfaces/gui/src/components/connectors/AddConnectionModal.tsx b/surfaces/gui/src/components/connectors/AddConnectionModal.tsx index 94d8372a..a81a9601 100644 --- a/surfaces/gui/src/components/connectors/AddConnectionModal.tsx +++ b/surfaces/gui/src/components/connectors/AddConnectionModal.tsx @@ -10,6 +10,7 @@ import { import { ConnectorBadge } from "../../connectors/ConnectorIcon"; import { ConnectSetup } from "../ManageTabs"; import { CloudSignInInline, CloudStatusPending } from "./CloudSignIn"; +import { GitHubDeviceAuth } from "./GitHubDeviceAuth"; import { PILL_ACCENT, PILL_LINE, TAG_ACCENT } from "./ui"; // The ONE place a connection gets added (UX-DECISIONS §21): the detail page's header @@ -103,6 +104,18 @@ export function AddConnectionModal({ ) ) : c.name === "slack" ? ( { onChanged(); onClose(); }} /> + ) : c.name === "github" ? ( +
+ { onChanged(); onClose(); }} /> +
+ + Advanced: personal access token + +
+
+ { onChanged(); onClose(); }} manualOnly /> +
+
) : (
{ onChanged(); onClose(); }} manualOnly /> diff --git a/surfaces/gui/src/components/connectors/GitHubDeviceAuth.test.tsx b/surfaces/gui/src/components/connectors/GitHubDeviceAuth.test.tsx new file mode 100644 index 00000000..9a40f104 --- /dev/null +++ b/surfaces/gui/src/components/connectors/GitHubDeviceAuth.test.tsx @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { GitHubDeviceAuth } from "./GitHubDeviceAuth"; + +const mocks = vi.hoisted(() => ({ + start: vi.fn(), + poll: vi.fn(), + cancel: vi.fn(), + openExternal: vi.fn(), +})); + +vi.mock("../../api", () => ({ + startGithubDeviceAuth: mocks.start, + pollGithubDeviceAuth: mocks.poll, + cancelGithubDeviceAuth: mocks.cancel, +})); +vi.mock("../../tauri", () => ({ openExternal: mocks.openExternal })); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const started = { + ok: true, + flow_id: "opaque-flow", + user_code: "WDJB-MJHT", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, +}; + +describe("GitHubDeviceAuth", () => { + it("starts locally and displays the user code while polling", async () => { + mocks.start.mockResolvedValue(started); + mocks.poll.mockResolvedValue({ ok: true, state: "pending", retry_after: 30 }); + + render(); + fireEvent.click(screen.getByTestId("github-device-start")); + + expect((await screen.findByTestId("github-user-code")).textContent).toContain("WDJB-MJHT"); + expect(screen.getByTestId("github-device-waiting").textContent).toContain( + "Waiting for approval on GitHub", + ); + await waitFor(() => expect(mocks.poll).toHaveBeenCalledWith("opaque-flow")); + }); + + it("copies the code and opens GitHub from the user gesture", async () => { + mocks.start.mockResolvedValue(started); + mocks.poll.mockResolvedValue({ ok: true, state: "pending", retry_after: 30 }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render(); + fireEvent.click(screen.getByTestId("github-device-start")); + const open = await screen.findByTestId("github-device-copy-open"); + fireEvent.click(open); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith("WDJB-MJHT")); + expect(mocks.openExternal).toHaveBeenCalledWith("https://github.com/login/device"); + expect(open.textContent).toContain("Copied"); + }); + + it("reports completion to the modal", async () => { + mocks.start.mockResolvedValue(started); + mocks.poll.mockResolvedValue({ + ok: true, + state: "complete", + account: "octocat", + }); + const connected = vi.fn(); + + render(); + fireEvent.click(screen.getByTestId("github-device-start")); + + await waitFor(() => expect(connected).toHaveBeenCalledTimes(1)); + }); + + it("cancels sidecar state when the modal unmounts", async () => { + mocks.start.mockResolvedValue(started); + mocks.poll.mockResolvedValue({ ok: true, state: "pending", retry_after: 30 }); + mocks.cancel.mockResolvedValue({ ok: true }); + const view = render(); + fireEvent.click(screen.getByTestId("github-device-start")); + await screen.findByTestId("github-user-code"); + + view.unmount(); + + await waitFor(() => expect(mocks.cancel).toHaveBeenCalledWith("opaque-flow")); + }); + + it("shows configuration failures without entering the waiting state", async () => { + mocks.start.mockResolvedValue({ + ok: false, + error: "GitHub device sign-in is not configured.", + }); + + render(); + fireEvent.click(screen.getByTestId("github-device-start")); + + expect( + await screen.findByText("GitHub device sign-in is not configured."), + ).toBeTruthy(); + expect(screen.queryByTestId("github-user-code")).toBeNull(); + }); +}); diff --git a/surfaces/gui/src/components/connectors/GitHubDeviceAuth.tsx b/surfaces/gui/src/components/connectors/GitHubDeviceAuth.tsx new file mode 100644 index 00000000..26f7c7d2 --- /dev/null +++ b/surfaces/gui/src/components/connectors/GitHubDeviceAuth.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import { + cancelGithubDeviceAuth, + pollGithubDeviceAuth, + startGithubDeviceAuth, +} from "../../api"; +import { openExternal } from "../../tauri"; +import { PILL_ACCENT, PILL_LINE, TAG_ACCENT } from "./ui"; + +type Flow = { + id: string; + code: string; + url: string; + interval: number; + expiresIn: number; +}; + +export function GitHubDeviceAuth({ onConnected }: { onConnected: () => void }) { + const [starting, setStarting] = useState(false); + const [flow, setFlow] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const onConnectedRef = useRef(onConnected); + onConnectedRef.current = onConnected; + + useEffect(() => { + if (!flow) return; + let stopped = false; + let timer: ReturnType | null = null; + + const tick = async () => { + try { + const result = await pollGithubDeviceAuth(flow.id); + if (stopped) return; + if (result.state === "complete") { + setFlow(null); + onConnectedRef.current(); + return; + } + if (result.state !== "pending") { + setFlow(null); + setError(result.error || "GitHub sign-in did not complete."); + return; + } + setError(result.error || null); + timer = setTimeout(tick, Math.max(1, result.retry_after || flow.interval) * 1000); + } catch { + if (!stopped) { + setError("Could not check GitHub yet; retrying."); + timer = setTimeout(tick, Math.max(2, flow.interval) * 1000); + } + } + }; + + timer = setTimeout(tick, 0); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + // Closing the modal, switching panes, or navigating away must also + // forget the server-side device code rather than merely stopping UI polls. + void Promise.resolve(cancelGithubDeviceAuth(flow.id)).catch(() => undefined); + }; + }, [flow]); + + const start = async () => { + setStarting(true); + setError(null); + setCopied(false); + try { + const result = await startGithubDeviceAuth(); + if ( + !result.ok || + !result.flow_id || + !result.user_code || + !result.verification_uri + ) { + setError(result.error || "Could not start GitHub sign-in."); + return; + } + setFlow({ + id: result.flow_id, + code: result.user_code, + url: result.verification_uri, + interval: Math.max(1, result.interval || 5), + expiresIn: Math.max(1, result.expires_in || 900), + }); + } catch { + setError("Could not reach the local OpenWorker service."); + } finally { + setStarting(false); + } + }; + + const copyAndOpen = async () => { + if (!flow) return; + try { + await navigator.clipboard?.writeText(flow.code); + setCopied(true); + } catch { + setCopied(false); + } + openExternal(flow.url); + }; + + const cancel = async () => { + const id = flow?.id; + setFlow(null); + setError(null); + setCopied(false); + if (id) await cancelGithubDeviceAuth(id).catch(() => undefined); + }; + + if (!flow) { + return ( +
+

+ Sign in directly with GitHub. No OpenWorker Cloud account, callback server, or + manually-created token is needed. +

+ + {error &&
{error}
} +

+ Local tools only · credentials stay on this + computer +

+
+ ); + } + + return ( +
+

+ Copy this one-time code, then approve OpenWorker on GitHub. +

+
+
+ {flow.code} +
+
+ Expires in about {Math.max(1, Math.round(flow.expiresIn / 60))} minutes +
+
+ +
+ + + Waiting for approval on GitHub… + + +
+ {error &&
{error}
} + + {flow.url} + +
+ ); +} diff --git a/surfaces/gui/src/components/connectors/GithubDetail.tsx b/surfaces/gui/src/components/connectors/GithubDetail.tsx index bfeb6ed8..32a81f3d 100644 --- a/surfaces/gui/src/components/connectors/GithubDetail.tsx +++ b/surfaces/gui/src/components/connectors/GithubDetail.tsx @@ -76,7 +76,9 @@ export function GithubDetail({ c, cloud, onChanged }: DetailProps) { {relay ? relayHealth(status).text - : "Connected · personal access token"} + : c.auth_method === "device" + ? `Connected as ${c.account || "GitHub user"} · local OAuth` + : "Connected · personal access token"} ) : ( @@ -100,7 +102,9 @@ export function GithubDetail({ c, cloud, onChanged }: DetailProps) {
One @ocw-agent App, installed per account or org — you pick the repos on GitHub; each installation keeps its own allow-list. - {cloud?.signed_in ? "" : " One-click needs cloud sign-in; a PAT works without it."} + {cloud?.signed_in + ? "" + : " One-click needs cloud sign-in; local GitHub sign-in works without it."}
)} @@ -116,11 +120,14 @@ export function GithubDetail({ c, cloud, onChanged }: DetailProps) { /> ))} - {/* Manual PAT: request/response tools only — no inbound triggers. */} + {/* Local user auth: request/response tools only — no inbound triggers. */} {c.connected && !relay && (
- Personal access token · tools only. Install the GitHub App to let + {c.auth_method === "device" + ? `Signed in locally${c.account ? ` as @${c.account}` : ""}` + : "Personal access token"}{" "} + · tools only. Install the GitHub App to let @-mentions and the agent label reach this computer.
diff --git a/tests/test_github_device_auth.py b/tests/test_github_device_auth.py new file mode 100644 index 00000000..dedcdc5e --- /dev/null +++ b/tests/test_github_device_auth.py @@ -0,0 +1,301 @@ +"""GitHub OAuth Device Flow: local protocol, persistence, and sidecar API.""" + +from __future__ import annotations + +import json +import threading + +import httpx +from fastapi.testclient import TestClient + +from coworker.connectors.github import auth as github_auth +from coworker.connectors.github.auth import GitHubDeviceAuth +from coworker.secrets import SecretStore +from coworker.server import SessionManager, create_app + + +def _response(data: dict, status: int = 200) -> httpx.Response: + return httpx.Response(status, json=data) + + +def test_start_requires_configured_public_client_id(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + auth = GitHubDeviceAuth(SecretStore()) + called = False + + def unexpected_post(*_args, **_kwargs): + nonlocal called + called = True + + monkeypatch.setattr(github_auth.httpx, "post", unexpected_post) + result = auth.start("") + + assert result == { + "ok": False, + "error": ( + "GitHub device sign-in is not configured. " + "Set github_oauth_client_id in config.toml." + ), + } + assert called is False + + +def test_start_returns_only_public_device_fields(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + calls = [] + + def post(url, **kwargs): + calls.append((url, kwargs)) + return _response( + { + "device_code": "server-only-secret", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + } + ) + + monkeypatch.setattr(github_auth.httpx, "post", post) + result = GitHubDeviceAuth(SecretStore()).start("public-client", "repo read:user") + + assert result["ok"] is True + assert result["user_code"] == "WDJB-MJHT" + assert result["verification_uri"] == "https://github.com/login/device" + assert result["flow_id"] + assert "device_code" not in result + assert "server-only-secret" not in json.dumps(result) + assert calls[0][1]["data"] == { + "client_id": "public-client", + "scope": "repo read:user", + } + + +def test_poll_enforces_interval_handles_slow_down_and_stores_token( + tmp_path, monkeypatch +): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + clock = [100.0] + monkeypatch.setattr(github_auth.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(github_auth.time, "time", lambda: 1_000.0 + clock[0]) + token_polls = [] + + def post(url, **kwargs): + if url == github_auth.DEVICE_CODE_URL: + return _response( + { + "device_code": "private-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + } + ) + token_polls.append(kwargs["data"]) + if len(token_polls) == 1: + return _response({"error": "authorization_pending"}) + if len(token_polls) == 2: + return _response({"error": "slow_down"}) + return _response( + { + "access_token": "gho_local-token", + "token_type": "bearer", + "scope": "repo", + } + ) + + def get(url, **_kwargs): + assert url == "https://api.github.com/user" + return _response({"id": 42, "login": "octocat"}) + + monkeypatch.setattr(github_auth.httpx, "post", post) + monkeypatch.setattr(github_auth.httpx, "get", get) + store = SecretStore() + store.put( + "github:default", + {"type": "oauth", "managed": True, "mode": "relay", "enabled": True}, + ) + auth = GitHubDeviceAuth(store) + flow_id = auth.start("public-client")["flow_id"] + + # The first GUI poll is answered locally until GitHub's interval elapses. + assert auth.poll(flow_id) == { + "ok": True, + "state": "pending", + "retry_after": 5, + } + assert len(token_polls) == 0 + clock[0] = 105.0 + assert auth.poll(flow_id) == { + "ok": True, + "state": "pending", + "retry_after": 5, + } + # A fast duplicate GUI poll is answered locally. + assert auth.poll(flow_id)["state"] == "pending" + assert len(token_polls) == 1 + + clock[0] = 110.0 + slowed = auth.poll(flow_id) + assert slowed == {"ok": True, "state": "pending", "retry_after": 10} + clock[0] = 119.0 + assert auth.poll(flow_id)["state"] == "pending" + assert len(token_polls) == 2 + + clock[0] = 120.0 + complete = auth.poll(flow_id) + assert complete == {"ok": True, "state": "complete", "account": "octocat"} + assert "gho_local-token" not in json.dumps(complete) + profile = store.get("github:default") + assert profile["token"] == "gho_local-token" + assert profile["auth_method"] == "device" + assert profile["account"] == "octocat" + assert profile["account_id"] == "42" + # A user credential can coexist with the App installation relay. + assert profile["mode"] == "relay" + assert profile["managed"] is True + assert auth.poll(flow_id)["state"] == "error" + + +def test_denial_removes_pending_flow(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + clock = [0.0] + monkeypatch.setattr(github_auth.time, "monotonic", lambda: clock[0]) + + def post(url, **_kwargs): + if url == github_auth.DEVICE_CODE_URL: + return _response( + { + "device_code": "device", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + } + ) + return _response({"error": "access_denied"}) + + monkeypatch.setattr(github_auth.httpx, "post", post) + auth = GitHubDeviceAuth(SecretStore()) + flow_id = auth.start("client")["flow_id"] + + clock[0] = 5.0 + denied = auth.poll(flow_id) + assert denied["state"] == "denied" + assert auth.poll(flow_id)["error"] == "Device sign-in flow not found." + assert SecretStore().get("github:default") is None + + +def test_local_expiry_has_distinct_state(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + clock = [10.0] + monkeypatch.setattr(github_auth.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr( + github_auth.httpx, + "post", + lambda *_args, **_kwargs: _response( + { + "device_code": "device", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 1, + "interval": 5, + } + ), + ) + auth = GitHubDeviceAuth(SecretStore()) + flow_id = auth.start("client")["flow_id"] + + clock[0] = 11.0 + expired = auth.poll(flow_id) + assert expired == { + "ok": False, + "state": "expired", + "error": "The GitHub device code expired. Start again.", + } + + +def test_cancel_wins_against_inflight_success(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path)) + clock = [0.0] + monkeypatch.setattr(github_auth.time, "monotonic", lambda: clock[0]) + entered = threading.Event() + release = threading.Event() + + def post(url, **_kwargs): + if url == github_auth.DEVICE_CODE_URL: + return _response( + { + "device_code": "device", + "user_code": "ABCD-EFGH", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 1, + } + ) + entered.set() + assert release.wait(timeout=2) + return _response({"access_token": "gho_must-not-persist"}) + + monkeypatch.setattr(github_auth.httpx, "post", post) + monkeypatch.setattr( + github_auth.httpx, + "get", + lambda *_args, **_kwargs: _response({"id": 42, "login": "octocat"}), + ) + store = SecretStore() + auth = GitHubDeviceAuth(store) + flow_id = auth.start("client")["flow_id"] + clock[0] = 1.0 + result = {} + + thread = threading.Thread(target=lambda: result.update(auth.poll(flow_id))) + thread.start() + assert entered.wait(timeout=2) + assert auth.cancel(flow_id) == {"ok": True, "cancelled": True} + release.set() + thread.join(timeout=2) + + assert result["state"] == "error" + assert result["error"] == "Device sign-in was cancelled." + assert store.get("github:default") is None + + +def test_sidecar_routes_use_config_and_never_expose_device_code(tmp_path, monkeypatch): + state = tmp_path / "state" + state.mkdir() + monkeypatch.setenv("COWORKER_STATE_DIR", str(state)) + (state / "config.toml").write_text( + 'github_oauth_client_id = "configured-client"\n' + 'github_oauth_scopes = "repo"\n', + encoding="utf-8", + ) + + def post(url, **kwargs): + assert url == github_auth.DEVICE_CODE_URL + assert kwargs["data"]["client_id"] == "configured-client" + return _response( + { + "device_code": "route-private-code", + "user_code": "ROUT-CODE", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + } + ) + + monkeypatch.setattr(github_auth.httpx, "post", post) + manager = SessionManager(workspace=tmp_path) + with TestClient(create_app(manager)) as client: + started = client.post("/v1/connectors/github/device/start") + assert started.status_code == 200 + payload = started.json() + assert payload["ok"] is True + assert payload["user_code"] == "ROUT-CODE" + assert "device_code" not in payload + assert "route-private-code" not in started.text + + cancelled = client.delete( + f"/v1/connectors/github/device/{payload['flow_id']}" + ) + assert cancelled.json() == {"ok": True, "cancelled": True}