security: complete local access protections

This commit is contained in:
Rohit P
2026-07-24 18:44:39 -07:00
parent 3f5ac872ca
commit ac83bc0490
20 changed files with 361 additions and 32 deletions
+3 -1
View File
@@ -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 `<state-dir>/sidecar-8765.token`; restart
Vite if the server is restarted.
## Run the desktop app from source
+4 -4
View File
@@ -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<boolean> {
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<boolean> {
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.
+23 -2
View File
@@ -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<Response> {
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<string | null> {
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());
+1
View File
@@ -2683,6 +2683,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"uuid",
]
[[package]]
+1
View File
@@ -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" }
+10 -3
View File
@@ -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<Option<Child>>);
@@ -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
+38
View File
@@ -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"]);
});
+27 -2
View File
@@ -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<Response> => {
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();
+27 -7
View File
@@ -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_"],
};
});