diff --git a/README.md b/README.md index dc933832..d96cf139 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ npm install npm run dev # browser UI on the Vite dev port ``` +The standalone server creates a per-launch token at +`/sidecar-8765.token`; Vite reads that user-only file when it starts. +For direct API calls, send its value in the `X-OpenWorker-Token` header. The +desktop app uses an in-memory launch token instead and never writes it to disk. + To run the full desktop app instead of the browser UI, replace step 3 with `npm run tauri dev` (from `surfaces/gui/`) - the Tauri shell launches the window and supervises the server itself. Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gui` (GUI unit + hermetic end-to-end). Desktop bundles are built with `packaging/build_dmg.sh` / `packaging/build_windows.ps1`. diff --git a/coworker/cloud.py b/coworker/cloud.py index df209cb1..3850de5e 100644 --- a/coworker/cloud.py +++ b/coworker/cloud.py @@ -56,6 +56,8 @@ PROVIDER_FOR_CONNECTOR = { # outlives the sidecar process simply has to be restarted. _pending_logins: dict[str, dict[str, float | str]] = {} _PENDING_TTL = 600 +_pending_managed_states: dict[str, float] = {} +_MANAGED_STATE_TTL = 600 def _b64url(raw: bytes) -> str: @@ -382,6 +384,7 @@ def begin_managed_connect( return {"ok": False, "error": f"cloud unreachable: {type(exc).__name__}"} if resp.status_code != 200: return {"ok": False, "error": f"start failed ({resp.status_code})"} + _pending_managed_states[app_state] = _now() return { "ok": True, "authorize_url": resp.json()["authorize_url"], @@ -389,6 +392,14 @@ def begin_managed_connect( } +def consume_managed_state(state: str) -> bool: + """Consume one recent managed-OAuth callback state exactly once.""" + if not state: + return False + created = _pending_managed_states.pop(state, None) + return created is not None and created >= _now() - _MANAGED_STATE_TTL + + def managed_profile_from_callback(form: dict[str, str]) -> dict[str, Any]: """Local connector profile from the broker's form-POST payload. diff --git a/coworker/secrets.py b/coworker/secrets.py index 97f40665..6c1c0326 100644 --- a/coworker/secrets.py +++ b/coworker/secrets.py @@ -88,6 +88,21 @@ def _restrict_to_user(path: Path, *, is_dir: bool) -> None: os.chmod(path, 0o700 if is_dir else 0o600) +def write_private_text(path: str | Path, content: str) -> Path: + """Atomically write a user-only text file using the SecretStore's OS protections.""" + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + try: + _restrict_to_user(target.parent, is_dir=True) + except OSError: + pass + tmp = target.with_name(target.name + ".tmp") + tmp.write_text(content, encoding="utf-8") + _restrict_to_user(tmp, is_dir=False) + os.replace(tmp, target) + return target + + class SecretStore: """File-backed secret store. Reads resolve `${VAR}` refs; status never leaks values.""" diff --git a/coworker/server/app.py b/coworker/server/app.py index dec49a38..baeaa2e7 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -11,6 +11,7 @@ import asyncio import json import os import re +import secrets import uuid from collections import deque from contextlib import asynccontextmanager @@ -19,6 +20,7 @@ from typing import Any, Optional from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse # Origins allowed to talk to the local sidecar. It binds to 127.0.0.1, but a page in the # user's own browser can still reach loopback — so without an origin gate, any website they @@ -177,6 +179,48 @@ def create_app(manager: SessionManager) -> FastAPI: await manager.aclose() # stop gateway + close MCP connections on shutdown app = FastAPI(title="coworker", version="0.0.0", lifespan=lifespan) + api_token = os.environ.get("COWORKER_API_TOKEN", "") + tokenless_paths = { + "/v1/health", + "/auth/callback", + "/mcp/oauth/callback", + "/oauth/callback", + } + + def _request_authenticated(request: Request) -> bool: + provided = request.headers.get("x-openworker-token", "") + return bool( + api_token + and provided + and secrets.compare_digest(provided, api_token) + ) + + def _websocket_authenticated(ws: WebSocket) -> bool: + if not api_token: + return True + protocols = { + part.strip() + for part in ws.headers.get("sec-websocket-protocol", "").split(",") + if part.strip() + } + return any(secrets.compare_digest(part, api_token) for part in protocols) + + @app.middleware("http") + async def require_sidecar_token(request: Request, call_next): + # Preflights carry the requested header name, not its value. CORS checks the + # Origin; the actual state-changing request still must authenticate. + if ( + not api_token + or request.method == "OPTIONS" + or request.url.path in tokenless_paths + or _request_authenticated(request) + ): + return await call_next(request) + return JSONResponse( + {"error": "missing or invalid OpenWorker sidecar token"}, + status_code=401, + ) + app.add_middleware( CORSMiddleware, # Pinned to the desktop webview + localhost (see _ALLOWED_ORIGIN_RE): stops a random @@ -188,7 +232,9 @@ def create_app(manager: SessionManager) -> FastAPI: app.state.manager = manager @app.get("/v1/health") - def health() -> dict[str, Any]: + def health(request: Request) -> dict[str, Any]: + if api_token and not _request_authenticated(request): + return {"status": "ok"} return { "status": "ok", "default_workspace": manager.default_workspace, @@ -1040,6 +1086,16 @@ def create_app(manager: SessionManager) -> FastAPI: form = await request.form() data = {k: str(v) for k, v in form.items()} connector = data.get("connector", "") + if not cloud.consume_managed_state(data.get("app_state", "")): + return HTMLResponse( + _browser_page( + "Connection failed", + _CONNECT_FAILED_DETAIL, + ok=False, + error="unknown or expired connection attempt", + ), + status_code=400, + ) if data.get("error"): return HTMLResponse( _browser_page( @@ -1389,13 +1445,16 @@ def create_app(manager: SessionManager) -> FastAPI: @app.websocket("/ws/session/{session_id}") async def ws_session(ws: WebSocket, session_id: str) -> None: + if not _websocket_authenticated(ws): + await ws.close(code=1008) + return # CORS never gates WebSockets, so a cross-site page could otherwise open this socket # and drive the session into tool calls. Reject a disallowed browser Origin before # accepting the handshake (1008 = policy violation). if not _origin_allowed(ws.headers.get("origin")): await ws.close(code=1008) return - await ws.accept() + await ws.accept(subprotocol="openworker" if api_token else None) agent = ws.query_params.get("agent") or "code" # All four interactive prompts (approval / question / directory / plan) are parked as Inbox @@ -1843,10 +1902,13 @@ def create_app(manager: SessionManager) -> FastAPI: """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 _websocket_authenticated(ws): + await ws.close(code=1008) + return if not _origin_allowed(ws.headers.get("origin")): await ws.close(code=1008) return - await ws.accept() + await ws.accept(subprotocol="openworker" if api_token else None) manager.register_event_client(ws.send_json) try: while True: diff --git a/coworker/server/run.py b/coworker/server/run.py index 7c10799d..de220c33 100644 --- a/coworker/server/run.py +++ b/coworker/server/run.py @@ -4,12 +4,13 @@ from __future__ import annotations import argparse import os +import secrets import sys from pathlib import Path from ..config import load_config from ..permissions import Mode -from ..secrets import state_dir +from ..secrets import state_dir, write_private_text from .app import _WS_MAX_FRAME_BYTES, create_app from .manager import SessionManager @@ -124,6 +125,17 @@ def _ensure_ca_bundle() -> None: pass +def _ensure_api_token(port: int) -> Path | None: + """Set launch auth; standalone/dev tokens use a user-only, port-specific file.""" + if os.environ.get("COWORKER_API_TOKEN"): + return None # Tauri supplied an in-memory token; never persist it. + token = secrets.token_hex(32) + os.environ["COWORKER_API_TOKEN"] = token + return write_private_text( + state_dir() / f"sidecar-{port}.token", token + "\n" + ) + + def main(argv=None) -> None: _ensure_ca_bundle() cfg = load_config() # global config supplies defaults @@ -144,14 +156,19 @@ def main(argv=None) -> None: # a random free port (to coexist with a hand-run server on 8765), so the # managed-connect redirect must follow the real port, not the 8765 default. os.environ["COWORKER_PORT"] = str(args.port) + generated_token_path = _ensure_api_token(args.port) + try: + import uvicorn - import uvicorn - - _exit_when_orphaned() - app = build_app(args.cwd, args.model, args.mode) - uvicorn.run( - app, host=args.host, port=args.port, ws_max_size=_WS_MAX_FRAME_BYTES - ) + _exit_when_orphaned() + app = build_app(args.cwd, args.model, args.mode) + uvicorn.run( + app, host=args.host, port=args.port, ws_max_size=_WS_MAX_FRAME_BYTES + ) + finally: + if generated_token_path is not None: + generated_token_path.unlink(missing_ok=True) + os.environ.pop("COWORKER_API_TOKEN", None) if __name__ == "__main__": diff --git a/surfaces/gui/README.md b/surfaces/gui/README.md index b62b314f..b17e54ed 100644 --- a/surfaces/gui/README.md +++ b/surfaces/gui/README.md @@ -27,7 +27,9 @@ bash platform/packaging/setup_dev_env.sh # → platform/.venv (server + this r ``` Open http://localhost:5173. The UI talks to `http://127.0.0.1:8765` (override with -`VITE_COWORKER_HTTP` / `VITE_COWORKER_WS`). +`VITE_COWORKER_HTTP` / `VITE_COWORKER_WS`). Start the server before Vite so the +UI can read its per-launch token from `/sidecar-8765.token`; restart +Vite if the server is restarted. ## Run the desktop app from source diff --git a/surfaces/gui/e2e-live/api-smoke.spec.ts b/surfaces/gui/e2e-live/api-smoke.spec.ts index 4d2935d4..d343f1d2 100644 --- a/surfaces/gui/e2e-live/api-smoke.spec.ts +++ b/surfaces/gui/e2e-live/api-smoke.spec.ts @@ -2,11 +2,11 @@ // /v1/providers to catch integration drift between the GUI's expectations and the backend's // responses. Skips cleanly when the backend is down, so it's safe to run anytime. No creds needed. import { expect, test } from "@playwright/test"; -import { BACKEND } from "./helpers"; +import { backendFetch } from "./helpers"; async function backendUp(): Promise { try { - const res = await fetch(`${BACKEND}/v1/health`); + const res = await backendFetch("/v1/health"); return res.ok; } catch { return false; @@ -15,7 +15,7 @@ async function backendUp(): Promise { test("health reports ok with the fields the GUI reads", async () => { test.skip(!(await backendUp()), "backend not running on :8765"); - const s = await (await fetch(`${BACKEND}/v1/health`)).json(); + const s = await (await backendFetch("/v1/health")).json(); expect(s.status).toBe("ok"); // The GUI's boot reads these three off /v1/health. expect(s).toHaveProperty("model"); @@ -24,7 +24,7 @@ test("health reports ok with the fields the GUI reads", async () => { test("providers list has the shape the Settings pane expects", async () => { test.skip(!(await backendUp()), "backend not running on :8765"); - const providers = await (await fetch(`${BACKEND}/v1/providers`)).json(); + const providers = await (await backendFetch("/v1/providers")).json(); expect(Array.isArray(providers)).toBe(true); expect(providers.length).toBeGreaterThan(0); // Each descriptor carries what ManageTabs renders: name/title/needs_key/fields/configured. diff --git a/surfaces/gui/e2e-live/helpers.ts b/surfaces/gui/e2e-live/helpers.ts index c7ba0c0c..9a19c040 100644 --- a/surfaces/gui/e2e-live/helpers.ts +++ b/surfaces/gui/e2e-live/helpers.ts @@ -1,4 +1,4 @@ -import { readdirSync, statSync } from "fs"; +import { readFileSync, readdirSync, statSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import type { Page } from "@playwright/test"; @@ -8,10 +8,31 @@ import type { Page } from "@playwright/test"; export const BACKEND = "http://127.0.0.1:8765"; +function sidecarToken(): string { + const state = + process.env.COWORKER_STATE_DIR || + (process.platform === "win32" + ? join(process.env.APPDATA || homedir(), "coworker") + : join(homedir(), ".config", "coworker")); + try { + return readFileSync(join(state, "sidecar-8765.token"), "utf8").trim(); + } catch { + return ""; + } +} + +/** Fetch from the live sidecar with its per-launch authentication token. */ +export function backendFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = sidecarToken(); + if (token) headers.set("X-OpenWorker-Token", token); + return fetch(`${BACKEND}${path}`, { ...init, headers }); +} + /** The expanded scratch base if the backend is up and a model is ready — else null (→ skip). */ export async function scratchBaseIfReady(): Promise { try { - const res = await fetch(`${BACKEND}/v1/settings`); + const res = await backendFetch("/v1/settings"); const s = await res.json(); if (res.ok && s.model_ready) { return String(s.scratch_base || "~/OpenWorker").replace(/^~(?=\/|$)/, homedir()); diff --git a/surfaces/gui/src-tauri/Cargo.lock b/surfaces/gui/src-tauri/Cargo.lock index a6b531c0..e1d34864 100644 --- a/surfaces/gui/src-tauri/Cargo.lock +++ b/surfaces/gui/src-tauri/Cargo.lock @@ -2683,6 +2683,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-single-instance", "tauri-plugin-updater", + "uuid", ] [[package]] diff --git a/surfaces/gui/src-tauri/Cargo.toml b/surfaces/gui/src-tauri/Cargo.toml index c0336aed..e8e721ab 100644 --- a/surfaces/gui/src-tauri/Cargo.toml +++ b/surfaces/gui/src-tauri/Cargo.toml @@ -20,5 +20,6 @@ tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +uuid = { version = "1", features = ["v4"] } # Kept outside the Tauri shell so another product can depend on the same local STT engine. ocw-stt = { path = "../../../stt" } diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index 37cd6e07..460021b4 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -3,8 +3,8 @@ //! Tauri is a thin native window over the existing React SPA. It: //! 1. picks a free localhost port and starts the Python `openworker-server` as a managed //! sidecar on that port (so it never clashes with a hand-run server on 8765); -//! 2. injects `window.__COWORKER_HTTP__` / `__COWORKER_WS__` before the SPA loads, so -//! `api.ts` talks to the sidecar (single codebase — the browser build still hits 8765); +//! 2. injects the sidecar HTTP/WS addresses and per-launch authentication token before the +//! SPA loads (single codebase — the browser build still hits 8765); //! 3. lives in the system tray: closing the window hides it (keeps MyHelper + the scheduler //! running); only tray → Quit stops the sidecar; //! 4. exposes native commands: folder picker, autostart (open-at-login), and keep-awake @@ -28,6 +28,7 @@ use tauri::{ Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent, }; use tauri_plugin_autostart::ManagerExt; +use uuid::Uuid; /// The sidecar server child — killed on exit (orphaned servers have bitten us before). struct ServerProcess(Mutex>); @@ -42,6 +43,10 @@ fn free_port() -> u16 { .unwrap_or(8765) } +fn launch_token() -> String { + format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) +} + /// Path to the server entrypoint. Resolution order: /// 1. `COWORKER_SERVER_BIN` env override. /// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the @@ -574,11 +579,12 @@ async fn install_update( pub fn run() { let port = free_port(); + let api_token = launch_token(); let http = format!("http://127.0.0.1:{port}"); let ws = format!("ws://127.0.0.1:{port}"); // Debug-format yields a quoted JS string literal. let inject = format!( - "window.__COWORKER_HTTP__={http:?};window.__COWORKER_WS__={ws:?};window.__OCW_PLATFORM__={:?};", + "window.__COWORKER_HTTP__={http:?};window.__COWORKER_WS__={ws:?};window.__COWORKER_API_TOKEN__={api_token:?};window.__OCW_PLATFORM__={:?};", std::env::consts::OS ); @@ -630,6 +636,7 @@ pub fn run() { // reparenting check alone leaks both processes on quit. .env("COWORKER_EXIT_WITH_PARENT", "1") .env("COWORKER_PARENT_PID", std::process::id().to_string()) + .env("COWORKER_API_TOKEN", &api_token) // This GUI app has no console, so a console-subsystem child would inherit // invalid std handles and crash a few seconds in when uvicorn writes its logs // (the "Starting coworker…" freeze on Windows). Hand it real handles: the diff --git a/surfaces/gui/src/api.auth.test.ts b/surfaces/gui/src/api.auth.test.ts new file mode 100644 index 00000000..d65be6a9 --- /dev/null +++ b/surfaces/gui/src/api.auth.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getHealth, Session } from "./api"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("authenticates REST and session WebSocket calls with the launch token", async () => { + vi.stubGlobal("__COWORKER_API_TOKEN__", "launch-token"); + const request = vi.fn(async (_url: string, init?: RequestInit) => { + expect(new Headers(init?.headers).get("X-OpenWorker-Token")).toBe("launch-token"); + return { json: async () => ({ status: "ok" }) } as Response; + }); + vi.stubGlobal("fetch", request); + + class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + readyState = FakeWebSocket.CONNECTING; + onmessage: ((event: MessageEvent) => void) | null = null; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + send = vi.fn(); + + constructor( + public readonly url: string, + public readonly protocols?: string | string[], + ) {} + } + vi.stubGlobal("WebSocket", FakeWebSocket); + + await getHealth(); + expect(request).toHaveBeenCalledOnce(); + + const session = new Session("s1", "/workspace", "code", { onEvent: vi.fn() }); + const socket = (session as unknown as { ws: FakeWebSocket }).ws; + expect(socket.protocols).toEqual(["openworker", "launch-token"]); +}); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index da51bfcf..edca6b1c 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1,5 +1,7 @@ import type { SessionInfo, WsEvent } from "./types"; +declare const __COWORKER_DEV_TOKEN__: string; + // Endpoint resolution order: runtime-injected globals (Tauri sets `window.__COWORKER_HTTP__` // for its dynamically-chosen sidecar port) → Vite env → the 127.0.0.1:8765 dev default. This // keeps a single codebase: browser `npm run dev` hits 8765; the desktop shell hits its sidecar. @@ -11,6 +13,29 @@ const wsBase = (): string => (globalThis as any).__COWORKER_WS__ || (import.meta as any).env?.VITE_COWORKER_WS || "ws://127.0.0.1:8765"; +const apiToken = (): string => + (globalThis as any).__COWORKER_API_TOKEN__ || + (import.meta as any).env?.VITE_COWORKER_API_TOKEN || + (typeof __COWORKER_DEV_TOKEN__ === "string" ? __COWORKER_DEV_TOKEN__ : ""); + +// All local REST calls pass through this module, so a module-local wrapper applies launch +// authentication without asking every endpoint helper to remember the security header. +const fetch = ( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise => { + const headers = new Headers(init.headers); + const token = apiToken(); + if (token) headers.set("X-OpenWorker-Token", token); + return globalThis.fetch(input, { ...init, headers }); +}; + +const openWebSocket = (url: string): WebSocket => { + const token = apiToken(); + return token + ? new WebSocket(url, ["openworker", token]) + : new WebSocket(url); +}; export interface Health { status: string; @@ -1388,7 +1413,7 @@ export function connectEvents( let closed = false; const open = () => { if (closed) return; - ws = new WebSocket(`${wsBase()}/ws/events`); + ws = openWebSocket(`${wsBase()}/ws/events`); ws.onmessage = (e) => { try { onEvent(JSON.parse(e.data)); @@ -1715,7 +1740,7 @@ export class Session { constructor(sessionId: string, workspace: string, agent: string, handlers: Handlers) { const q = `?workspace=${encodeURIComponent(workspace)}&agent=${encodeURIComponent(agent)}`; - this.ws = new WebSocket(`${wsBase()}/ws/session/${sessionId}${q}`); + this.ws = openWebSocket(`${wsBase()}/ws/session/${sessionId}${q}`); this.ws.onmessage = (e) => handlers.onEvent(JSON.parse(e.data)); this.ws.onopen = () => { this.flush(); diff --git a/surfaces/gui/vite.config.ts b/surfaces/gui/vite.config.ts index 8673d47a..1264a267 100644 --- a/surfaces/gui/vite.config.ts +++ b/surfaces/gui/vite.config.ts @@ -1,16 +1,36 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; // `base: "./"` makes built asset URLs relative, so the bundle loads from the `tauri://` // origin in the desktop shell (absolute `/assets` 404s there); a server-hosted build is // unaffected. Dev runs on a fixed port (1420) with strictPort so the Tauri webview always // loads the vite instance Tauri itself spawns (a drifting port would make the window load a // stale/other server). `tauri.conf.json` devUrl must match this. -export default defineConfig({ - base: "./", - plugins: [react()], - server: { port: 1420, strictPort: true }, - // Tauri CLI looks for these; harmless for the browser build. - clearScreen: false, - envPrefix: ["VITE_", "TAURI_"], +export default defineConfig(({ command }) => { + let devToken = ""; + if (command === "serve") { + const state = + process.env.COWORKER_STATE_DIR || + (process.platform === "win32" + ? path.join(process.env.APPDATA || os.homedir(), "coworker") + : path.join(os.homedir(), ".config", "coworker")); + try { + devToken = fs.readFileSync(path.join(state, "sidecar-8765.token"), "utf8").trim(); + } catch { + // The Tauri dev shell injects its in-memory token at runtime. Plain browser dev + // shows the normal startup retry until the standalone server/token file exists. + } + } + return { + base: "./", + plugins: [react()], + server: { port: 1420, strictPort: true }, + define: { __COWORKER_DEV_TOKEN__: JSON.stringify(devToken) }, + // Tauri CLI looks for these; harmless for the browser build. + clearScreen: false, + envPrefix: ["VITE_", "TAURI_"], + }; }); diff --git a/tests/conftest.py b/tests/conftest.py index 0b56b69c..61abb579 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ def _isolated_state_dir(tmp_path, monkeypatch): sign-in, which made test session creation emit REAL telemetry to prod (found 2026-07-03 as burst noise in the ocw-connect-telemetry-events table).""" monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "coworker-state")) + monkeypatch.delenv("COWORKER_API_TOKEN", raising=False) @pytest_asyncio.fixture diff --git a/tests/test_cloud_server.py b/tests/test_cloud_server.py index 4a4d63a8..12f33e7d 100644 --- a/tests/test_cloud_server.py +++ b/tests/test_cloud_server.py @@ -9,6 +9,12 @@ from fastapi.testclient import TestClient from coworker.server import SessionManager, create_app +def _allow_managed_state(state: str = "s") -> None: + from coworker import cloud + + cloud._pending_managed_states[state] = cloud._now() + + @pytest.fixture def client(tmp_path, monkeypatch): monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) @@ -38,6 +44,7 @@ def test_connect_managed_requires_sign_in(client): def test_oauth_callback_writes_profile_and_returns_page(client): + _allow_managed_state() resp = client.post( "/oauth/callback", data={ @@ -72,9 +79,10 @@ def test_oauth_callback_writes_profile_and_returns_page(client): def test_oauth_callback_error_shows_failure_page(client): + _allow_managed_state() resp = client.post( "/oauth/callback", - data={"connector": "gmail", "error": "access_denied"}, + data={"connector": "gmail", "error": "access_denied", "app_state": "s"}, ) assert resp.status_code == 400 assert "access_denied" in resp.text @@ -83,14 +91,31 @@ def test_oauth_callback_error_shows_failure_page(client): def test_oauth_callback_rejects_unmanaged_connector(client): # telegram is manual-only (github gained a managed path with the App relay) + _allow_managed_state() resp = client.post( "/oauth/callback", - data={"connector": "telegram", "access_token": "x"}, + data={"connector": "telegram", "access_token": "x", "app_state": "s"}, ) assert resp.status_code == 400 assert client.manager.secrets.get("telegram:default") is None +def test_oauth_callback_rejects_unknown_and_replayed_state(client): + form = { + "provider": "google", + "connector": "gmail", + "access_token": "token", + "account": "a@b.c", + "app_state": "once", + } + assert client.post("/oauth/callback", data=form).status_code == 400 + assert client.manager.secrets.get("gmail:default") is None + + _allow_managed_state("once") + assert client.post("/oauth/callback", data=form).status_code == 200 + assert client.post("/oauth/callback", data=form).status_code == 400 + + def test_auth_callback_rejects_unknown_state(client): resp = client.get("/auth/callback", params={"code": "c", "state": "forged"}) assert resp.status_code == 400 diff --git a/tests/test_github_installs.py b/tests/test_github_installs.py index 87fb1f86..907dd2e6 100644 --- a/tests/test_github_installs.py +++ b/tests/test_github_installs.py @@ -43,6 +43,8 @@ def client(tmp_path, monkeypatch): def _install_form(installation_id: str, *, login="octocat", account="acme") -> dict: """The broker's loopback POST — deliberately NO token fields (§4).""" + state = f"github-{installation_id}" + cloud._pending_managed_states[state] = cloud._now() return { "connector": "github", "installation_id": installation_id, @@ -51,6 +53,7 @@ def _install_form(installation_id: str, *, login="octocat", account="acme") -> d "github_login": login, "repo_selection": "selected", "connection_id": f"conn_{installation_id}", + "app_state": state, } diff --git a/tests/test_hubspot_portals.py b/tests/test_hubspot_portals.py index a272032d..19a836b3 100644 --- a/tests/test_hubspot_portals.py +++ b/tests/test_hubspot_portals.py @@ -201,6 +201,9 @@ def client(tmp_path, monkeypatch): def test_managed_callback_lands_in_portal_profile(client): + import coworker.cloud as cloud + + cloud._pending_managed_states["s"] = cloud._now() resp = client.post( "/oauth/callback", data={ diff --git a/tests/test_server.py b/tests/test_server.py index a6a5ffbe..af870c51 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -466,6 +466,23 @@ def test_server_sets_explicit_websocket_frame_limit(tmp_path, monkeypatch): assert seen["ws_max_size"] == server_run._WS_MAX_FRAME_BYTES +def test_standalone_server_token_file_is_user_only(tmp_path, monkeypatch): + import os + + from coworker.server import run as server_run + + monkeypatch.delenv("COWORKER_API_TOKEN", raising=False) + path = server_run._ensure_api_token(9876) + try: + assert path == tmp_path / "coworker-state" / "sidecar-9876.token" + assert path.read_text().strip() == os.environ["COWORKER_API_TOKEN"] + assert len(path.read_text().strip()) == 64 + assert (path.stat().st_mode & 0o777) == 0o600 + finally: + path.unlink(missing_ok=True) + os.environ.pop("COWORKER_API_TOKEN", None) + + def test_ws_error_persists_notice_and_retry_reruns(tmp_path): class FlakyProvider(ProviderClient): def __init__(self): @@ -535,6 +552,57 @@ def test_ws_allows_webview_origin(tmp_path): assert ws.receive_json()["type"] == "ready" +def test_sidecar_token_gates_rest_and_websockets(tmp_path, monkeypatch): + from coworker.mcp.config import global_mcp_path + from starlette.websockets import WebSocketDisconnect as WSD + + monkeypatch.setenv("COWORKER_API_TOKEN", "a" * 64) + manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([])) + client = TestClient(create_app(manager)) + + assert client.get("/v1/health").json() == {"status": "ok"} + assert client.get("/v1/sessions").status_code == 401 + assert client.get( + "/v1/sessions", headers={"X-OpenWorker-Token": "wrong"} + ).status_code == 401 + + headers = {"X-OpenWorker-Token": "a" * 64} + assert client.get("/v1/health", headers=headers).json()[ + "default_workspace" + ] == str(tmp_path.resolve()) + assert client.get("/v1/sessions", headers=headers).status_code == 200 + + rejected = client.post( + "/v1/mcp", + json={"name": "evil", "config": {"command": "sh", "args": ["-c", "id"]}}, + ) + assert rejected.status_code == 401 + assert not global_mcp_path().exists() + + with pytest.raises(WSD) as denied: + with client.websocket_connect("/ws/session/tokenless") as ws: + ws.receive_json() + assert denied.value.code == 1008 + + with client.websocket_connect( + "/ws/session/authed", subprotocols=["openworker", "a" * 64] + ) as ws: + assert ws.accepted_subprotocol == "openworker" + assert ws.receive_json()["type"] == "ready" + + with client.websocket_connect( + "/ws/events", subprotocols=["openworker", "a" * 64] + ) as ws: + assert ws.accepted_subprotocol == "openworker" + + # Redirect callbacks remain tokenless, then enforce their own signed state. + assert client.get( + "/auth/callback", params={"code": "x", "state": "bad"} + ).status_code == 400 + assert client.get("/mcp/oauth/callback").status_code == 400 + assert client.post("/oauth/callback", data={"app_state": "bad"}).status_code == 400 + + def test_ws_approval_round_trip(tmp_path): client = _client( tmp_path, diff --git a/tests/test_slack_workspaces.py b/tests/test_slack_workspaces.py index 1e131234..4b1cd9ef 100644 --- a/tests/test_slack_workspaces.py +++ b/tests/test_slack_workspaces.py @@ -11,6 +11,7 @@ from __future__ import annotations import pytest from fastapi.testclient import TestClient +from coworker import cloud from coworker.server import SessionManager, create_app @@ -25,6 +26,8 @@ def client(tmp_path, monkeypatch): def _install_form(team_id: str) -> dict: + state = f"slack-{team_id}" + cloud._pending_managed_states[state] = cloud._now() return { "connector": "slack", "team_id": team_id, @@ -33,6 +36,7 @@ def _install_form(team_id: str) -> dict: "account": f"Workspace {team_id}", "team_domain": f"dom-{team_id.lower()}", "connection_id": f"conn_{team_id}", + "app_state": state, }