mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-10 22:20:27 +00:00
security: complete local access protections
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
Generated
+1
@@ -2683,6 +2683,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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_"],
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user