diff --git a/.env.example b/.env.example index 221b2ad5..db91efa4 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,12 @@ AUTOCONTEXT_ALLOW_PRIMEINTELLECT_FALLBACK=true # Local sandbox behavior AUTOCONTEXT_LOCAL_SANDBOX_HARDENED=true +# API server (autoctx serve / autoctx tui) +# Comma-separated origins allowed to call the HTTP API cross-origin. +# Defaults cover local GUI clients (cowork desktop and dev servers); +# set this for remote or custom GUI deployments. +AUTOCONTEXT_CORS_ORIGINS=http://localhost:1420,http://localhost:4173,http://localhost:3000,tauri://localhost + # Optional browser exploration (secure defaults) AUTOCONTEXT_BROWSER_ENABLED=false AUTOCONTEXT_BROWSER_BACKEND=chrome-cdp diff --git a/autocontext/README.md b/autocontext/README.md index d1ca309b..91d7466e 100644 --- a/autocontext/README.md +++ b/autocontext/README.md @@ -147,7 +147,7 @@ Start the API server: uv run autoctx serve --host 127.0.0.1 --port 8000 ``` -Inspect `http://127.0.0.1:8000/` for the API index after the server starts. For an interactive terminal UI, use the TypeScript package: `npx autoctx tui`. +Inspect `http://127.0.0.1:8000/` for the API index after the server starts. Browser-based GUI clients calling the HTTP API cross-origin are allowed via CORS for local app origins by default (the cowork desktop webview and dev servers); set `AUTOCONTEXT_CORS_ORIGINS` to a comma-separated origin list for remote or custom GUI deployments. For an interactive terminal UI, use the TypeScript package: `npx autoctx tui`. Run a persistent queue worker beside the API server: diff --git a/autocontext/src/autocontext/server/app.py b/autocontext/src/autocontext/server/app.py index b603fbaf..87a1570b 100644 --- a/autocontext/src/autocontext/server/app.py +++ b/autocontext/src/autocontext/server/app.py @@ -2,10 +2,12 @@ import asyncio import logging +import os from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware from pydantic import ValidationError from autocontext.config import load_settings @@ -101,6 +103,23 @@ def create_app( ) -> FastAPI: """Factory that creates the FastAPI app, optionally wired to a LoopController.""" application = FastAPI(title="autocontext API", version="0.1.0") + # Local GUI clients (cowork desktop webview, browser dev servers) call the + # HTTP API cross-origin. The engine binds to localhost, so allowing + # explicit local-app origins is safe; override via AUTOCONTEXT_CORS_ORIGINS. + cors_origins = [ + origin.strip() + for origin in os.environ.get( + "AUTOCONTEXT_CORS_ORIGINS", + "http://localhost:1420,http://localhost:4173,http://localhost:3000,tauri://localhost", + ).split(",") + if origin.strip() + ] + application.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["GET", "PUT", "POST"], + allow_headers=["content-type"], + ) application.include_router(cockpit_router) application.include_router(hub_router) application.include_router(knowledge_router) diff --git a/autocontext/tests/test_server_cors.py b/autocontext/tests/test_server_cors.py new file mode 100644 index 00000000..8476ec76 --- /dev/null +++ b/autocontext/tests/test_server_cors.py @@ -0,0 +1,54 @@ +"""CORS for local GUI clients (cowork desktop webview, browser dev servers).""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from autocontext.server.app import create_app + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setenv("AUTOCONTEXT_KNOWLEDGE_ROOT", str(tmp_path / "knowledge")) + monkeypatch.setenv("AUTOCONTEXT_DB_PATH", str(tmp_path / "runs.sqlite3")) + monkeypatch.setenv("AUTOCONTEXT_AGENT_PROVIDER", "deterministic") + return TestClient(create_app()) + + +def test_get_allows_local_gui_origin(client: TestClient) -> None: + response = client.get("/api/runs", headers={"Origin": "http://localhost:1420"}) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "http://localhost:1420" + + +def test_put_preflight_allows_tauri_origin(client: TestClient) -> None: + response = client.options( + "/api/knowledge/grid_ctf", + headers={ + "Origin": "tauri://localhost", + "Access-Control-Request-Method": "PUT", + "Access-Control-Request-Headers": "content-type", + }, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "tauri://localhost" + assert "PUT" in response.headers["access-control-allow-methods"] + + +def test_unknown_origin_gets_no_cors_headers(client: TestClient) -> None: + response = client.get("/api/runs", headers={"Origin": "https://evil.example"}) + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers + + +def test_origins_overridable_via_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AUTOCONTEXT_KNOWLEDGE_ROOT", str(tmp_path / "knowledge")) + monkeypatch.setenv("AUTOCONTEXT_DB_PATH", str(tmp_path / "runs.sqlite3")) + monkeypatch.setenv("AUTOCONTEXT_AGENT_PROVIDER", "deterministic") + monkeypatch.setenv("AUTOCONTEXT_CORS_ORIGINS", "http://localhost:9999") + client = TestClient(create_app()) + allowed = client.get("/api/runs", headers={"Origin": "http://localhost:9999"}) + assert allowed.headers["access-control-allow-origin"] == "http://localhost:9999" + default_gone = client.get("/api/runs", headers={"Origin": "http://localhost:1420"}) + assert "access-control-allow-origin" not in default_gone.headers